Compare commits

..
Author SHA1 Message Date
Felix FaerberandClaude Opus 4.8 09c716a8c1 agents: fix review findings — thread attribution, races, rescue stall, docs, CI
ci / lint (push) Skipped
Fixes from a full repo review:

- fetch-thread.sh: attribute每 comment to its REAL author (@pm/@qa/… — agents
  post as their own users now); the old "🤖 @name line at the top" hint pointed
  at headers we removed, leaving every teammate comment anonymous. Also strip
  the hidden loop-prevention marker from bodies (prompt noise).
- agent.yml: per-issue concurrency group (queued, no cancel) — two quick
  comments on one issue no longer race the same ai/issue-N branch.
- rescue-pr.sh: after opening a rescue PR, hand it back into the flow with an
  unmarked @pm trigger (the pm→qa choreography previously stalled silently on
  any rescued run); drop the old "🤖 **@name**" header style; add ops to the
  token case; mark the status note with the hidden marker.
- README: token table said "@qa merges / TOKEN_QA needs write:repository" —
  inverted since the PM-orchestrated flow (@pm merges, autopilot only; @qa is
  read-only). Updated the agent table (descs, node1-ssh moved to homelab) and
  added a "How a task flows" section.
- agents.json: pm/qa descs now describe the orchestrator/reviewer roles (these
  feed the roster prompt agents route by).
- NEW ci.yml: bash -n + shellcheck(-S error) on every script, YAML-parse on
  every workflow, agents.json schema check — the ${x:-{}} brace bug would have
  been caught here before it shipped.
- install-opencode.sh: pin opencode (default 1.17.13, override via
  OPENCODE_VERSION) — a breaking release no longer takes down every agent.
- build-activity-log.sh: ollama/ollama-cloud models are subscription-billed
  (no $/token exists) — label the footer "subscription" instead of a
  misleading $0.0000; metered (anthropic) models keep the real dollar cost.
- route.sh: document that mention-priority is list-order and load-bearing for
  the flow's trigger comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:46:53 +03:00
13 changed files with 104 additions and 504 deletions
+24 -104
View File
@@ -4,35 +4,6 @@ name: agent
# The gate + steps run in the caller's event context (github.event.* / github.repository are the caller's). # The gate + steps run in the caller's event context (github.event.* / github.repository are the caller's).
on: on:
workflow_call: workflow_call:
# Explicit secret contract so callers can map secrets by name (more reliable than
# secrets: inherit alone on some Gitea versions / cross-owner reusable workflows).
secrets:
GITEA_TOKEN:
required: true
OLLAMA_URL:
required: false
OLLAMA_CLOUD_API_KEY:
required: false
XAI_API_KEY:
required: false
TOKEN_PM:
required: false
TOKEN_SENIOR:
required: false
TOKEN_JUNIOR:
required: false
TOKEN_LEAD:
required: false
TOKEN_QA:
required: false
TOKEN_OPS:
required: false
TOKEN_INTERN:
required: false
# Pinned opencode version — used to install it and to key the CI cache below.
env:
OPENCODE_VERSION: "1.17.13"
jobs: jobs:
@@ -40,40 +11,41 @@ jobs:
# 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
# Trusted author only, and only when a known agent is mentioned. This gate is the main # Trusted author only, and only when a known agent is mentioned. This gate is the main
# defense against malicious-issue prompt injection — do not loosen it. # defense against malicious-issue prompt injection — do not loosen it.
if: > if: >
(github.event.comment == null && (github.event.issue.user.login == 'ffaerber' || github.event.issue.user.login == 'hermes')) || (github.event.comment == null && github.event.issue.user.login == 'ffaerber') ||
(github.event.comment != null && (github.event.comment != null &&
(github.event.comment.user.login == 'ffaerber' || (github.event.comment.user.login == 'ffaerber' ||
github.event.comment.user.login == 'hermes' ||
github.event.comment.user.login == 'pm' || github.event.comment.user.login == 'pm' ||
github.event.comment.user.login == 'junior' || github.event.comment.user.login == 'junior' ||
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
@@ -135,55 +107,18 @@ 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 }}
# Accept common alternate names — empty XAI_API_KEY has bitten us when the
# secret was stored under a slightly different key on the caller repo.
XAI_API_KEY: ${{ secrets.XAI_API_KEY || secrets.XAI_KEY || secrets.GROK_API_KEY || secrets.XAI_TOKEN }}
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
@@ -198,7 +133,6 @@ 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.
@@ -207,7 +141,6 @@ 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
@@ -218,7 +151,6 @@ 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
@@ -228,7 +160,6 @@ 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 }}
@@ -236,23 +167,20 @@ 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
XAI_API_KEY: ${{ secrets.XAI_API_KEY || secrets.XAI_KEY || secrets.GROK_API_KEY || secrets.XAI_TOKEN }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_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 || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }} 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 || '' }}
NAME: ${{ steps.prep.outputs.name }} NAME: ${{ steps.prep.outputs.name }}
MODEL: ${{ steps.prep.outputs.model }} MODEL: ${{ steps.prep.outputs.model }}
FALLBACK: ${{ steps.prep.outputs.fallback }}
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
@@ -264,7 +192,6 @@ 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
@@ -273,7 +200,6 @@ 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 }}
@@ -283,10 +209,8 @@ 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 }}
@@ -301,7 +225,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() && steps.prep.outputs.mode != 'skip' if: failure()
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }} GT: ${{ secrets.GITEA_TOKEN }}
@@ -311,7 +235,6 @@ 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 }}
@@ -320,27 +243,24 @@ 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:
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 || '' }} GT: ${{ secrets.GITEA_TOKEN }}
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 $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || 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 curl -sS -X POST -H "Authorization: token $GT" -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() && steps.prep.outputs.mode != 'skip' if: failure()
env: 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 || '' }} GT: ${{ secrets.GITEA_TOKEN }}
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 $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || 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 curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true
+1 -14
View File
@@ -12,17 +12,4 @@ on:
jobs: jobs:
agent: agent:
uses: gitea/agents/.gitea/workflows/agent.yml@main uses: gitea/agents/.gitea/workflows/agent.yml@main
# Explicit secret map (plus inherit) so XAI_API_KEY / OLLAMA_* always reach the reusable secrets: inherit
# workflow. secrets: inherit alone has left XAI_API_KEY empty on some Gitea cross-owner calls.
secrets:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
OLLAMA_URL: ${{ secrets.OLLAMA_URL }}
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
TOKEN_PM: ${{ secrets.TOKEN_PM }}
TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }}
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
TOKEN_QA: ${{ secrets.TOKEN_QA }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }}
+7 -19
View File
@@ -1,34 +1,31 @@
{ {
"pm": { "pm": {
"model": "ollama-cloud/minimax-m3:cloud", "model": "ollama-cloud/gemma4:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": true, "vision": true,
"mode": "comment", "mode": "comment",
"skills": [ "skills": [
"gitea-api" "gitea-api"
], ],
"desc": "Product manager & orchestrator — plans and picks the dev, hands each finished PR to @qa for review, and reports back to the issue creator (in autopilot it merges approved PRs itself). Works from the issue thread only — comments only, never edits files, never reads the PR diff. Falls back to xAI grok-4.5 if Ollama Cloud is unavailable/quota-exhausted." "desc": "Product manager & orchestrator — plans and picks the dev, hands each finished PR to @qa for review, and reports back to the issue creator (in autopilot it merges approved PRs itself). Works from the issue thread only — comments only, never edits files, never reads the PR diff."
}, },
"junior": { "junior": {
"model": "ollama-cloud/kimi-k2.7-code:cloud", "model": "ollama-cloud/kimi-k2.7-code:cloud",
"fallback": "xai-oc/grok-4.3",
"vision": false, "vision": false,
"mode": "pr", "mode": "pr",
"skills": [], "skills": [],
"desc": "Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to @senior or @lead. Falls back to xAI grok-4.3 if Ollama Cloud fails." "desc": "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": { "senior": {
"model": "ollama-cloud/glm-5.2:cloud", "model": "ollama-cloud/glm-5.2:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": false, "vision": false,
"mode": "pr", "mode": "pr",
"skills": [ "skills": [
"gitea-api" "gitea-api"
], ],
"desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). Falls back to xAI grok-4.5 if Ollama Cloud fails." "desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."
}, },
"lead": { "lead": {
"model": "xai-oc/grok-4.5", "model": "anthropic/claude-opus-4-8",
"vision": true, "vision": true,
"mode": "pr", "mode": "pr",
"skills": [ "skills": [
@@ -38,29 +35,20 @@
}, },
"qa": { "qa": {
"model": "ollama-cloud/minimax-m3:cloud", "model": "ollama-cloud/minimax-m3:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": true, "vision": true,
"mode": "comment", "mode": "comment",
"skills": [ "skills": [
"gitea-api" "gitea-api"
], ],
"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. Falls back to xAI grok-4.5 if Ollama Cloud fails." "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": "xai-oc/grok-4.5", "model": "anthropic/claude-opus-4-8",
"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",
"fallback": "xai-oc/grok-4.3",
"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. Falls back to xAI grok-4.3 if local Ollama is down."
} }
} }
@@ -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 (xai-oc/…) get the real dollar cost opencode computed. # Metered providers (anthropic/…) 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.
{ {
printf '\n\n<details>\n<summary>details</summary>\n\n' if [ "$n" -gt 0 ]; then
printf '🔧 %s tool calls · in %s · out %s tokens · %s · model %s\n\n' "$n" "$IN_TOTAL" "$OUT" "$COSTF" "${MODEL:-?}" printf '\n\n<details>\n<summary>🔧 %s tool calls · in %s · out %s · %s</summary>\n\n' "$n" "$IN_TOTAL" "$OUT" "$COSTF"
if [ "$n" -gt 0 ]; then cat /tmp/tools.md; else printf '_(no tool calls — text-only reply)_\n'; fi cat /tmp/tools.md
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 -21
View File
@@ -4,31 +4,14 @@
# 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}"
: > /tmp/thread_pages.json curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=100" 2>/dev/null \
for page in $(seq 1 10); do | jq -r '.[] |
pg=$(curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=50&page=$page" 2>/dev/null) || pg='[]'
n=$(printf '%s' "$pg" | jq 'if type=="array" then length else 0 end' 2>/dev/null || echo 0)
[ "${n:-0}" -gt 0 ] && printf '%s\n' "$pg" >> /tmp/thread_pages.json
[ "${n:-0}" -lt 50 ] && break
done
jq -rs '
add // [] | .[-100:] | .[] |
( if (.user.login == "ffaerber") then "@ffaerber (the maintainer)" ( if (.user.login == "ffaerber") then "@ffaerber (the maintainer)"
else "@" + .user.login end ) as $who | else "@" + .user.login end ) as $who |
"### comment by \($who):\n\(.body | gsub("\\s*<!-- 🤖 agent reply — do not trigger -->"; ""))\n"' \ "### 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 > /tmp/thread.md 2>/dev/null || true
echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0) (newest 100 kept)" echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0)"
# 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)"
+11 -41
View File
@@ -1,30 +1,15 @@
#!/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 XAI_API_KEY # Required env (provided by the workflow step): OLLAMA_URL OLLAMA_CLOUD_API_KEY NAME SKILLS
# NAME SKILLS GITHUB_PATH HOME # GITHUB_PATH HOME
set -eu set -eu
# Non-secret diagnostics — prove which provider keys reached the runner (length only).
echo "provider key lengths: OLLAMA_URL=${#OLLAMA_URL} OLLAMA_CLOUD_API_KEY=${#OLLAMA_CLOUD_API_KEY} XAI_API_KEY=${#XAI_API_KEY}"
if [ -z "${XAI_API_KEY:-}" ]; then
echo "WARNING: XAI_API_KEY is empty in this job. xai-oc fallback will fail."
echo "Fix: set Actions secret XAI_API_KEY on the CALLER repo (e.g. ffaerber/homelab),"
echo "not only on gitea/agents. Name must be exact: XAI_API_KEY"
fi
# 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}"
# 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" 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
@@ -46,32 +31,17 @@ 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"})) )}')
# Three OpenAI-compatible providers: local self-hosted ollama (ornith) + Ollama Cloud # Two ollama providers: local self-hosted (ornith) + Ollama Cloud (gemma4/kimi-k2.7-code/glm-5.2/minimax-m3).
# (gemma4/kimi-k2.7-code/glm-5.2/minimax-m3) + xAI (grok-4.5). The provider `models:` maps are # The ollama-cloud `models:` map is DERIVED from agents.json (the single source of truth, shared with
# DERIVED from agents.json (the single source of truth, shared with route.sh) so every model an # route.sh) so every model an agent is routed to is always declared in the provider config. Only the
# agent is routed to is always declared in the provider config. `ollama-cloud/` prefix models go to # `ollama-cloud/` provider prefix models participate — e.g. `anthropic/claude-opus-4-8` (@lead) is a
# the cloud provider; `ollama/` prefix models go to the local provider; `xai-oc/` prefix models go # built-in provider and `ornith:35b` is local-only, neither belongs here. See issue #31.
# to our xAI shim (OpenAI-compatible, https://api.x.ai/v1). No other built-in providers remain.
#
# The provider key is `xai-oc`, NOT `xai` — opencode ships a built-in `xai` entry in its own model
# catalog (a real @ai-sdk/xai integration that implements the Responses API). Naming our custom
# openai-compatible shim `xai` collides with that catalog entry: opencode's --auto permission-check
# path then assumes the catalog's provider (which has `.responses()`) and crashes with
# "Z.responses is not a function" on every run once a `permission` block is present (i.e. always in
# this pipeline). Confirmed by reproducing locally with the production config shape on opencode
# 1.17.13 — renaming the key to `xai-oc` avoids the collision entirely. See issue #118.
# See issue #31.
AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json" AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json"
# Include primary `.model` AND optional `.fallback` so failover models are always registered CLOUD_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama-cloud/")) | sub("^ollama-cloud/";"")] | map({(.):{}}) | add // {}' "$AGENTS_JSON")
# in opencode provider maps (issue: Ollama Cloud quota → xAI). jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --argjson cloud "$CLOUD_MODELS" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" '{
CLOUD_MODELS=$(jq -r '[.[] | (.model, .fallback) | select(type=="string" and startswith("ollama-cloud/")) | sub("^ollama-cloud/";"")] | unique | map({(.):{}}) | add // {}' "$AGENTS_JSON")
LOCAL_MODELS=$(jq -r '[.[] | (.model, .fallback) | select(type=="string" and startswith("ollama/")) | sub("^ollama/";"")] | unique | map({(.):{}}) | add // {"ornith:35b":{}}' "$AGENTS_JSON")
XAI_MODELS=$(jq -r '[.[] | (.model, .fallback) | select(type=="string" and startswith("xai-oc/")) | sub("^xai-oc/";"")] | unique | 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:$local}, ollama: {npm:"@ai-sdk/openai-compatible", options:{baseURL:($url+"/v1")}, models:{"ornith:35b":{}}},
"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-oc": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://api.x.ai/v1", apiKey:$xkey}, models:$xai}
}, },
permission: $perm, permission: $perm,
mcp: $mcp mcp: $mcp
+24 -129
View File
@@ -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";; intern) TOK="$TOKEN_INTERN";; *) TOK="";; lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) 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,14 +44,8 @@ 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}"
# All OPEN PRs belonging to this issue, oldest→newest. Matches ai/issue-N AND the ai/issue-N-<slug> resolve_pr() { curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \
# split branches AGENTS.md tells devs to use — an exact-only match silently stalled the flow on | 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; }
# 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() {
@@ -66,17 +60,15 @@ del_autopilot_label() {
fi fi
} }
# drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / RETRO / APPROVE / HALT / BOUNCE, # drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / APPROVE / HALT / BOUNCE, and the
# and the BEGIN_SUBTASKS..END_SUBTASKS and BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR # 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}
@@ -109,37 +101,26 @@ 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 ----------
# ALL technical review detail lands ON THE PR (onsite the diff); the ISSUE gets only the terse # Recommendations land ON THE PR (onsite the diff); the pass/fail verdict lands ON THE ISSUE so
# pass/fail verdict so @pm (who never reads the PR) can act on it and the issue thread — which is # @pm (who never reads the PR) can act on it. Ends its reply with APPROVE / BOUNCE: @dev / HALT.
# 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
if [ -n "$PRN" ]; then post_to "$ISSN" "$(printf '✅ Reviewed PR #%s — looks good.\n\n%s%s' "${PRN:-?}" "$reply" "$activity")"
post_to "$PRN" "$reply$activity" # the review detail belongs on the PR trig "$ISSN" "@pm — @qa approved PR #${PRN:-?} (issue #$ISSN). Over to you."
post_to "$ISSN" "✅ Reviewed PR #$PRN — looks good." elif grep -qiE '^[[:space:]]*BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md; then
else # no PR resolved — nowhere better than the issue dev=$(grep -oiE 'BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md | head -1 | grep -oiE '(junior|senior|lead)' | tr '[:upper:]' '[:lower:]')
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 bounce TRIGGERS on the PR thread — only @qa-authored comments # Bounce budget: count prior "fix attempt" markers on the PR thread; stop after 3.
# matching the exact "(fix attempt N/3)" template. A loose substring match would also count 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}
# 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))
# NOTE: this template and the counter regex above MUST stay in sync — if you reword one, trig "$dest" "@${dev:-junior} please address @qa's review above and update PR #${PRN:-?} (fix attempt $n/3)."
# 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"
@@ -151,7 +132,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|intern)' /tmp/agent_out.md 2>/dev/null | head -1 | grep -oiE '(junior|senior|lead|qa|intern)' | tr '[:upper:]' '[:lower:]') 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:]')
# 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
@@ -195,43 +176,15 @@ 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
# 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")" 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
PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w) PRN=$(resolve_pr)
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 \
@@ -250,55 +203,20 @@ 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
PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w) PRN=$(resolve_pr)
if [ -n "$PRN" ] && [ "$CNT" -gt 1 ]; then if [ -n "$PRN" ]; 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 my plan above." trig "$ISSN" "@$target please proceed with issue #$ISSN per the plan above (delegated by $NAME)."
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)"
@@ -306,15 +224,6 @@ 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
@@ -334,14 +243,7 @@ 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
if git push origin "HEAD:$BRANCH"; then git push origin "HEAD:$BRANCH" || true
:
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")
@@ -367,21 +269,14 @@ 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_body=/tmp/pr_create_resp.json resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \
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}')")
resp=$(cat "$resp_body" 2>/dev/null || true) echo "PR create ($br): $resp"
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
if [ -z "$url" ]; then [ -z "$url" ] && { echo "PR open/lookup failed for $br — posting reply on issue instead"; post "$(printf '%s%s' "$reply" "$activity")"; exit 0; }
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() {
@@ -402,6 +297,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|intern) trig "$prnum" "@qa please re-verify PR #$prnumI have pushed an update." ;; junior|senior|lead) trig "$prnum" "@qa please re-verify PR #$prnumthe dev has pushed an update." ;;
esac esac
fi fi
+1 -1
View File
@@ -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";; intern) TOK="$TOKEN_INTERN";; *) TOK="";; lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) 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.
+7 -40
View File
@@ -31,54 +31,24 @@ 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.
# WORD-BOUNDARY match, not substring: "@internal" or "x@internet.com" must NOT route to @intern for a in pm junior senior lead qa ops; do
# (the workflow gate can only do contains(), so this is where its false positives get filtered). case "$scan" in *"@$a"*) name=$a; break;; esac
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 if [ -z "$CID" ]; then name=pm; else echo "no known agent mentioned"; exit 1; fi
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 "fallback="; 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)
# Optional provider failover when primary is quota/network-dead (e.g. Ollama Cloud → xAI).
fallback=$(jq -r --arg a "$name" '.[$a].fallback // empty' /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)"
# --- WORKMODE for dev (mode=pr) agents: build vs DISCUSS. --- { echo "name=$name"; echo "model=$model"; echo "vision=$vision"; echo "mode=$mode"; echo "skills=$skills"; } >> "$GITHUB_OUTPUT"
# 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 fallback=${fallback:-none} vision=$vision mode=$mode workmode=$workmode skills=$skills)"
{ echo "name=$name"; echo "model=$model"; echo "fallback=$fallback"; 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";; intern) TOK="$TOKEN_INTERN";; *) TOK="";; lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";;
esac esac
[ -z "$TOK" ] && TOK="$GT" [ -z "$TOK" ] && TOK="$GT"
git config user.name "$name" git config user.name "$name"
@@ -86,10 +56,7 @@ 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 [ "$workmode" = "discuss" ]; then # conversation only — no branch, no PR machinery if [ -n "$IS_PR" ]; then # comment on a PR -> resume its branch
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"
+5 -74
View File
@@ -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):
# XAI_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE # ANTHROPIC_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 @intern) — but ONLY when you are ready to hand off AND need nothing further from the @junior @senior @lead @qa) — 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,15 +54,7 @@ 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
marker line does. DELEGATE 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
@@ -94,22 +86,13 @@ 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 / @intern — usually whoever built it). The automation sends the PR back and it (@junior / @senior / @lead — 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,
@@ -119,42 +102,15 @@ 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}
@@ -181,39 +137,14 @@ 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}"
# Provider failover: when primary model dies on quota/auth/provider errors, switch once to
# FALLBACK (from agents.json) and continue the retry loop. Transient rate-limits still back off
# on the current model first.
FALLBACK_MODEL="${FALLBACK:-}"
fallback_used=0
is_failover_error() {
# Ollama Cloud exhausted / provider hard-fail — switch to fallback rather than thrash.
grep -qiE 'overloaded|429|529|rate.?limit|timeout|ETIMEDOUT|ECONNRESET|EAI_AGAIN|quota|credit|balance|usage.?limit|limit.?exceed|402|403|401|insufficient|out of credits|payment.?required|model_not_found|not found|Unavailable|capacity|ENOTFOUND|ECONNREFUSED' \
/tmp/events.jsonl /tmp/agent_err.log 2>/dev/null
}
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "opencode attempt $attempt/3 for @$NAME ($MODEL, timeout ${AGENT_TIMEOUT}s)" echo "opencode attempt $attempt/3 for @$NAME ($MODEL)"
rc=0 rc=0
timeout -k 30 "$AGENT_TIMEOUT" \
opencode run --model "$MODEL" --auto --format json "$PROMPT" ${FILES:-} \ 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 [ $fallback_used -eq 0 ] && [ -n "$FALLBACK_MODEL" ] && [ "$FALLBACK_MODEL" != "$MODEL" ] && is_failover_error; then
echo "primary model failed — failing over to fallback: $FALLBACK_MODEL"
MODEL="$FALLBACK_MODEL"
fallback_used=1
# short pause then use next attempt slot on the fallback provider
sleep 2
continue
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
+1 -14
View File
@@ -107,22 +107,9 @@ 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 (XAI_API_KEY, SELF_TOKEN, 5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_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`.
-9
View File
@@ -1,9 +0,0 @@
# 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.
+4 -23
View File
@@ -7,13 +7,12 @@ 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/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. | | `@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. |
| `@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` | `xai-oc/grok-4.5` | yes | pr | `gitea-api` | Tech lead — the hardest problems, architecture, and final calls. | | `@lead` | `anthropic/claude-opus-4-8` | 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` | `xai-oc/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. | | `@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. |
| `@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
@@ -36,24 +35,6 @@ 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
@@ -111,7 +92,7 @@ points `$SCRIPTS` at it. Keep the workflow and its scripts moving together on `m
| Secret | For | | Secret | For |
|--------|-----| |--------|-----|
| `XAI_API_KEY` | `@lead`, `@ops` (and any other agent switched to a `xai-oc/…` model) | | `ANTHROPIC_API_KEY` | `@lead` (and `@pm`/`@senior`/`@qa` if on Claude) |
| `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`. |