Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd25c0ac2b | ||
|
|
8ae6abd9d0 | ||
|
|
b30db8a4c9 | ||
|
|
6955bc4f71 | ||
|
|
0a06c813bc | ||
|
|
c4fb117b93 | ||
|
|
eeae1fdaaa | ||
|
|
7abbbb1b8d | ||
|
|
95bc254640 | ||
|
|
75a2493dee | ||
|
|
53ac2b7ba8 | ||
|
|
4b73d0b8e9 | ||
|
|
b587a7d9a7 | ||
|
|
ef43d69309 | ||
|
|
63d9147cd7 | ||
|
|
e9df9d6bdd | ||
|
|
84467f6dfd | ||
|
|
0771d394bd | ||
|
|
099a7c97be | ||
|
|
fa3005b7b5 | ||
|
|
1db36d4968 | ||
|
|
ffd42eb3d3 | ||
|
|
4e1ed9a4c5 | ||
|
|
cbb938a28f | ||
|
|
f1b0896f7e | ||
|
|
4a92f4686f | ||
|
|
bc197f9568 | ||
|
|
36c374d746 | ||
|
|
e22a4cf98c | ||
|
|
59a60740fa | ||
|
|
d0345617d5 | ||
|
|
9073dc0406 | ||
|
|
b40f547740 | ||
|
|
6abb8874cd | ||
|
|
c95d52aabb | ||
|
|
fc4217f1cb | ||
|
|
bf1256ecec | ||
|
|
950c639b0a | ||
|
|
305aeb085c | ||
|
|
d491a249e9 |
+74
-23
@@ -5,12 +5,20 @@ name: agent
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
# Pinned opencode version — used to install it and to key the CI cache below.
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: "1.17.13"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
agent:
|
agent:
|
||||||
# One run at a time PER ISSUE: two quick comments on the same issue would otherwise race —
|
# One run at a time PER ISSUE: two quick comments on the same issue would otherwise race —
|
||||||
# both checking out ai/issue-N, pushing (non-fast-forward loss) and double-posting. Queued
|
# both checking out ai/issue-N, pushing (non-fast-forward loss) and double-posting. Queued
|
||||||
# runs wait (no cancel) so every trigger is still processed, just serially.
|
# runs wait (no cancel) so every trigger is still processed, just serially.
|
||||||
|
# KNOWN CAVEAT: a run triggered on the PR thread groups under the PR number, not the origin
|
||||||
|
# issue (that mapping is only resolved later, in route.sh) — so an issue-thread run and a
|
||||||
|
# PR-thread run for the SAME work item can overlap. Accepted: they post to different threads,
|
||||||
|
# and the branch is only mutated by dev runs, which resume serially per thread.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ai-agent-${{ github.repository }}-${{ github.event.issue.number }}
|
group: ai-agent-${{ github.repository }}-${{ github.event.issue.number }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
@@ -25,27 +33,21 @@ jobs:
|
|||||||
github.event.comment.user.login == 'senior' ||
|
github.event.comment.user.login == 'senior' ||
|
||||||
github.event.comment.user.login == 'lead' ||
|
github.event.comment.user.login == 'lead' ||
|
||||||
github.event.comment.user.login == 'qa' ||
|
github.event.comment.user.login == 'qa' ||
|
||||||
github.event.comment.user.login == 'ops') &&
|
github.event.comment.user.login == 'ops' ||
|
||||||
|
github.event.comment.user.login == 'intern') &&
|
||||||
!contains(github.event.comment.body, '🤖') &&
|
!contains(github.event.comment.body, '🤖') &&
|
||||||
(contains(github.event.comment.body, '@pm') ||
|
(contains(github.event.comment.body, '@pm') ||
|
||||||
contains(github.event.comment.body, '@junior') ||
|
contains(github.event.comment.body, '@junior') ||
|
||||||
contains(github.event.comment.body, '@senior') ||
|
contains(github.event.comment.body, '@senior') ||
|
||||||
contains(github.event.comment.body, '@lead') ||
|
contains(github.event.comment.body, '@lead') ||
|
||||||
contains(github.event.comment.body, '@qa') ||
|
contains(github.event.comment.body, '@qa') ||
|
||||||
contains(github.event.comment.body, '@ops')))
|
contains(github.event.comment.body, '@ops') ||
|
||||||
|
contains(github.event.comment.body, '@intern')))
|
||||||
runs-on: ci-runner
|
runs-on: ci-runner
|
||||||
|
# Job-level backstop (the per-attempt `timeout` in run-agent.sh is the primary guard): a wedged
|
||||||
|
# job must never hold the single runner slot for hours.
|
||||||
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- name: Acknowledge with 👀
|
|
||||||
env:
|
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
CID: ${{ github.event.comment.id }}
|
|
||||||
NUM: ${{ github.event.issue.number }}
|
|
||||||
run: |
|
|
||||||
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
|
|
||||||
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
|
|
||||||
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" \
|
|
||||||
"$R" -d '{"content":"eyes"}' -w '\nreact -> HTTP %{http_code}\n' || true
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -107,18 +109,53 @@ jobs:
|
|||||||
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
|
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
|
||||||
TOKEN_QA: ${{ secrets.TOKEN_QA }}
|
TOKEN_QA: ${{ secrets.TOKEN_QA }}
|
||||||
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
|
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
|
||||||
|
TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }}
|
||||||
run: bash "$SCRIPTS/route.sh"
|
run: bash "$SCRIPTS/route.sh"
|
||||||
|
|
||||||
|
- name: Acknowledge with 👀 (as the routed agent)
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
|
env:
|
||||||
|
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
|
||||||
|
CID: ${{ github.event.comment.id }}
|
||||||
|
NUM: ${{ github.event.issue.number }}
|
||||||
|
run: |
|
||||||
|
[ -n "$SELF_TOKEN" ] || { echo "no agent token — skipping 👀"; exit 0; }
|
||||||
|
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
|
||||||
|
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
|
||||||
|
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
|
||||||
|
"$R" -d '{"content":"eyes"}' -w '\nreact -> HTTP %{http_code}\n' || true
|
||||||
|
|
||||||
|
- name: Cache opencode CLI
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
|
continue-on-error: true # a cache backend hiccup must never fail an agent run
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.opencode
|
||||||
|
key: opencode-${{ runner.os }}-${{ env.OPENCODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers + npm (browser agents only)
|
||||||
|
if: steps.prep.outputs.mode != 'skip' && (steps.prep.outputs.name == 'senior' || steps.prep.outputs.name == 'lead' || steps.prep.outputs.name == 'qa')
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/ms-playwright
|
||||||
|
~/.npm
|
||||||
|
key: playwright-npm-${{ runner.os }}-v1
|
||||||
|
|
||||||
- name: Install opencode + provider config (+ Playwright MCP for browser agents)
|
- name: Install opencode + provider config (+ Playwright MCP for browser agents)
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
OLLAMA_URL: ${{ secrets.OLLAMA_URL }}
|
OLLAMA_URL: ${{ secrets.OLLAMA_URL }}
|
||||||
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
||||||
|
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||||
NAME: ${{ steps.prep.outputs.name }}
|
NAME: ${{ steps.prep.outputs.name }}
|
||||||
SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load
|
SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load
|
||||||
run: bash "$SCRIPTS/install-opencode.sh"
|
run: bash "$SCRIPTS/install-opencode.sh"
|
||||||
|
|
||||||
- name: Install caller-provided skills (from the caller repo's .gitea/agent-skills/)
|
- name: Install caller-provided skills (from the caller repo's .gitea/agent-skills/)
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
# Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under
|
# Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under
|
||||||
# `.gitea/agent-skills/<name>/` (SKILL.md + skill.json + optional setup.sh) — e.g. homelab's
|
# `.gitea/agent-skills/<name>/` (SKILL.md + skill.json + optional setup.sh) — e.g. homelab's
|
||||||
# "ssh into the deploy host" skill. This installs the ones allowed for the running agent, so
|
# "ssh into the deploy host" skill. This installs the ones allowed for the running agent, so
|
||||||
@@ -133,6 +170,7 @@ jobs:
|
|||||||
run: bash "$SCRIPTS/install-caller-skills.sh"
|
run: bash "$SCRIPTS/install-caller-skills.sh"
|
||||||
|
|
||||||
- name: Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos)
|
- name: Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos)
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
# Emits an opencode Skill file. The skill uses SELF_TOKEN — the running agent's OWN token
|
# Emits an opencode Skill file. The skill uses SELF_TOKEN — the running agent's OWN token
|
||||||
# (e.g. TOKEN_PM for @pm), injected into the Run-agent step below — so each agent talks to
|
# (e.g. TOKEN_PM for @pm), injected into the Run-agent step below — so each agent talks to
|
||||||
# Gitea as itself. This step only writes the doc; permission.skill scopes who may load it.
|
# Gitea as itself. This step only writes the doc; permission.skill scopes who may load it.
|
||||||
@@ -141,6 +179,7 @@ jobs:
|
|||||||
run: bash "$SCRIPTS/skill-gitea-api.sh"
|
run: bash "$SCRIPTS/skill-gitea-api.sh"
|
||||||
|
|
||||||
- name: Set up `gitea-admin` skill (@ops only — administer the Gitea instance)
|
- name: Set up `gitea-admin` skill (@ops only — administer the Gitea instance)
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
# Instance administration (orgs/users/repos/labels/secrets/scoped tokens). The SKILL.md is
|
# Instance administration (orgs/users/repos/labels/secrets/scoped tokens). The SKILL.md is
|
||||||
# written ONLY for @ops (skill-gitea-admin.sh gates on NAME) and permission.skill also denies
|
# written ONLY for @ops (skill-gitea-admin.sh gates on NAME) and permission.skill also denies
|
||||||
# it to every other agent. It uses SELF_TOKEN (which for @ops is TOKEN_OPS), injected into the
|
# it to every other agent. It uses SELF_TOKEN (which for @ops is TOKEN_OPS), injected into the
|
||||||
@@ -151,6 +190,7 @@ jobs:
|
|||||||
run: bash "$SCRIPTS/skill-gitea-admin.sh"
|
run: bash "$SCRIPTS/skill-gitea-admin.sh"
|
||||||
|
|
||||||
- name: Inspect / fetch image attachments (download only for vision agents)
|
- name: Inspect / fetch image attachments (download only for vision agents)
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
id: imgs
|
id: imgs
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
@@ -160,6 +200,7 @@ jobs:
|
|||||||
run: bash "$SCRIPTS/fetch-images.sh"
|
run: bash "$SCRIPTS/fetch-images.sh"
|
||||||
|
|
||||||
- name: Fetch the full issue thread (shared memory)
|
- name: Fetch the full issue thread (shared memory)
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
GT: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -167,20 +208,22 @@ jobs:
|
|||||||
run: bash "$SCRIPTS/fetch-thread.sh"
|
run: bash "$SCRIPTS/fetch-thread.sh"
|
||||||
|
|
||||||
- name: Run agent
|
- name: Run agent
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
id: run
|
id: run
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||||
# SELF_TOKEN = the RUNNING agent's OWN token (TOKEN_PM for @pm, TOKEN_OPS for @ops, …).
|
# SELF_TOKEN = the RUNNING agent's OWN token (TOKEN_PM for @pm, TOKEN_OPS for @ops, …).
|
||||||
# Only this agent's token is placed in its process env, so no agent can act as another.
|
# Only this agent's token is placed in its process env, so no agent can act as another.
|
||||||
# Powers the gitea-api / gitea-admin skills — each agent calls Gitea as itself. Every
|
# Powers the gitea-api / gitea-admin skills — each agent calls Gitea as itself. Every
|
||||||
# consuming repo now carries the per-agent TOKEN_* secrets (org-level for gitea/*, user-level
|
# consuming repo now carries the per-agent TOKEN_* secrets (org-level for gitea/*, user-level
|
||||||
# for ffaerber/*), so there is no shared-token fallback.
|
# for ffaerber/*), so there is no shared-token fallback.
|
||||||
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || '' }}
|
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
|
||||||
NAME: ${{ steps.prep.outputs.name }}
|
NAME: ${{ steps.prep.outputs.name }}
|
||||||
MODEL: ${{ steps.prep.outputs.model }}
|
MODEL: ${{ steps.prep.outputs.model }}
|
||||||
VISION: ${{ steps.prep.outputs.vision }}
|
VISION: ${{ steps.prep.outputs.vision }}
|
||||||
MODE: ${{ steps.prep.outputs.mode }}
|
MODE: ${{ steps.prep.outputs.mode }}
|
||||||
|
WORKMODE: ${{ steps.prep.outputs.workmode }} # build | discuss (devs consulted in-thread)
|
||||||
HAS_IMAGES: ${{ steps.imgs.outputs.has_images }}
|
HAS_IMAGES: ${{ steps.imgs.outputs.has_images }}
|
||||||
BRANCH: ${{ steps.prep.outputs.branch }}
|
BRANCH: ${{ steps.prep.outputs.branch }}
|
||||||
AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the issue carries the `autopilot` label
|
AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the issue carries the `autopilot` label
|
||||||
@@ -192,6 +235,7 @@ jobs:
|
|||||||
run: bash "$SCRIPTS/run-agent.sh"
|
run: bash "$SCRIPTS/run-agent.sh"
|
||||||
|
|
||||||
- name: Build run report (tool calls + input/output tokens + $ cost) from the event stream
|
- name: Build run report (tool calls + input/output tokens + $ cost) from the event stream
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
id: log
|
id: log
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
@@ -200,6 +244,7 @@ jobs:
|
|||||||
run: bash "$SCRIPTS/build-activity-log.sh"
|
run: bash "$SCRIPTS/build-activity-log.sh"
|
||||||
|
|
||||||
- name: Publish — PR (dev agents) or comment (pm), always reply in the issue
|
- name: Publish — PR (dev agents) or comment (pm), always reply in the issue
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
GT: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -209,8 +254,10 @@ jobs:
|
|||||||
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
|
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
|
||||||
TOKEN_QA: ${{ secrets.TOKEN_QA }}
|
TOKEN_QA: ${{ secrets.TOKEN_QA }}
|
||||||
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
|
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
|
||||||
|
TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }}
|
||||||
NAME: ${{ steps.prep.outputs.name }}
|
NAME: ${{ steps.prep.outputs.name }}
|
||||||
MODE: ${{ steps.prep.outputs.mode }}
|
MODE: ${{ steps.prep.outputs.mode }}
|
||||||
|
WORKMODE: ${{ steps.prep.outputs.workmode }}
|
||||||
NUM: ${{ github.event.issue.number }}
|
NUM: ${{ github.event.issue.number }}
|
||||||
TITLE: ${{ github.event.issue.title }}
|
TITLE: ${{ github.event.issue.title }}
|
||||||
BRANCH: ${{ steps.prep.outputs.branch }}
|
BRANCH: ${{ steps.prep.outputs.branch }}
|
||||||
@@ -225,7 +272,7 @@ jobs:
|
|||||||
# This best-effort step opens a PR for the pushed branch so nothing is silently lost. Runs from
|
# This best-effort step opens a PR for the pushed branch so nothing is silently lost. Runs from
|
||||||
# $SCRIPTS (outside the workspace) so it works even if the tree was mangled by the agent.
|
# $SCRIPTS (outside the workspace) so it works even if the tree was mangled by the agent.
|
||||||
- name: Rescue — open a PR for pushed work if the run failed
|
- name: Rescue — open a PR for pushed work if the run failed
|
||||||
if: failure()
|
if: failure() && steps.prep.outputs.mode != 'skip'
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
GT: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -235,6 +282,7 @@ jobs:
|
|||||||
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
|
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
|
||||||
TOKEN_QA: ${{ secrets.TOKEN_QA }}
|
TOKEN_QA: ${{ secrets.TOKEN_QA }}
|
||||||
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
|
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
|
||||||
|
TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }}
|
||||||
NAME: ${{ steps.prep.outputs.name }}
|
NAME: ${{ steps.prep.outputs.name }}
|
||||||
MODE: ${{ steps.prep.outputs.mode }}
|
MODE: ${{ steps.prep.outputs.mode }}
|
||||||
NUM: ${{ github.event.issue.number }}
|
NUM: ${{ github.event.issue.number }}
|
||||||
@@ -243,24 +291,27 @@ jobs:
|
|||||||
run: bash "$SCRIPTS/rescue-pr.sh" || true
|
run: bash "$SCRIPTS/rescue-pr.sh" || true
|
||||||
|
|
||||||
- name: Mark done with 🚀 (remove 👀)
|
- name: Mark done with 🚀 (remove 👀)
|
||||||
|
if: steps.prep.outputs.mode != 'skip'
|
||||||
env:
|
env:
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
|
||||||
CID: ${{ github.event.comment.id }}
|
CID: ${{ github.event.comment.id }}
|
||||||
NUM: ${{ github.event.issue.number }}
|
NUM: ${{ github.event.issue.number }}
|
||||||
run: |
|
run: |
|
||||||
|
[ -n "$SELF_TOKEN" ] || exit 0
|
||||||
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
|
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
|
||||||
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
|
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
|
||||||
curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
|
curl -sS -X DELETE -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
|
||||||
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"rocket"}' -w '\nreact -> HTTP %{http_code}\n' || true
|
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"rocket"}' -w '\nreact -> HTTP %{http_code}\n' || true
|
||||||
|
|
||||||
- name: Mark failed with 😕 (remove 👀)
|
- name: Mark failed with 😕 (remove 👀)
|
||||||
if: failure()
|
if: failure() && steps.prep.outputs.mode != 'skip'
|
||||||
env:
|
env:
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
|
||||||
CID: ${{ github.event.comment.id }}
|
CID: ${{ github.event.comment.id }}
|
||||||
NUM: ${{ github.event.issue.number }}
|
NUM: ${{ github.event.issue.number }}
|
||||||
run: |
|
run: |
|
||||||
|
[ -n "$SELF_TOKEN" ] || exit 0
|
||||||
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
|
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
|
||||||
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
|
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
|
||||||
curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
|
curl -sS -X DELETE -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
|
||||||
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true
|
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pm": {
|
"pm": {
|
||||||
"model": "ollama-cloud/gemma4:cloud",
|
"model": "ollama-cloud/minimax-m3:cloud",
|
||||||
"vision": true,
|
"vision": true,
|
||||||
"mode": "comment",
|
"mode": "comment",
|
||||||
"skills": [
|
"skills": [
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."
|
"desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."
|
||||||
},
|
},
|
||||||
"lead": {
|
"lead": {
|
||||||
"model": "anthropic/claude-opus-4-8",
|
"model": "xai/grok-4.5",
|
||||||
"vision": true,
|
"vision": true,
|
||||||
"mode": "pr",
|
"mode": "pr",
|
||||||
"skills": [
|
"skills": [
|
||||||
@@ -43,12 +43,19 @@
|
|||||||
"desc": "QA / reviewer — reviews PRs: reads the diff, drives a headless browser (Playwright) to verify behavior, posts specific recommendations on the PR and the pass/fail verdict on the issue. Never edits code, never merges."
|
"desc": "QA / reviewer — reviews PRs: reads the diff, drives a headless browser (Playwright) to verify behavior, posts specific recommendations on the PR and the pass/fail verdict on the issue. Never edits code, never merges."
|
||||||
},
|
},
|
||||||
"ops": {
|
"ops": {
|
||||||
"model": "anthropic/claude-opus-4-8",
|
"model": "xai/grok-4.5",
|
||||||
"vision": false,
|
"vision": false,
|
||||||
"mode": "comment",
|
"mode": "comment",
|
||||||
"skills": [
|
"skills": [
|
||||||
"gitea-admin"
|
"gitea-admin"
|
||||||
],
|
],
|
||||||
"desc": "Gitea operator — administers the Gitea instance itself: create orgs/users/repos, manage labels and secrets, mint scoped per-user tokens, bootstrap new repos with the agent caller. Comments only; never edits code. ALWAYS confirms before any destructive action (delete user/repo/org)."
|
"desc": "Gitea operator — administers the Gitea instance itself: create orgs/users/repos, manage labels and secrets, mint scoped per-user tokens, bootstrap new repos with the agent caller. Comments only; never edits code. ALWAYS confirms before any destructive action (delete user/repo/org)."
|
||||||
|
},
|
||||||
|
"intern": {
|
||||||
|
"model": "ollama/ornith:35b",
|
||||||
|
"vision": false,
|
||||||
|
"mode": "pr",
|
||||||
|
"skills": [],
|
||||||
|
"desc": "Intern — very basic tasks only, routed to the local Ollama model (ornith:35b). Text-only, cannot read images. Escalates anything non-trivial to @junior, @senior or @lead."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,20 +38,20 @@ COST=${COST:-0}; INP=${INP:-0}; OUT=${OUT:-0}; CR=${CR:-0}; CW=${CW:-0}; RE=${RE
|
|||||||
IN_TOTAL=$(( INP + CR + CW )) # total input context processed
|
IN_TOTAL=$(( INP + CR + CW )) # total input context processed
|
||||||
# Cost label: ollama / ollama-cloud models are SUBSCRIPTION-billed (GPU-time against the plan, no
|
# Cost label: ollama / ollama-cloud models are SUBSCRIPTION-billed (GPU-time against the plan, no
|
||||||
# $/token price exists), so a "$0.0000" there would be misleading — label it a subscription instead.
|
# $/token price exists), so a "$0.0000" there would be misleading — label it a subscription instead.
|
||||||
# Metered providers (anthropic/…) get the real dollar cost opencode computed.
|
# Metered providers (xai/…) get the real dollar cost opencode computed.
|
||||||
case "${MODEL:-}" in
|
case "${MODEL:-}" in
|
||||||
ollama*|*"/ollama"*) COSTF="subscription" ;;
|
ollama*|*"/ollama"*) COSTF="subscription" ;;
|
||||||
*) COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}') ;;
|
*) COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}') ;;
|
||||||
esac
|
esac
|
||||||
echo "usage: in=$IN_TOTAL out=$OUT cost=$COSTF (fresh=$INP cache_r=$CR cache_w=$CW reasoning=$RE); tools=$n"
|
echo "usage: in=$IN_TOTAL out=$OUT cost=$COSTF (fresh=$INP cache_r=$CR cache_w=$CW reasoning=$RE); tools=$n"
|
||||||
|
|
||||||
|
# ONE uniform format for every agent comment (with or without tool calls): a collapsed dropdown
|
||||||
|
# with a STATIC "details" label — identical everywhere — holding the tool calls (or a none-note)
|
||||||
|
# and the full token/cost breakdown.
|
||||||
{
|
{
|
||||||
if [ "$n" -gt 0 ]; then
|
printf '\n\n<details>\n<summary>details</summary>\n\n'
|
||||||
printf '\n\n<details>\n<summary>🔧 %s tool calls · in %s · out %s · %s</summary>\n\n' "$n" "$IN_TOTAL" "$OUT" "$COSTF"
|
printf '🔧 %s tool calls · in %s · out %s tokens · %s · model %s\n\n' "$n" "$IN_TOTAL" "$OUT" "$COSTF" "${MODEL:-?}"
|
||||||
cat /tmp/tools.md
|
if [ "$n" -gt 0 ]; then cat /tmp/tools.md; else printf '_(no tool calls — text-only reply)_\n'; fi
|
||||||
printf '\n\n<sub>tokens — input %s (fresh %s · cache %sw / %sr) · output %s · reasoning %s · **%s**</sub>\n</details>' \
|
printf '\n\n<sub>tokens — input %s (fresh %s · cache %sw / %sr) · output %s · reasoning %s · **%s**</sub>\n</details>' \
|
||||||
"$IN_TOTAL" "$INP" "$CW" "$CR" "$OUT" "$RE" "$COSTF"
|
"$IN_TOTAL" "$INP" "$CW" "$CR" "$OUT" "$RE" "$COSTF"
|
||||||
else
|
|
||||||
printf '\n\n<sub>💰 **%s** · in %s · out %s tokens (cache %sw / %sr)</sub>' "$COSTF" "$IN_TOTAL" "$OUT" "$CW" "$CR"
|
|
||||||
fi
|
|
||||||
} > /tmp/activity_log.md
|
} > /tmp/activity_log.md
|
||||||
|
|||||||
@@ -4,14 +4,31 @@
|
|||||||
# to its real author (@pm/@qa/@junior/…). Strip the hidden `<!-- 🤖 … -->` loop-prevention marker
|
# to its real author (@pm/@qa/@junior/…). Strip the hidden `<!-- 🤖 … -->` loop-prevention marker
|
||||||
# from bodies — it's plumbing, not conversation, and would just waste prompt tokens.
|
# from bodies — it's plumbing, not conversation, and would just waste prompt tokens.
|
||||||
#
|
#
|
||||||
|
# PAGINATION: Gitea returns comments ASCENDING and `limit` caps a single page — a bare ?limit=100
|
||||||
|
# used to keep the OLDEST 100 comments and silently drop the newest (the exact opposite of what an
|
||||||
|
# agent needs on a long thread). Fetch all pages (up to 10 = 500 comments) and keep the LAST 100.
|
||||||
|
#
|
||||||
# Required env (provided by the workflow step): GT NUM GITHUB_SERVER_URL GITHUB_REPOSITORY
|
# Required env (provided by the workflow step): GT NUM GITHUB_SERVER_URL GITHUB_REPOSITORY
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=100" 2>/dev/null \
|
: > /tmp/thread_pages.json
|
||||||
| jq -r '.[] |
|
for page in $(seq 1 10); do
|
||||||
( if (.user.login == "ffaerber") then "@ffaerber (the maintainer)"
|
pg=$(curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=50&page=$page" 2>/dev/null) || pg='[]'
|
||||||
else "@" + .user.login end ) as $who |
|
n=$(printf '%s' "$pg" | jq 'if type=="array" then length else 0 end' 2>/dev/null || echo 0)
|
||||||
"### comment by \($who):\n\(.body | gsub("\\s*<!-- 🤖 agent reply — do not trigger -->"; ""))\n"' \
|
[ "${n:-0}" -gt 0 ] && printf '%s\n' "$pg" >> /tmp/thread_pages.json
|
||||||
> /tmp/thread.md 2>/dev/null || true
|
[ "${n:-0}" -lt 50 ] && break
|
||||||
echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0)"
|
done
|
||||||
|
jq -rs '
|
||||||
|
add // [] | .[-100:] | .[] |
|
||||||
|
( if (.user.login == "ffaerber") then "@ffaerber (the maintainer)"
|
||||||
|
else "@" + .user.login end ) as $who |
|
||||||
|
"### comment by \($who):\n\(.body | gsub("\\s*<!-- 🤖 agent reply — do not trigger -->"; ""))\n"' \
|
||||||
|
/tmp/thread_pages.json > /tmp/thread.md 2>/dev/null || : > /tmp/thread.md
|
||||||
|
echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0) (newest 100 kept)"
|
||||||
|
|
||||||
|
# Record the newest comment id on the thread BEFORE the agent runs. publish.sh compares against
|
||||||
|
# it to detect an agent that self-posted its reply mid-run (via the gitea-api skill, despite the
|
||||||
|
# prompt telling it not to) and skips the duplicate framework reply. Ids are monotonic — no dates.
|
||||||
|
jq -rs '[ (add // [])[].id ] | max // 0' /tmp/thread_pages.json > /tmp/thread_max_cid 2>/dev/null || echo 0 > /tmp/thread_max_cid
|
||||||
|
echo "pre-run newest comment id: $(cat /tmp/thread_max_cid)"
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Install opencode + provider config (+ Playwright MCP for browser agents).
|
# Install opencode + provider config (+ Playwright MCP for browser agents).
|
||||||
#
|
#
|
||||||
# Required env (provided by the workflow step): OLLAMA_URL OLLAMA_CLOUD_API_KEY NAME SKILLS
|
# Required env (provided by the workflow step): OLLAMA_URL OLLAMA_CLOUD_API_KEY XAI_API_KEY
|
||||||
# GITHUB_PATH HOME
|
# NAME SKILLS GITHUB_PATH HOME
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# PIN the opencode version: an unpinned `latest` means a breaking release (CLI flags, or the
|
# PIN the opencode version: an unpinned `latest` means a breaking release (CLI flags, or the
|
||||||
# --format json event schema that build-activity-log.sh parses) breaks every agent in every repo
|
# --format json event schema that build-activity-log.sh parses) breaks every agent in every repo
|
||||||
# at once. Bump deliberately by changing this default (or set OPENCODE_VERSION in the step env).
|
# at once. Bump deliberately by changing this default (or set OPENCODE_VERSION in the step env).
|
||||||
OPENCODE_VERSION="${OPENCODE_VERSION:-1.17.13}"
|
OPENCODE_VERSION="${OPENCODE_VERSION:-1.17.13}"
|
||||||
curl -fsSL https://opencode.ai/install | bash -s -- --version "$OPENCODE_VERSION"
|
# Skip the download when a cache hit already restored the pinned binary (see the Cache
|
||||||
|
# opencode CLI step in agent.yml). The installer always re-fetches otherwise.
|
||||||
|
OC_BIN="$HOME/.opencode/bin/opencode"
|
||||||
|
if [ -x "$OC_BIN" ] && "$OC_BIN" --version 2>/dev/null | grep -qF "$OPENCODE_VERSION"; then
|
||||||
|
echo "opencode $OPENCODE_VERSION already present (cache hit) — skipping install"
|
||||||
|
else
|
||||||
|
curl -fsSL https://opencode.ai/install | bash -s -- --version "$OPENCODE_VERSION"
|
||||||
|
fi
|
||||||
echo "$HOME/.opencode/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.opencode/bin" >> "$GITHUB_PATH"
|
||||||
mkdir -p ~/.config/opencode
|
mkdir -p ~/.config/opencode
|
||||||
# Playwright browser MCP only for agents that need to drive a web app
|
# Playwright browser MCP only for agents that need to drive a web app
|
||||||
@@ -31,17 +38,22 @@ esac
|
|||||||
SKILLS="${SKILLS:-[]}"
|
SKILLS="${SKILLS:-[]}"
|
||||||
PERM=$(jq -nc --argjson s "$SKILLS" '
|
PERM=$(jq -nc --argjson s "$SKILLS" '
|
||||||
{skill: ( {"*":"deny"} + (reduce $s[] as $k ({}; . + {($k):"allow"})) )}')
|
{skill: ( {"*":"deny"} + (reduce $s[] as $k ({}; . + {($k):"allow"})) )}')
|
||||||
# Two ollama providers: local self-hosted (ornith) + Ollama Cloud (gemma4/kimi-k2.7-code/glm-5.2/minimax-m3).
|
# Three OpenAI-compatible providers: local self-hosted ollama (ornith) + Ollama Cloud
|
||||||
# The ollama-cloud `models:` map is DERIVED from agents.json (the single source of truth, shared with
|
# (gemma4/kimi-k2.7-code/glm-5.2/minimax-m3) + xAI (grok-4.5). The provider `models:` maps are
|
||||||
# route.sh) so every model an agent is routed to is always declared in the provider config. Only the
|
# DERIVED from agents.json (the single source of truth, shared with route.sh) so every model an
|
||||||
# `ollama-cloud/` provider prefix models participate — e.g. `anthropic/claude-opus-4-8` (@lead) is a
|
# agent is routed to is always declared in the provider config. `ollama-cloud/` prefix models go to
|
||||||
# built-in provider and `ornith:35b` is local-only, neither belongs here. See issue #31.
|
# the cloud provider; `ollama/` prefix models go to the local provider; `xai/` prefix models go to
|
||||||
|
# the xAI provider (OpenAI-compatible, https://api.x.ai/v1). No other built-in providers remain.
|
||||||
|
# See issue #31.
|
||||||
AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json"
|
AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json"
|
||||||
CLOUD_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama-cloud/")) | sub("^ollama-cloud/";"")] | map({(.):{}}) | add // {}' "$AGENTS_JSON")
|
CLOUD_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama-cloud/")) | sub("^ollama-cloud/";"")] | map({(.):{}}) | add // {}' "$AGENTS_JSON")
|
||||||
jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --argjson cloud "$CLOUD_MODELS" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" '{
|
LOCAL_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama/")) | sub("^ollama/";"")] | map({(.):{}}) | add // {"ornith:35b":{}}' "$AGENTS_JSON")
|
||||||
|
XAI_MODELS=$(jq -r '[.[] | .model | select(startswith("xai/")) | sub("^xai/";"")] | map({(.):{}}) | add // {}' "$AGENTS_JSON")
|
||||||
|
jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --argjson cloud "$CLOUD_MODELS" --argjson local "$LOCAL_MODELS" --argjson xai "$XAI_MODELS" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" --arg xkey "$XAI_API_KEY" '{
|
||||||
provider: {
|
provider: {
|
||||||
ollama: {npm:"@ai-sdk/openai-compatible", options:{baseURL:($url+"/v1")}, models:{"ornith:35b":{}}},
|
ollama: {npm:"@ai-sdk/openai-compatible", options:{baseURL:($url+"/v1")}, models:$local},
|
||||||
"ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:$cloud}
|
"ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:$cloud},
|
||||||
|
xai: {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://api.x.ai/v1", apiKey:$xkey}, models:$xai}
|
||||||
},
|
},
|
||||||
permission: $perm,
|
permission: $perm,
|
||||||
mcp: $mcp
|
mcp: $mcp
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ set +e # publish is best-effort: a grep-no-match / curl non-zero must NOT kill
|
|||||||
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
|
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
|
||||||
case "$NAME" in
|
case "$NAME" in
|
||||||
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
|
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
|
||||||
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";;
|
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; intern) TOK="$TOKEN_INTERN";; *) TOK="";;
|
||||||
esac
|
esac
|
||||||
[ -z "$TOK" ] && TOK="$GT"
|
[ -z "$TOK" ] && TOK="$GT"
|
||||||
# Trigger token: comments that must FIRE the next workflow (delegation, autopilot) and PR merges
|
# Trigger token: comments that must FIRE the next workflow (delegation, autopilot) and PR merges
|
||||||
@@ -44,8 +44,14 @@ trig() { if [ -z "$TTOK" ]; then echo "no trigger token — cannot fire on #$1";
|
|||||||
# Origin issue for this run (route.sh resolves it from the branch on PR threads), and a resolver for
|
# Origin issue for this run (route.sh resolves it from the branch on PR threads), and a resolver for
|
||||||
# the open PR built from its branch (ai/issue-<issue>). Lets @pm/@qa cross between the issue and PR.
|
# the open PR built from its branch (ai/issue-<issue>). Lets @pm/@qa cross between the issue and PR.
|
||||||
ISSN="${ISSNUM:-$NUM}"
|
ISSN="${ISSNUM:-$NUM}"
|
||||||
resolve_pr() { curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \
|
# All OPEN PRs belonging to this issue, oldest→newest. Matches ai/issue-N AND the ai/issue-N-<slug>
|
||||||
| jq -r --arg br "ai/issue-$ISSN" 'if type=="array" then (map(select(.head.ref==$br))|.[0].number // empty) else empty end' 2>/dev/null; }
|
# split branches AGENTS.md tells devs to use — an exact-only match silently stalled the flow on
|
||||||
|
# slugged branches (DELEGATE:@qa found "no open PR"; autopilot MERGE_PR couldn't merge).
|
||||||
|
resolve_prs() { curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \
|
||||||
|
| jq -r --arg br "ai/issue-$ISSN" 'if type=="array" then
|
||||||
|
([ .[] | select(.head.ref==$br or (.head.ref|startswith($br+"-"))) | .number ] | sort | join(" "))
|
||||||
|
else "" end' 2>/dev/null; }
|
||||||
|
resolve_pr() { resolve_prs | awk '{print $NF}'; } # newest open PR (empty if none)
|
||||||
# Remove the 'autopilot' label from an issue by resolving its ID first (Gitea's DELETE label
|
# Remove the 'autopilot' label from an issue by resolving its ID first (Gitea's DELETE label
|
||||||
# endpoint is by ID, not name). Arg $1 = issue number. Used as the autopilot kill switch.
|
# endpoint is by ID, not name). Arg $1 = issue number. Used as the autopilot kill switch.
|
||||||
del_autopilot_label() {
|
del_autopilot_label() {
|
||||||
@@ -60,15 +66,17 @@ del_autopilot_label() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / APPROVE / HALT / BOUNCE, and the
|
# drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / RETRO / APPROVE / HALT / BOUNCE,
|
||||||
# BEGIN_SUBTASKS..END_SUBTASKS and BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR
|
# and the BEGIN_SUBTASKS..END_SUBTASKS and BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR
|
||||||
# description is published separately).
|
# description is published separately).
|
||||||
reply=$(awk '
|
reply=$(awk '
|
||||||
/^[[:space:]]*BEGIN_SUBTASKS/{s=1}
|
/^[[:space:]]*BEGIN_SUBTASKS/{s=1}
|
||||||
/^[[:space:]]*BEGIN_PR_DESCRIPTION/{p=1}
|
/^[[:space:]]*BEGIN_PR_DESCRIPTION/{p=1}
|
||||||
/^[[:space:]]*DELEGATE:[[:space:]]*@/{next}
|
/^[[:space:]]*DELEGATE:[[:space:]]*@/{next}
|
||||||
|
/^[[:space:]]*ASK:[[:space:]]*@/{next}
|
||||||
/^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next}
|
/^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next}
|
||||||
/^[[:space:]]*MERGE_PR[[:space:]]*$/{next}
|
/^[[:space:]]*MERGE_PR[[:space:]]*$/{next}
|
||||||
|
/^[[:space:]]*RETRO[[:space:]]*$/{next}
|
||||||
/^[[:space:]]*APPROVE[[:space:]]*$/{next}
|
/^[[:space:]]*APPROVE[[:space:]]*$/{next}
|
||||||
/^[[:space:]]*HALT([_ ]AUTOPILOT)?[[:space:]]*$/{next}
|
/^[[:space:]]*HALT([_ ]AUTOPILOT)?[[:space:]]*$/{next}
|
||||||
/^[[:space:]]*BOUNCE:[[:space:]]*@/{next}
|
/^[[:space:]]*BOUNCE:[[:space:]]*@/{next}
|
||||||
@@ -101,26 +109,37 @@ if [ "$MODE" != "pr" ]; then
|
|||||||
git clean -fd 2>/dev/null || true
|
git clean -fd 2>/dev/null || true
|
||||||
|
|
||||||
# ---------- @qa: reviewer only — never edits, never merges ----------
|
# ---------- @qa: reviewer only — never edits, never merges ----------
|
||||||
# Recommendations land ON THE PR (onsite the diff); the pass/fail verdict lands ON THE ISSUE so
|
# ALL technical review detail lands ON THE PR (onsite the diff); the ISSUE gets only the terse
|
||||||
# @pm (who never reads the PR) can act on it. Ends its reply with APPROVE / BOUNCE: @dev / HALT.
|
# pass/fail verdict so @pm (who never reads the PR) can act on it and the issue thread — which is
|
||||||
|
# for the creator/orchestration — stays free of review internals. APPROVE / BOUNCE: @dev / HALT.
|
||||||
if [ "$NAME" = "qa" ]; then
|
if [ "$NAME" = "qa" ]; then
|
||||||
PRN=$(resolve_pr)
|
PRN=$(resolve_pr)
|
||||||
if grep -qiE '^[[:space:]]*APPROVE[[:space:]]*$' /tmp/agent_out.md; then
|
if grep -qiE '^[[:space:]]*APPROVE[[:space:]]*$' /tmp/agent_out.md; then
|
||||||
post_to "$ISSN" "$(printf '✅ Reviewed PR #%s — looks good.\n\n%s%s' "${PRN:-?}" "$reply" "$activity")"
|
if [ -n "$PRN" ]; then
|
||||||
trig "$ISSN" "@pm — @qa approved PR #${PRN:-?} (issue #$ISSN). Over to you."
|
post_to "$PRN" "$reply$activity" # the review detail belongs on the PR
|
||||||
elif grep -qiE '^[[:space:]]*BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md; then
|
post_to "$ISSN" "✅ Reviewed PR #$PRN — looks good."
|
||||||
dev=$(grep -oiE 'BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md | head -1 | grep -oiE '(junior|senior|lead)' | tr '[:upper:]' '[:lower:]')
|
else # no PR resolved — nowhere better than the issue
|
||||||
|
post_to "$ISSN" "$(printf '✅ Reviewed — looks good.\n\n%s%s' "$reply" "$activity")"
|
||||||
|
fi
|
||||||
|
trig "$ISSN" "@pm — I have reviewed and approved PR #${PRN:-?} (issue #$ISSN). Over to you."
|
||||||
|
elif grep -qiE '^[[:space:]]*BOUNCE:[[:space:]]*@(junior|senior|lead|intern)' /tmp/agent_out.md; then
|
||||||
|
dev=$(grep -oiE 'BOUNCE:[[:space:]]*@(junior|senior|lead|intern)' /tmp/agent_out.md | head -1 | grep -oiE '(junior|senior|lead|intern)' | tr '[:upper:]' '[:lower:]')
|
||||||
[ -z "$dev" ] && [ -n "$PRN" ] && dev=$(curl -sS "${hdr[@]}" "$API/pulls/$PRN" | jq -r '.user.login // "junior"')
|
[ -z "$dev" ] && [ -n "$PRN" ] && dev=$(curl -sS "${hdr[@]}" "$API/pulls/$PRN" | jq -r '.user.login // "junior"')
|
||||||
dest="${PRN:-$NUM}"
|
dest="${PRN:-$NUM}"
|
||||||
post_to "$dest" "$reply$activity" # recommendations, on the PR
|
post_to "$dest" "$reply$activity" # recommendations, on the PR
|
||||||
# Bounce budget: count prior "fix attempt" markers on the PR thread; stop after 3.
|
# Bounce budget: count prior bounce TRIGGERS on the PR thread — only @qa-authored comments
|
||||||
prior=$(curl -sS "${hdr[@]}" "$API/issues/$dest/comments?limit=100" | jq -r 'if type=="array" then [.[]|select(.body|test("fix attempt"))]|length else 0 end' 2>/dev/null); prior=${prior:-0}
|
# matching the exact "(fix attempt N/3)" template. A loose substring match would also count
|
||||||
|
# review text QUOTING our own templates (seen on PR #84: the counter jumped 1/3 → 3/3 because
|
||||||
|
# a qa review quoted publish.sh lines containing the phrase), halving the fix budget.
|
||||||
|
prior=$(curl -sS "${hdr[@]}" "$API/issues/$dest/comments?limit=100" | jq -r 'if type=="array" then [.[]|select(.user.login=="qa")|select(.body|test("^@[a-z]+ please address my review above and update PR #[0-9?]+ \\(fix attempt [0-9]+/3\\)\\.$"))]|length else 0 end' 2>/dev/null); prior=${prior:-0}
|
||||||
if [ "$prior" -ge 3 ]; then
|
if [ "$prior" -ge 3 ]; then
|
||||||
[ "$AUTOPILOT" = "true" ] && del_autopilot_label "$ISSN"
|
[ "$AUTOPILOT" = "true" ] && del_autopilot_label "$ISSN"
|
||||||
post_to "$ISSN" "🛑 Still not right after 3 fix attempts on PR #${PRN:-?} — handing to @ffaerber (details on the PR)."
|
post_to "$ISSN" "🛑 Still not right after 3 fix attempts on PR #${PRN:-?} — handing to @ffaerber (details on the PR)."
|
||||||
else
|
else
|
||||||
n=$((prior + 1))
|
n=$((prior + 1))
|
||||||
trig "$dest" "@${dev:-junior} please address @qa's review above and update PR #${PRN:-?} (fix attempt $n/3)."
|
# NOTE: this template and the counter regex above MUST stay in sync — if you reword one,
|
||||||
|
# reword the other, or the count resets to 0 and the 3-round cap stops working.
|
||||||
|
trig "$dest" "@${dev:-junior} please address my review above and update PR #${PRN:-?} (fix attempt $n/3)."
|
||||||
fi
|
fi
|
||||||
elif grep -qiE '^[[:space:]]*HALT([_ ]AUTOPILOT)?[[:space:]]*$' /tmp/agent_out.md; then
|
elif grep -qiE '^[[:space:]]*HALT([_ ]AUTOPILOT)?[[:space:]]*$' /tmp/agent_out.md; then
|
||||||
[ "$AUTOPILOT" = "true" ] && del_autopilot_label "$ISSN"
|
[ "$AUTOPILOT" = "true" ] && del_autopilot_label "$ISSN"
|
||||||
@@ -132,7 +151,7 @@ if [ "$MODE" != "pr" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------- @pm / @ops: issue-thread orchestration ----------
|
# ---------- @pm / @ops: issue-thread orchestration ----------
|
||||||
target=$(grep -oiE 'DELEGATE:[[:space:]]*@(junior|senior|lead|qa)' /tmp/agent_out.md 2>/dev/null | head -1 | grep -oiE '(junior|senior|lead|qa)' | tr '[:upper:]' '[:lower:]')
|
target=$(grep -oiE 'DELEGATE:[[:space:]]*@(junior|senior|lead|qa|intern)' /tmp/agent_out.md 2>/dev/null | head -1 | grep -oiE '(junior|senior|lead|qa|intern)' | tr '[:upper:]' '[:lower:]')
|
||||||
# Visible comment: the reply text, or a sensible line if the agent only emitted a marker.
|
# Visible comment: the reply text, or a sensible line if the agent only emitted a marker.
|
||||||
msg="$reply"
|
msg="$reply"
|
||||||
case "$msg" in ""|"_(Made changes"*) msg=$([ -n "$target" ] && echo "Handing off to @$target." || echo "_(no further comment)_") ;; esac
|
case "$msg" in ""|"_(Made changes"*) msg=$([ -n "$target" ] && echo "Handing off to @$target." || echo "_(no further comment)_") ;; esac
|
||||||
@@ -176,15 +195,43 @@ if [ "$MODE" != "pr" ]; then
|
|||||||
done < /tmp/subtasks.txt
|
done < /tmp/subtasks.txt
|
||||||
subtext=$(printf '\n\n---\nCreated sub-issues%s (mention an agent on each when ready):%b' "${ms:+ under milestone **$ms**}" "$links")
|
subtext=$(printf '\n\n---\nCreated sub-issues%s (mention an agent on each when ready):%b' "${ms:+ under milestone **$ms**}" "$links")
|
||||||
fi
|
fi
|
||||||
post "$(printf '%s%s%s' "$msg" "$subtext" "$activity")"
|
# DEDUP GUARD (issue: @pm double-posts its report). Prompt-level "do not self-post" is ignored
|
||||||
|
# by some models, so enforce it here: if the agent ALREADY posted a comment on this thread
|
||||||
|
# during the run (any comment by $NAME newer than the pre-run newest id from fetch-thread.sh),
|
||||||
|
# its self-post IS the reply — skip the duplicate framework comment. Markers (CLOSE_ISSUE,
|
||||||
|
# DELEGATE, MERGE_PR, subtasks) were already processed above and are unaffected.
|
||||||
|
# FAIL OPEN: if the pre-run marker is missing (fetch-thread hiccup), pre_cid=0 would make the
|
||||||
|
# agent's comments from PREVIOUS runs count as self-posts and wrongly suppress the reply.
|
||||||
|
# Without the marker, skip the guard and post normally.
|
||||||
|
pre_cid=$(cat /tmp/thread_max_cid 2>/dev/null || echo "")
|
||||||
|
selfposts=0
|
||||||
|
if [ -n "$pre_cid" ]; then
|
||||||
|
: > /tmp/all_comments.json
|
||||||
|
for pg in $(seq 1 10); do
|
||||||
|
cpg=$(curl -sS "${hdr[@]}" "$API/issues/$NUM/comments?limit=50&page=$pg" 2>/dev/null) || cpg='[]'
|
||||||
|
cn=$(printf '%s' "$cpg" | jq 'if type=="array" then length else 0 end' 2>/dev/null || echo 0)
|
||||||
|
[ "${cn:-0}" -gt 0 ] && printf '%s\n' "$cpg" >> /tmp/all_comments.json
|
||||||
|
[ "${cn:-0}" -lt 50 ] && break
|
||||||
|
done
|
||||||
|
selfposts=$(jq -rs --arg n "$NAME" --argjson c "${pre_cid:-0}" '[ (add // [])[] | select(.user.login==$n) | select(.id > $c) ] | length' /tmp/all_comments.json 2>/dev/null || echo 0)
|
||||||
|
fi
|
||||||
|
if [ "${selfposts:-0}" -gt 0 ]; then
|
||||||
|
echo "agent @$NAME already posted ${selfposts} comment(s) on #$NUM during this run — skipping duplicate framework reply"
|
||||||
|
else
|
||||||
|
post "$(printf '%s%s%s' "$msg" "$subtext" "$activity")"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- @pm autopilot merge: @pm is the ONLY agent that merges, and ONLY under the autopilot label ---
|
# --- @pm autopilot merge: @pm is the ONLY agent that merges, and ONLY under the autopilot label ---
|
||||||
# (@qa never merges — it approves and hands back here.) Merge with the PAT (TTOK), not the built-in
|
# (@qa never merges — it approves and hands back here.) Merge with the PAT (TTOK), not the built-in
|
||||||
# token, so the push to main fires the deploy. TOKEN_PM must carry write:repository.
|
# token, so the push to main fires the deploy. TOKEN_PM must carry write:repository.
|
||||||
if [ "$NAME" = "pm" ] && [ "$AUTOPILOT" = "true" ] && grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then
|
if [ "$NAME" = "pm" ] && [ "$AUTOPILOT" = "true" ] && grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then
|
||||||
PRN=$(resolve_pr)
|
PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w)
|
||||||
if [ -z "$PRN" ]; then
|
if [ -z "$PRN" ]; then
|
||||||
echo "MERGE_PR but no open PR found for issue #$ISSN"
|
echo "MERGE_PR but no open PR found for issue #$ISSN"
|
||||||
|
elif [ "$CNT" -gt 1 ]; then
|
||||||
|
# Split-PR work: auto-merging just one of several open PRs is half a change deployed.
|
||||||
|
del_autopilot_label "$ISSN"
|
||||||
|
post_to "$ISSN" "⚠️ This issue has $CNT open PRs (#${PRS// /, #}) — autopilot only merges single-PR work. Removed the autopilot label; @ffaerber please review and merge them in order."
|
||||||
else
|
else
|
||||||
echo "@pm autopilot: merging PR #$PRN (issue #$ISSN)"
|
echo "@pm autopilot: merging PR #$PRN (issue #$ISSN)"
|
||||||
mc=$(curl -sS -o /tmp/merge_resp.txt -w '%{http_code}' -X POST \
|
mc=$(curl -sS -o /tmp/merge_resp.txt -w '%{http_code}' -X POST \
|
||||||
@@ -203,20 +250,55 @@ if [ "$MODE" != "pr" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- @pm RETRO: open a retrospective issue for this thread (maintainer asked for a retro) ---
|
||||||
|
# Creates a retro issue pointing at this issue + its PR and triggers @senior on it (has gitea-api
|
||||||
|
# to read both threads). The retro produces a LEARNINGS.md PR via the NORMAL choreography (senior →
|
||||||
|
# pm → qa → merge), and run-agent.sh injects LEARNINGS.md into every future prompt — closing the loop.
|
||||||
|
if [ "$NAME" = "pm" ] && grep -qiE '^[[:space:]]*RETRO[[:space:]]*$' /tmp/agent_out.md; then
|
||||||
|
PRN=$(curl -sS "${hdr[@]}" "$API/pulls?state=all&limit=50" \
|
||||||
|
| jq -r --arg br "ai/issue-$ISSN" 'if type=="array" then ([.[]|select(.head.ref==$br or (.head.ref|startswith($br+"-")))] | sort_by(.number) | last | .number // empty) else empty end' 2>/dev/null)
|
||||||
|
rbody=$(printf 'Retrospective for issue #%s%s.\n\nRead the FULL issue thread%s using the gitea-api skill (issue comments%s and the PR diff). Identify what went wrong, slow, or needed human correction — missed wiring, review misses, bounced rounds, unclear delegation, missing context.\n\nThen APPEND the distilled learnings to `LEARNINGS.md` at the repo root (create it with a short header if missing). Rules for entries:\n- 3 to 6 bullets max, each ONE line: `symptom -> rule for next time`.\n- Concrete and checkable (name the file/step/marker), not generic advice.\n- Do not repeat an existing bullet; refine it instead.\n- Do not rewrite unrelated parts of the file.\n\nThese learnings are injected into every future agent prompt, so quality over quantity.' \
|
||||||
|
"$ISSN" "${PRN:+ / PR #$PRN}" "${PRN:+ and PR #$PRN thread}" "${PRN:+, PR comments}")
|
||||||
|
rnum=$(curl -sS -X POST "${hdr[@]}" "$API/issues" \
|
||||||
|
-d "$(jq -nc --arg t "retro: issue #$ISSN" --arg b "$rbody" '{title:$t,body:$b}')" | jq -r '.number // empty')
|
||||||
|
if [ -n "$rnum" ]; then
|
||||||
|
echo "opened retro issue #$rnum"
|
||||||
|
post_to "$ISSN" "📝 Opened retro issue #$rnum."
|
||||||
|
trig "$rnum" "@senior please run this retrospective per the issue body."
|
||||||
|
else
|
||||||
|
echo "retro issue creation failed"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- @pm ASK: consult a dev in the thread WITHOUT starting a build ---
|
||||||
|
# 'ASK: @<dev> <question>' fires the dev in DISCUSSION mode (route.sh: the trigger below does not
|
||||||
|
# match any build phrase, so the dev replies in-thread — no branch, no PR). @pm gathers input this
|
||||||
|
# way, then DELEGATEs when enough is known.
|
||||||
|
ask_line=$(grep -oiE '^[[:space:]]*ASK:[[:space:]]*@(junior|senior|lead|intern)[[:space:]]+.*$' /tmp/agent_out.md 2>/dev/null | head -1)
|
||||||
|
if [ "$NAME" = "pm" ] && [ -n "$ask_line" ]; then
|
||||||
|
ask_dev=$(printf '%s' "$ask_line" | grep -oiE '@(junior|senior|lead|intern)' | head -1 | tr -d '@' | tr '[:upper:]' '[:lower:]')
|
||||||
|
ask_q=$(printf '%s' "$ask_line" | sed -E 's/^[[:space:]]*ASK:[[:space:]]*@[A-Za-z]+[[:space:]]+//')
|
||||||
|
trig "$ISSN" "@$ask_dev $ask_q — this is a discussion: reply in this thread with your assessment; do not start any work."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# --- @pm delegation: hand the build to a dev, or hand the finished PR to @qa for review ---
|
# --- @pm delegation: hand the build to a dev, or hand the finished PR to @qa for review ---
|
||||||
# Only an explicit 'DELEGATE: @<agent>' line acts (never a prose mention). Fires via the PAT (TTOK)
|
# Only an explicit 'DELEGATE: @<agent>' line acts (never a prose mention). Fires via the PAT (TTOK)
|
||||||
# so a new run starts; the built-in token cannot. Everything posts on the ISSUE — @pm never touches
|
# so a new run starts; the built-in token cannot. Everything posts on the ISSUE — @pm never touches
|
||||||
# the PR. Chain terminates: normal → @pm tells the creator (no marker); autopilot → @pm merges above.
|
# the PR. Chain terminates: normal → @pm tells the creator (no marker); autopilot → @pm merges above.
|
||||||
if [ -n "$target" ] && [ "$target" != "$NAME" ]; then
|
if [ -n "$target" ] && [ "$target" != "$NAME" ]; then
|
||||||
if [ "$target" = "qa" ]; then
|
if [ "$target" = "qa" ]; then
|
||||||
PRN=$(resolve_pr)
|
PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w)
|
||||||
if [ -n "$PRN" ]; then
|
if [ -n "$PRN" ] && [ "$CNT" -gt 1 ]; then
|
||||||
|
trig "$ISSN" "@qa please review the $CNT open PRs for issue #$ISSN (#${PRS// /, #}) — put your recommendations on each PR; approve only when ALL are good."
|
||||||
|
elif [ -n "$PRN" ]; then
|
||||||
trig "$ISSN" "@qa please review PR #$PRN for issue #$ISSN — put your recommendations on the PR, or approve."
|
trig "$ISSN" "@qa please review PR #$PRN for issue #$ISSN — put your recommendations on the PR, or approve."
|
||||||
else
|
else
|
||||||
echo "DELEGATE:@qa but no open PR yet for issue #$ISSN — not firing"
|
echo "DELEGATE:@qa but no open PR yet for issue #$ISSN — not firing"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
trig "$ISSN" "@$target please proceed with issue #$ISSN per the plan above (delegated by $NAME)."
|
trig "$ISSN" "@$target please proceed with issue #$ISSN per my plan above."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "no DELEGATE marker — not delegating (agent is asking or finished)"
|
echo "no DELEGATE marker — not delegating (agent is asking or finished)"
|
||||||
@@ -224,6 +306,15 @@ if [ "$MODE" != "pr" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- dev DISCUSSION mode: consulted for expertise, no build (route.sh workmode=discuss) ---
|
||||||
|
# The reply is a comment on the thread — discard any stray file edits, skip ALL git/PR machinery.
|
||||||
|
if [ "${WORKMODE:-build}" = "discuss" ]; then
|
||||||
|
git checkout -- . 2>/dev/null || true
|
||||||
|
git clean -fd 2>/dev/null || true
|
||||||
|
post "$(printf '%s%s' "$reply" "$activity")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Scrub the runtime scripts checkout (.agents-workflow) from the tree so it never lands in a
|
# Scrub the runtime scripts checkout (.agents-workflow) from the tree so it never lands in a
|
||||||
# commit/PR and never confuses the git ops below (issue #33). The scripts we run live outside the
|
# commit/PR and never confuses the git ops below (issue #33). The scripts we run live outside the
|
||||||
# workspace ($SCRIPTS -> runner.temp), so removing this in-tree copy is always safe. Handle every
|
# workspace ($SCRIPTS -> runner.temp), so removing this in-tree copy is always safe. Handle every
|
||||||
@@ -243,7 +334,14 @@ if [ -n "$(git status --porcelain)" ]; then
|
|||||||
git add -A
|
git add -A
|
||||||
git commit -m "@$NAME: issue #$NUM"
|
git commit -m "@$NAME: issue #$NUM"
|
||||||
fi
|
fi
|
||||||
git push origin "HEAD:$BRANCH" || true
|
if git push origin "HEAD:$BRANCH"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
status=$?
|
||||||
|
echo "git push failed for $BRANCH (exit $status)"
|
||||||
|
post "$(printf '⚠️ Push to branch `%s` failed (git exit %s). The PR will not open until the push succeeds. Please check the Actions log.%s' "$BRANCH" "$status" "$activity")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
git fetch -q origin 2>/dev/null || true
|
git fetch -q origin 2>/dev/null || true
|
||||||
|
|
||||||
prbody=$(printf '%s\n\n---\nResolves #%s' "$prdesc" "$NUM")
|
prbody=$(printf '%s\n\n---\nResolves #%s' "$prdesc" "$NUM")
|
||||||
@@ -269,14 +367,21 @@ url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
|
|||||||
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
|
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
|
||||||
if [ -z "$url" ]; then
|
if [ -z "$url" ]; then
|
||||||
title="@$NAME: $TITLE"
|
title="@$NAME: $TITLE"
|
||||||
resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \
|
resp_body=/tmp/pr_create_resp.json
|
||||||
|
http_status=$(curl -sS -o "$resp_body" -w '%{http_code}' -X POST "${hdr[@]}" "$API/pulls" \
|
||||||
-d "$(jq -nc --arg t "$title" --arg h "$br" --arg b "$prbody" \
|
-d "$(jq -nc --arg t "$title" --arg h "$br" --arg b "$prbody" \
|
||||||
'{title:$t, head:$h, base:"main", body:$b}')")
|
'{title:$t, head:$h, base:"main", body:$b}')")
|
||||||
echo "PR create ($br): $resp"
|
resp=$(cat "$resp_body" 2>/dev/null || true)
|
||||||
|
echo "PR create ($br): HTTP $http_status — $resp"
|
||||||
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
|
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
|
||||||
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
|
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
|
||||||
fi
|
fi
|
||||||
[ -z "$url" ] && { echo "PR open/lookup failed for $br — posting reply on issue instead"; post "$(printf '%s%s' "$reply" "$activity")"; exit 0; }
|
if [ -z "$url" ]; then
|
||||||
|
err_msg=$(printf '%s' "$resp" | jq -r 'if type=="object" and .message then .message else "(no error message in response)" end' 2>/dev/null)
|
||||||
|
echo "PR open/lookup failed for $br — HTTP $http_status — response: $resp"
|
||||||
|
post "$(printf '⚠️ Failed to open PR for branch `%s`.\n\nHTTP status: %s\nGitea message: %s%s' "$br" "$http_status" "$err_msg" "$activity")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Posts to the PR thread when we have a PR number, else to the origin issue ($NUM).
|
# Posts to the PR thread when we have a PR number, else to the origin issue ($NUM).
|
||||||
prpost() {
|
prpost() {
|
||||||
@@ -297,6 +402,6 @@ else
|
|||||||
# on the PR thread. The qa↔dev loop is direct — it does NOT go back through @pm each round.
|
# on the PR thread. The qa↔dev loop is direct — it does NOT go back through @pm each round.
|
||||||
prpost "$prnum" "$(printf 'Pushed an update to PR #%s.%s' "$prnum" "$activity")"
|
prpost "$prnum" "$(printf 'Pushed an update to PR #%s.%s' "$prnum" "$activity")"
|
||||||
case "$NAME" in
|
case "$NAME" in
|
||||||
junior|senior|lead) trig "$prnum" "@qa please re-verify PR #$prnum — the dev has pushed an update." ;;
|
junior|senior|lead|intern) trig "$prnum" "@qa please re-verify PR #$prnum — I have pushed an update." ;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ set +e
|
|||||||
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
|
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
|
||||||
case "$NAME" in
|
case "$NAME" in
|
||||||
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
|
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
|
||||||
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";;
|
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; intern) TOK="$TOKEN_INTERN";; *) TOK="";;
|
||||||
esac
|
esac
|
||||||
[ -z "$TOK" ] && TOK="$GT"
|
[ -z "$TOK" ] && TOK="$GT"
|
||||||
# Trigger token: the @pm hand-back below must FIRE a new run, which the built-in token cannot.
|
# Trigger token: the @pm hand-back below must FIRE a new run, which the built-in token cannot.
|
||||||
|
|||||||
@@ -31,24 +31,52 @@ name=""
|
|||||||
# load-bearing for the flow's trigger comments: "@pm — @qa approved …" must route to @pm (pm is
|
# load-bearing for the flow's trigger comments: "@pm — @qa approved …" must route to @pm (pm is
|
||||||
# checked first), while "@junior please address @qa's review …" must route to the dev (devs are
|
# checked first), while "@junior please address @qa's review …" must route to the dev (devs are
|
||||||
# checked before qa). If you add an agent or reword a trigger in publish.sh, re-check this order.
|
# checked before qa). If you add an agent or reword a trigger in publish.sh, re-check this order.
|
||||||
for a in pm junior senior lead qa ops; do
|
# WORD-BOUNDARY match, not substring: "@internal" or "x@internet.com" must NOT route to @intern
|
||||||
case "$scan" in *"@$a"*) name=$a; break;; esac
|
# (the workflow gate can only do contains(), so this is where its false positives get filtered).
|
||||||
|
for a in pm junior senior lead qa ops intern; do
|
||||||
|
if printf '%s' "$scan" | grep -qE "(^|[^[:alnum:]_])@$a([^[:alnum:]_-]|\$)"; then name=$a; break; fi
|
||||||
done
|
done
|
||||||
if [ -z "$name" ]; then
|
if [ -z "$name" ]; then
|
||||||
if [ -z "$CID" ]; then name=pm; else echo "no known agent mentioned"; exit 1; fi
|
if [ -z "$CID" ]; then
|
||||||
|
name=pm
|
||||||
|
else
|
||||||
|
# Not an agent task (e.g. the gate's contains() matched "@internal"). Skip GRACEFULLY: emit
|
||||||
|
# mode=skip so every later step no-ops — a red run for a non-agent comment is just noise.
|
||||||
|
echo "no known agent mentioned (word-boundary) — skipping run"
|
||||||
|
{ echo "name=none"; echo "model=none"; echo "vision=false"; echo "mode=skip"; echo "skills=[]";
|
||||||
|
echo "branch=main"; echo "new=false"; echo "autopilot=false"; echo "issnum=$NUM"; } >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
model=$(jq -r --arg a "$name" '.[$a].model' /tmp/agents.json)
|
model=$(jq -r --arg a "$name" '.[$a].model' /tmp/agents.json)
|
||||||
vision=$(jq -r --arg a "$name" '.[$a].vision' /tmp/agents.json)
|
vision=$(jq -r --arg a "$name" '.[$a].vision' /tmp/agents.json)
|
||||||
mode=$(jq -r --arg a "$name" '.[$a].mode' /tmp/agents.json)
|
mode=$(jq -r --arg a "$name" '.[$a].mode' /tmp/agents.json)
|
||||||
# Compact JSON array of the skills this agent may load (scopes permission.skill in install-opencode.sh).
|
# Compact JSON array of the skills this agent may load (scopes permission.skill in install-opencode.sh).
|
||||||
skills=$(jq -c --arg a "$name" '.[$a].skills // []' /tmp/agents.json)
|
skills=$(jq -c --arg a "$name" '.[$a].skills // []' /tmp/agents.json)
|
||||||
echo "Routing to @$name (model=$model vision=$vision mode=$mode skills=$skills)"
|
|
||||||
{ echo "name=$name"; echo "model=$model"; echo "vision=$vision"; echo "mode=$mode"; echo "skills=$skills"; } >> "$GITHUB_OUTPUT"
|
# --- WORKMODE for dev (mode=pr) agents: build vs DISCUSS. ---
|
||||||
|
# Mentioning a dev is a CONVERSATION by default — it replies in the thread without creating a
|
||||||
|
# branch or PR. Actual building starts ONLY on the explicit signals:
|
||||||
|
# - a comment on a PR thread (resuming existing work), or
|
||||||
|
# - the pm delegation template ".. please proceed with issue .." (also usable by a human), or
|
||||||
|
# - the qa bounce template ".. please address my review ..".
|
||||||
|
# This lets @pm (via its ASK marker) and the maintainer consult devs to gather information first,
|
||||||
|
# and explicitly start the build later — see publish.sh / run-agent.sh.
|
||||||
|
workmode=build
|
||||||
|
if [ "$mode" = "pr" ] && [ -z "$IS_PR" ]; then
|
||||||
|
if printf '%s' "$scan" | grep -qiE 'please (proceed with issue|address my review)'; then
|
||||||
|
workmode=build
|
||||||
|
else
|
||||||
|
workmode=discuss
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "Routing to @$name (model=$model vision=$vision mode=$mode workmode=$workmode skills=$skills)"
|
||||||
|
{ echo "name=$name"; echo "model=$model"; echo "vision=$vision"; echo "mode=$mode"; echo "workmode=$workmode"; echo "skills=$skills"; } >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# Act as the agent's own Gitea user when its token is set; else the built-in bot.
|
# Act as the agent's own Gitea user when its token is set; else the built-in bot.
|
||||||
case "$name" in
|
case "$name" in
|
||||||
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
|
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
|
||||||
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";;
|
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; intern) TOK="$TOKEN_INTERN";; *) TOK="";;
|
||||||
esac
|
esac
|
||||||
[ -z "$TOK" ] && TOK="$GT"
|
[ -z "$TOK" ] && TOK="$GT"
|
||||||
git config user.name "$name"
|
git config user.name "$name"
|
||||||
@@ -56,7 +84,10 @@ git config user.email "$name@ffaerber.duckdns.org"
|
|||||||
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
|
hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
|
||||||
branch_ref=""
|
branch_ref=""
|
||||||
if [ -n "$IS_PR" ]; then # comment on a PR -> resume its branch
|
if [ "$workmode" = "discuss" ]; then # conversation only — no branch, no PR machinery
|
||||||
|
echo "discussion mode — staying on main, no branch prep"
|
||||||
|
{ echo "branch=main"; echo "new=false"; } >> "$GITHUB_OUTPUT"
|
||||||
|
elif [ -n "$IS_PR" ]; then # comment on a PR -> resume its branch
|
||||||
ref=$(curl -s -H "Authorization: token $GT" "$API/pulls/$NUM" | jq -r .head.ref)
|
ref=$(curl -s -H "Authorization: token $GT" "$API/pulls/$NUM" | jq -r .head.ref)
|
||||||
branch_ref="$ref"
|
branch_ref="$ref"
|
||||||
git fetch origin "$ref" && git checkout "$ref"
|
git fetch origin "$ref" && git checkout "$ref"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# plain-text reply (/tmp/agent_out.md) plus the raw event stream (/tmp/events.jsonl).
|
# plain-text reply (/tmp/agent_out.md) plus the raw event stream (/tmp/events.jsonl).
|
||||||
#
|
#
|
||||||
# Required env (provided by the workflow step):
|
# Required env (provided by the workflow step):
|
||||||
# ANTHROPIC_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
|
# XAI_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
|
||||||
# IBODY CMT
|
# IBODY CMT
|
||||||
# FILES (the opencode -f image flags, from the imgs step output)
|
# FILES (the opencode -f image flags, from the imgs step output)
|
||||||
# AUTOPILOT is 'true' when the issue carries the `autopilot` label (label-gated autopilot mode).
|
# AUTOPILOT is 'true' when the issue carries the `autopilot` label (label-gated autopilot mode).
|
||||||
@@ -26,7 +26,7 @@ if [ "$MODE" = "comment" ]; then
|
|||||||
ACTION="You do NOT edit files, create branches, or write a PR description. Respond with your analysis,
|
ACTION="You do NOT edit files, create branches, or write a PR description. Respond with your analysis,
|
||||||
plan, research, or clarifying questions — your reply becomes a comment on the issue.
|
plan, research, or clarifying questions — your reply becomes a comment on the issue.
|
||||||
To hand work to a teammate, end your reply with EXACTLY one line: 'DELEGATE: @<agent>' (one of
|
To hand work to a teammate, end your reply with EXACTLY one line: 'DELEGATE: @<agent>' (one of
|
||||||
@junior @senior @lead @qa) — but ONLY when you are ready to hand off AND need nothing further from the
|
@junior @senior @lead @qa @intern) — but ONLY when you are ready to hand off AND need nothing further from the
|
||||||
maintainer. If you are asking @ffaerber to confirm or decide ANYTHING, do NOT include a DELEGATE line;
|
maintainer. If you are asking @ffaerber to confirm or decide ANYTHING, do NOT include a DELEGATE line;
|
||||||
just ask and wait. Never ask for confirmation and delegate in the same reply. Mentioning a teammate in
|
just ask and wait. Never ask for confirmation and delegate in the same reply. Mentioning a teammate in
|
||||||
prose does NOT delegate — only the DELEGATE line does.
|
prose does NOT delegate — only the DELEGATE line does.
|
||||||
@@ -54,7 +54,15 @@ if [ "$MODE" = "comment" ]; then
|
|||||||
|
|
||||||
If anything is unclear or needs a decision at any phase, START your reply with '@ffaerber', ask
|
If anything is unclear or needs a decision at any phase, START your reply with '@ffaerber', ask
|
||||||
specific questions, and do NOT emit a marker. Mentioning a teammate in prose does NOT act — only a
|
specific questions, and do NOT emit a marker. Mentioning a teammate in prose does NOT act — only a
|
||||||
DELEGATE line does.
|
marker line does.
|
||||||
|
ASK — to CONSULT a dev before (or instead of) planning, end your reply with EXACTLY one line:
|
||||||
|
'ASK: @<dev> <one concrete question>'. The dev replies in this thread WITHOUT starting any work —
|
||||||
|
use it to gather feasibility/effort/approach input, then present your plan (and later DELEGATE)
|
||||||
|
once you know enough. One ASK per reply; never ASK and DELEGATE in the same reply.
|
||||||
|
RETRO — when the maintainer asks for a retrospective on this issue (e.g. 'run a retro',
|
||||||
|
'@pm retro'), briefly acknowledge and end your reply with EXACTLY one line: 'RETRO'. The
|
||||||
|
automation opens a retro issue (read this issue + its PR, distill learnings into LEARNINGS.md)
|
||||||
|
and assigns it. Emit RETRO only when explicitly asked.
|
||||||
BREAKDOWN (a feature too big for one PR): in PHASE 1, propose a milestone name and the sub-task
|
BREAKDOWN (a feature too big for one PR): in PHASE 1, propose a milestone name and the sub-task
|
||||||
list, then ask '@ffaerber create these N sub-issues? reply yes.' ONLY after approval, end with:
|
list, then ask '@ffaerber create these N sub-issues? reply yes.' ONLY after approval, end with:
|
||||||
BEGIN_SUBTASKS
|
BEGIN_SUBTASKS
|
||||||
@@ -86,13 +94,22 @@ if [ "$MODE" = "comment" ]; then
|
|||||||
issue and hands back to @pm (who tells the creator, or in autopilot merges). You do NOT merge.
|
issue and hands back to @pm (who tells the creator, or in autopilot merges). You do NOT merge.
|
||||||
- 'BOUNCE: @<dev>' — something needs changing. FIRST spell out, specifically and actionably, exactly
|
- 'BOUNCE: @<dev>' — something needs changing. FIRST spell out, specifically and actionably, exactly
|
||||||
what to change (file, label, value, hostname, …), THEN end with the BOUNCE line naming who fixes
|
what to change (file, label, value, hostname, …), THEN end with the BOUNCE line naming who fixes
|
||||||
it (@junior / @senior / @lead — usually whoever built it). The automation sends the PR back and
|
it (@junior / @senior / @lead / @intern — usually whoever built it). The automation sends the PR back and
|
||||||
re-verifies with you. After 3 rounds it stops and hands to @ffaerber — so list ALL problems at
|
re-verifies with you. After 3 rounds it stops and hands to @ffaerber — so list ALL problems at
|
||||||
once, not one at a time.
|
once, not one at a time.
|
||||||
- 'HALT' — the problem is NOT something a dev can fix (the request is ambiguous / needs a human
|
- 'HALT' — the problem is NOT something a dev can fix (the request is ambiguous / needs a human
|
||||||
decision). Hands back to @ffaerber.
|
decision). Hands back to @ffaerber.
|
||||||
Emit AT MOST one marker, and only after you have actually verified."
|
Emit AT MOST one marker, and only after you have actually verified."
|
||||||
fi
|
fi
|
||||||
|
elif [ "${WORKMODE:-build}" = "discuss" ]; then
|
||||||
|
# A dev agent consulted for its EXPERTISE — conversation only, no build. Building starts later,
|
||||||
|
# explicitly ('please proceed with issue …'). See route.sh workmode.
|
||||||
|
ACTION="You are being CONSULTED in this thread — this is a DISCUSSION, not a build task. Answer the
|
||||||
|
question you were asked: read whatever files/logs you need (read-only), give your assessment,
|
||||||
|
approach, effort estimate, risks, or answer — concise and concrete. Your reply becomes a comment.
|
||||||
|
Do NOT modify files, do NOT commit or push, do NOT create branches, do NOT open PRs. Do not
|
||||||
|
emit any marker. When the team has enough information, @pm (or the maintainer) will explicitly
|
||||||
|
tell a dev to start building."
|
||||||
else
|
else
|
||||||
ACTION="You start on git branch '${BRANCH}', with git and push credentials already configured.
|
ACTION="You start on git branch '${BRANCH}', with git and push credentials already configured.
|
||||||
FIRST read AGENTS.md at the repo root and FOLLOW IT EXACTLY — it defines the golden rules,
|
FIRST read AGENTS.md at the repo root and FOLLOW IT EXACTLY — it defines the golden rules,
|
||||||
@@ -102,15 +119,42 @@ else
|
|||||||
open pull requests yourself — that is automated for every branch you push.
|
open pull requests yourself — that is automated for every branch you push.
|
||||||
If the task is genuinely unclear, make NO changes and reply with specific questions instead."
|
If the task is genuinely unclear, make NO changes and reply with specific questions instead."
|
||||||
fi
|
fi
|
||||||
|
# TEAM LEARNINGS — distilled from past retros (see the RETRO flow in publish.sh). Lives at the
|
||||||
|
# CALLER repo root as LEARNINGS.md, maintained by retro PRs. Injected into EVERY agent's prompt
|
||||||
|
# (capped) so past mistakes actually change future behavior — this is the feedback loop.
|
||||||
|
# Cap is LINE-aware and keeps the NEWEST entries: retros append at the bottom, so a byte-cap from
|
||||||
|
# the top would silently drop the latest lessons first (and cut mid-bullet).
|
||||||
|
LEARN=""
|
||||||
|
if [ -s LEARNINGS.md ]; then
|
||||||
|
if [ "$(wc -l < LEARNINGS.md)" -gt 80 ]; then
|
||||||
|
LEARN=$(printf '%s\n_(older learnings truncated — full list in LEARNINGS.md)_\n%s' \
|
||||||
|
"$(head -n 3 LEARNINGS.md)" "$(tail -n 70 LEARNINGS.md)")
|
||||||
|
else
|
||||||
|
LEARN=$(cat LEARNINGS.md)
|
||||||
|
fi
|
||||||
|
LEARN=$(printf '%s' "$LEARN" | head -c 8000)
|
||||||
|
fi
|
||||||
|
[ -n "$LEARN" ] && LEARN="
|
||||||
|
TEAM LEARNINGS (distilled from past retros in this repo — APPLY them; they exist because a
|
||||||
|
previous task went wrong without them):
|
||||||
|
${LEARN}
|
||||||
|
"
|
||||||
PROMPT="You are @${NAME}, a member of an AI dev team working on this Gitea repository.
|
PROMPT="You are @${NAME}, a member of an AI dev team working on this Gitea repository.
|
||||||
YOUR ROLE: ${DESC}
|
YOUR ROLE: ${DESC}
|
||||||
YOUR CAPABILITIES: model ${MODEL}. ${CAP}
|
YOUR CAPABILITIES: model ${MODEL}. ${CAP}
|
||||||
${NOTE}
|
${NOTE}
|
||||||
|
${LEARN}
|
||||||
|
|
||||||
Your reply is posted as a comment already attributed to you (@${NAME}) — your name and avatar are
|
Your reply is posted as a comment already attributed to you (@${NAME}) — your name and avatar are
|
||||||
shown by Gitea. Do NOT begin your reply with your own name, an '@${NAME}' header, or a '🤖/🔨 @you'
|
shown by Gitea. Do NOT begin your reply with your own name, an '@${NAME}' header, or a '🤖/🔨 @you'
|
||||||
line; just write the content directly.
|
line; just write the content directly.
|
||||||
|
|
||||||
|
Do NOT use the gitea-api skill to post your reply, report, or any comment on THIS thread
|
||||||
|
yourself. The automation already posts your reply exactly once — self-posting it too is what
|
||||||
|
creates the duplicate comments you must avoid. On this thread, use gitea-api only to READ, or to
|
||||||
|
take an explicit action you were asked for (add/remove a label, close the issue, merge the PR).
|
||||||
|
Your report or answer IS your reply text — write it as your reply; do not post it via the API.
|
||||||
|
|
||||||
TEAM ROSTER (who does what — hand off if a task isn't yours):
|
TEAM ROSTER (who does what — hand off if a task isn't yours):
|
||||||
${ROSTER}
|
${ROSTER}
|
||||||
|
|
||||||
@@ -137,14 +181,21 @@ echo "opencode version: $(opencode --version 2>&1)"
|
|||||||
# keeps reading agent_out.md exactly as before. Success is exit code 0: the agent may
|
# keeps reading agent_out.md exactly as before. Success is exit code 0: the agent may
|
||||||
# make tool-only changes with no text summary, so DO NOT treat empty output as failure.
|
# make tool-only changes with no text summary, so DO NOT treat empty output as failure.
|
||||||
rc=1
|
rc=1
|
||||||
|
# HARD per-attempt timeout: there is exactly ONE runner slot, and a hung model (a stalled local
|
||||||
|
# ollama generate on a trivial @intern question) once held it for ~1h, queueing every agent run
|
||||||
|
# instance-wide. 20 min is far above any legitimate attempt. timeout SIGTERMs, then SIGKILLs 30s
|
||||||
|
# later. rc=124 (timed out) is NOT retried — a hung backend stays hung; fail fast, free the runner.
|
||||||
|
AGENT_TIMEOUT="${AGENT_TIMEOUT:-1200}"
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "opencode attempt $attempt/3 for @$NAME ($MODEL)"
|
echo "opencode attempt $attempt/3 for @$NAME ($MODEL, timeout ${AGENT_TIMEOUT}s)"
|
||||||
rc=0
|
rc=0
|
||||||
opencode run --model "$MODEL" --auto --format json "$PROMPT" ${FILES:-} \
|
timeout -k 30 "$AGENT_TIMEOUT" \
|
||||||
|
opencode run --model "$MODEL" --auto --format json "$PROMPT" ${FILES:-} \
|
||||||
>/tmp/events.jsonl 2>/tmp/agent_err.log || rc=$?
|
>/tmp/events.jsonl 2>/tmp/agent_err.log || rc=$?
|
||||||
echo "rc=$rc"; echo "--- events ($(wc -l < /tmp/events.jsonl 2>/dev/null || echo 0) lines) ---"
|
echo "rc=$rc"; echo "--- events ($(wc -l < /tmp/events.jsonl 2>/dev/null || echo 0) lines) ---"
|
||||||
echo "--- stderr (trace) ---"; cat /tmp/agent_err.log
|
echo "--- stderr (trace) ---"; cat /tmp/agent_err.log
|
||||||
[ $rc -eq 0 ] && break
|
[ $rc -eq 0 ] && break
|
||||||
|
if [ $rc -eq 124 ]; then echo "attempt timed out after ${AGENT_TIMEOUT}s — backend hung, not retrying"; break; fi
|
||||||
if grep -qiE 'overloaded|429|529|rate.?limit|timeout|ETIMEDOUT|ECONNRESET|EAI_AGAIN' /tmp/events.jsonl /tmp/agent_err.log; then
|
if grep -qiE 'overloaded|429|529|rate.?limit|timeout|ETIMEDOUT|ECONNRESET|EAI_AGAIN' /tmp/events.jsonl /tmp/agent_err.log; then
|
||||||
echo "transient error — backing off $((attempt*20))s"; sleep $((attempt * 20)); continue
|
echo "transient error — backing off $((attempt*20))s"; sleep $((attempt * 20)); continue
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -107,9 +107,22 @@ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$API/orgs/{org}/labels"
|
|||||||
3. Commit the standard caller so it gets the agents — `PUT /repos/{owner}/{repo}/contents/.gitea/workflows/ai-agent.yml`
|
3. Commit the standard caller so it gets the agents — `PUT /repos/{owner}/{repo}/contents/.gitea/workflows/ai-agent.yml`
|
||||||
with base64 `content`, `message`, `branch:"main"` (copy the exact caller from the `agents` repo README).
|
with base64 `content`, `message`, `branch:"main"` (copy the exact caller from the `agents` repo README).
|
||||||
4. Add the agent bot users as collaborators: `PUT /repos/{owner}/{repo}/collaborators/{username}` (`{"permission":"write"}`).
|
4. Add the agent bot users as collaborators: `PUT /repos/{owner}/{repo}/collaborators/{username}` (`{"permission":"write"}`).
|
||||||
5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_API_KEY, SELF_TOKEN,
|
5. Ensure the repo can run agents — the org must hold the runtime secrets (XAI_API_KEY, SELF_TOKEN,
|
||||||
TOKEN_* , OLLAMA_URL, OLLAMA_CLOUD_API_KEY); set any missing via the secrets calls above.
|
TOKEN_* , OLLAMA_URL, OLLAMA_CLOUD_API_KEY); set any missing via the secrets calls above.
|
||||||
|
|
||||||
|
## Packages / container registry
|
||||||
|
Container images pushed by CI land in the **owner's** package namespace (e.g. `ffaerber/-/packages`)
|
||||||
|
and are NOT automatically shown on the repo's Packages page — link once after the first push:
|
||||||
|
```
|
||||||
|
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$API/packages/{owner}/container/{name}/-/link/{repo}"
|
||||||
|
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$API/packages/{owner}/container/{name}/-/unlink"
|
||||||
|
```
|
||||||
|
Registry auth facts (for wiring CI): the internal Actions token (`GITHUB_TOKEN`) is REJECTED by the
|
||||||
|
container registry — a real PAT is required. A **user**-namespace package is writable only by that
|
||||||
|
user or a site admin, so CI pushing to `<user>/<image>` needs a PAT minted BY that user with scope
|
||||||
|
`write:package` only (stored as a repo/user secret, e.g. `REGISTRY_TOKEN`). Your own token carries
|
||||||
|
`write:package`, so you can link/unlink and (if ever needed) push to any namespace.
|
||||||
|
|
||||||
## Admin user management
|
## Admin user management
|
||||||
- Create: `POST /admin/users`. Edit: `PATCH /admin/users/{username}`. Delete: `DELETE /admin/users/{username}` (**confirm first**).
|
- Create: `POST /admin/users`. Edit: `PATCH /admin/users/{username}`. Delete: `DELETE /admin/users/{username}` (**confirm first**).
|
||||||
- List: `GET /admin/users`.
|
- List: `GET /admin/users`.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# LEARNINGS — distilled from retros
|
||||||
|
|
||||||
|
Rules for the team. Each line: `symptom -> rule for next time`. Keep concrete and checkable.
|
||||||
|
|
||||||
|
- New agent added without the `agent.yml` trigger gate, breaking all `@intern` comments until round 3 -> adding an agent means editing BOTH the trusted-author list and the mention list in `agent.yml` (lines ~28 and ~37) in the same commit; @qa grep the gate for the new name.
|
||||||
|
- Two of the 8 files an agent touches were missed on the first PR -> when adding an agent, touch all of `agents.json`, `install-opencode.sh`, `route.sh`, `agent.yml`, `publish.sh`, `rescue-pr.sh`, `run-agent.sh`, `README.md`; @qa diff-stat the PR and confirm the name appears in each.
|
||||||
|
- Stray leading-space edits to `run-agent.sh` prompt heredoc bounced 2 review rounds -> only edit the exact token (the agent name) inside prompt heredocs, never re-indent surrounding lines; verify with `cat -A` against `main` before pushing.
|
||||||
|
- @qa quoted the `@${dev} ... (fix attempt $n/3)` trigger string from the diff, inflating the bounce counter 1/3 -> 3/3 -> @qa paraphrase the fix-attempt line, never reproduce it verbatim; the publish.sh template+regex must stay pinned together.
|
||||||
|
- @qa found whitespace and the gate miss in separate rounds, hitting the 3-round cap -> on a BOUNCE, list ALL problems (every file/line) in one round; the 3-round cap is hard.
|
||||||
@@ -7,12 +7,13 @@ Shared **AI dev-team** workflow for Gitea Actions, reusable across repos. It giv
|
|||||||
|
|
||||||
| Agent | Model | Vision | Mode | Skills | Role |
|
| Agent | Model | Vision | Mode | Skills | Role |
|
||||||
|-------|-------|:------:|------|--------|------|
|
|-------|-------|:------:|------|--------|------|
|
||||||
| `@pm` | `ollama-cloud/gemma4:cloud` | yes | comment | `gitea-api` | Product manager & orchestrator — plans, picks the dev, hands finished PRs to `@qa`, reports back to the issue creator (autopilot: merges approved PRs itself). Issue thread only; never edits files, never reads the PR diff. |
|
| `@pm` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | Product manager & orchestrator — plans, picks the dev, hands finished PRs to `@qa`, reports back to the issue creator (autopilot: merges approved PRs itself). Issue thread only; never edits files, never reads the PR diff. |
|
||||||
| `@junior` | `ollama-cloud/kimi-k2.7-code:cloud` | no | pr | — | Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to `@senior` or `@lead`. |
|
| `@junior` | `ollama-cloud/kimi-k2.7-code:cloud` | no | pr | — | Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to `@senior` or `@lead`. |
|
||||||
| `@senior` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api` | Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). |
|
| `@senior` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api` | Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). |
|
||||||
| `@lead` | `anthropic/claude-opus-4-8` | yes | pr | `gitea-api` | Tech lead — the hardest problems, architecture, and final calls. |
|
| `@lead` | `xai/grok-4.5` | yes | pr | `gitea-api` | Tech lead — the hardest problems, architecture, and final calls. |
|
||||||
| `@qa` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | QA / reviewer — reads the PR diff, drives a headless browser (Playwright) to verify behavior; recommendations on the PR, pass/fail verdict on the issue. Never edits code, never merges. |
|
| `@qa` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | QA / reviewer — reads the PR diff, drives a headless browser (Playwright) to verify behavior; recommendations on the PR, pass/fail verdict on the issue. Never edits code, never merges. |
|
||||||
| `@ops` | `anthropic/claude-opus-4-8` | no | comment | `gitea-admin` | Gitea operator — administers the instance itself (create orgs/users/repos, labels, secrets, scoped per-user tokens, bootstrap repos). Comments only; never edits code. Confirms before destructive actions. |
|
| `@ops` | `xai/grok-4.5` | no | comment | `gitea-admin` | Gitea operator — administers the instance itself (create orgs/users/repos, labels, secrets, scoped per-user tokens, bootstrap repos). Comments only; never edits code. Confirms before destructive actions. |
|
||||||
|
| `@intern` | `ollama/ornith:35b` | no | pr | — | Intern — very basic tasks only, routed to the local Ollama model (`ornith:35b`). Text-only, cannot read images. Escalates anything non-trivial to `@junior`, `@senior` or `@lead`. |
|
||||||
|
|
||||||
The registry `.gitea/workflows/scripts/agents.json` is the source of truth for this mapping — if you
|
The registry `.gitea/workflows/scripts/agents.json` is the source of truth for this mapping — if you
|
||||||
change a model or an agent's skills there, update this table too. (Repo-specific skills, e.g. a
|
change a model or an agent's skills there, update this table too. (Repo-specific skills, e.g. a
|
||||||
@@ -35,6 +36,24 @@ deploy-host SSH skill, live in the consuming repo under `.gitea/agent-skills/`
|
|||||||
`@pm` is the only agent that ever merges, and only under the `autopilot` label (its kill switch:
|
`@pm` is the only agent that ever merges, and only under the `autopilot` label (its kill switch:
|
||||||
remove the label mid-flight and the next step reverts to human control).
|
remove the label mid-flight and the next step reverts to human control).
|
||||||
|
|
||||||
|
### Discussion vs building
|
||||||
|
|
||||||
|
Mentioning a dev agent is a **conversation by default**: it reads what it needs and replies in the
|
||||||
|
thread — no branch, no PR. `@pm` can consult devs the same way with an `ASK: @<dev> <question>`
|
||||||
|
marker (gather feasibility/effort input before planning). **Building starts only on the explicit
|
||||||
|
signals**: `@pm`'s delegation (*"please proceed with issue …"* — a human can write the same phrase
|
||||||
|
to start a build directly), a `@qa` bounce (*"please address my review …"*), or any comment on the
|
||||||
|
PR thread itself (resuming existing work).
|
||||||
|
|
||||||
|
### Retros — the learning loop
|
||||||
|
|
||||||
|
Ask `@pm` for a retrospective on any issue (e.g. **"@pm run a retro"**, typically when merging). The
|
||||||
|
automation opens a `retro: issue #N` issue and assigns `@senior`, who reads the full issue + PR
|
||||||
|
threads (via the `gitea-api` skill), distills what went wrong or slow, and appends one-line
|
||||||
|
`symptom → rule` bullets to **`LEARNINGS.md`** at the repo root — through the normal PR choreography,
|
||||||
|
so the retro itself gets reviewed. `LEARNINGS.md` is injected into **every agent's prompt** on every
|
||||||
|
run, so the lessons actually change future behavior (better delegation, fewer repeated misses).
|
||||||
|
|
||||||
### Per-agent skill scoping
|
### Per-agent skill scoping
|
||||||
|
|
||||||
Skills load **on-demand**: only a skill's one-line `description` ever appears in an agent's
|
Skills load **on-demand**: only a skill's one-line `description` ever appears in an agent's
|
||||||
@@ -92,7 +111,7 @@ points `$SCRIPTS` at it. Keep the workflow and its scripts moving together on `m
|
|||||||
|
|
||||||
| Secret | For |
|
| Secret | For |
|
||||||
|--------|-----|
|
|--------|-----|
|
||||||
| `ANTHROPIC_API_KEY` | `@lead` (and `@pm`/`@senior`/`@qa` if on Claude) |
|
| `XAI_API_KEY` | `@lead`, `@ops` (and any other agent switched to a `xai/…` model) |
|
||||||
| `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, glm-5.2, minimax-m3) |
|
| `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, glm-5.2, minimax-m3) |
|
||||||
| `TOKEN_PM`,`TOKEN_SENIOR`,`TOKEN_JUNIOR`,`TOKEN_LEAD`,`TOKEN_QA` | **primary** — each agent's own Gitea-user PAT. The running agent gets *only its own* token (as `SELF_TOKEN`) so it posts, commits and comments as itself, and its `gitea-api` skill acts with its own scopes. Scopes: devs + `TOKEN_PM` carry `write:repository` (`@pm` is the only agent that merges, autopilot only); `TOKEN_QA` is `read:repository` + `write:issue` (reviews, never merges). |
|
| `TOKEN_PM`,`TOKEN_SENIOR`,`TOKEN_JUNIOR`,`TOKEN_LEAD`,`TOKEN_QA` | **primary** — each agent's own Gitea-user PAT. The running agent gets *only its own* token (as `SELF_TOKEN`) so it posts, commits and comments as itself, and its `gitea-api` skill acts with its own scopes. Scopes: devs + `TOKEN_PM` carry `write:repository` (`@pm` is the only agent that merges, autopilot only); `TOKEN_QA` is `read:repository` + `write:issue` (reviews, never merges). |
|
||||||
| `TOKEN_OPS` | `@ops` only — the admin PAT behind the `gitea-admin` skill (create orgs/users/repos, manage labels & secrets, mint scoped tokens). Injected into the agent process only when the agent is `@ops`. |
|
| `TOKEN_OPS` | `@ops` only — the admin PAT behind the `gitea-admin` skill (create orgs/users/repos, manage labels & secrets, mint scoped tokens). Injected into the agent process only when the agent is `@ops`. |
|
||||||
|
|||||||
Reference in New Issue
Block a user