From 09c716a8c13cdf26cce55b962143877983f841b1 Mon Sep 17 00:00:00 2001 From: Felix Faerber Date: Mon, 6 Jul 2026 09:46:53 +0300 Subject: [PATCH] =?UTF-8?q?agents:=20fix=20review=20findings=20=E2=80=94?= =?UTF-8?q?=20thread=20attribution,=20races,=20rescue=20stall,=20docs,=20C?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/agent.yml | 9 ++- .gitea/workflows/ci.yml | 56 +++++++++++++++++++ .gitea/workflows/scripts/agents.json | 4 +- .../workflows/scripts/build-activity-log.sh | 10 +++- .gitea/workflows/scripts/fetch-thread.sh | 12 ++-- .gitea/workflows/scripts/install-opencode.sh | 6 +- .gitea/workflows/scripts/rescue-pr.sh | 22 ++++++-- .gitea/workflows/scripts/route.sh | 4 ++ README.md | 37 +++++++++--- 9 files changed, 136 insertions(+), 24 deletions(-) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/agent.yml b/.gitea/workflows/agent.yml index 743d2b5..939338c 100644 --- a/.gitea/workflows/agent.yml +++ b/.gitea/workflows/agent.yml @@ -6,8 +6,14 @@ on: workflow_call: jobs: - + agent: + # 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 + # runs wait (no cancel) so every trigger is still processed, just serially. + concurrency: + group: ai-agent-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: false # 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. if: > @@ -190,6 +196,7 @@ jobs: env: SCRIPTS: ${{ runner.temp }}/agents-scripts MODE: ${{ steps.prep.outputs.mode }} + MODEL: ${{ steps.prep.outputs.model }} # ollama-cloud models are subscription-billed (no $/token) run: bash "$SCRIPTS/build-activity-log.sh" - name: Publish β€” PR (dev agents) or comment (pm), always reply in the issue diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..5e40202 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,56 @@ +name: ci +run-name: "ci Β· ${{ github.event.pull_request.title || github.sha }}" +# Lint the very scripts every agent run executes. A single unchecked shell bug here breaks ALL +# agents in ALL repos at once (e.g. the ${VAR:-{}} brace bug shellcheck flags as SC1083/SC2321), +# so PRs must pass: bash -n + shellcheck on every script, YAML-parse on every workflow, and a +# schema check on agents.json (the routing registry). +on: + pull_request: + push: + branches: [main] + +jobs: + lint: + runs-on: ci-runner + steps: + - uses: actions/checkout@v4 + + - name: Install linters + run: | + command -v shellcheck >/dev/null || (apt-get update -qq && apt-get install -y -qq shellcheck) || \ + sudo sh -c 'apt-get update -qq && apt-get install -y -qq shellcheck' || true + python3 -c 'import yaml' 2>/dev/null || pip3 install --quiet pyyaml || \ + (apt-get install -y -qq python3-yaml || sudo apt-get install -y -qq python3-yaml) || true + + - name: bash -n (syntax) β€” every script + run: | + set -e + for f in .gitea/workflows/scripts/*.sh; do bash -n "$f" && echo "OK $f"; done + + - name: shellcheck β€” every script + run: | + set -e + if command -v shellcheck >/dev/null; then + # error-severity only: the scripts intentionally use unquoted word-splitting in places; + # errors (real breakage like the ${x:-{}} brace bug) must fail the build. + shellcheck -S error .gitea/workflows/scripts/*.sh && echo "shellcheck clean (severity=error)" + else + echo "shellcheck unavailable on runner β€” skipped" + fi + + - name: YAML-parse every workflow + run: | + set -e + python3 - <<'EOF' + import glob, sys, yaml + for f in sorted(glob.glob('.gitea/workflows/*.yml')): + yaml.safe_load(open(f)) + print('OK', f) + EOF + + - name: Validate agents.json (registry schema) + run: | + set -e + jq -e 'to_entries | all(.value | (.model|type=="string") and (.mode=="pr" or .mode=="comment") + and (.vision|type=="boolean") and (.skills|type=="array") and (.desc|type=="string"))' \ + .gitea/workflows/scripts/agents.json >/dev/null && echo "agents.json OK" diff --git a/.gitea/workflows/scripts/agents.json b/.gitea/workflows/scripts/agents.json index ba1e8cb..c1ce189 100644 --- a/.gitea/workflows/scripts/agents.json +++ b/.gitea/workflows/scripts/agents.json @@ -6,7 +6,7 @@ "skills": [ "gitea-api" ], - "desc": "Product manager β€” research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files." + "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": { "model": "ollama-cloud/kimi-k2.7-code:cloud", @@ -40,7 +40,7 @@ "skills": [ "gitea-api" ], - "desc": "QA β€” verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs." + "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": { "model": "anthropic/claude-opus-4-8", diff --git a/.gitea/workflows/scripts/build-activity-log.sh b/.gitea/workflows/scripts/build-activity-log.sh index 15a6866..13906cf 100755 --- a/.gitea/workflows/scripts/build-activity-log.sh +++ b/.gitea/workflows/scripts/build-activity-log.sh @@ -36,14 +36,20 @@ read -r COST INP OUT CR CW RE < <(jq -rs ' | @tsv' "$E" 2>/dev/null) COST=${COST:-0}; INP=${INP:-0}; OUT=${OUT:-0}; CR=${CR:-0}; CW=${CW:-0}; RE=${RE:-0} IN_TOTAL=$(( INP + CR + CW )) # total input context processed -COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}') +# 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. +# Metered providers (anthropic/…) get the real dollar cost opencode computed. +case "${MODEL:-}" in + ollama*|*"/ollama"*) COSTF="subscription" ;; + *) COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}') ;; +esac echo "usage: in=$IN_TOTAL out=$OUT cost=$COSTF (fresh=$INP cache_r=$CR cache_w=$CW reasoning=$RE); tools=$n" { if [ "$n" -gt 0 ]; then printf '\n\n
\nπŸ”§ %s tool calls Β· in %s Β· out %s Β· %s\n\n' "$n" "$IN_TOTAL" "$OUT" "$COSTF" cat /tmp/tools.md - printf '\n\ntokens β€” input %s (fresh %s Β· cache %sw / %sr) Β· output %s Β· reasoning %s Β· **cost %s**\n
' \ + printf '\n\ntokens β€” input %s (fresh %s Β· cache %sw / %sr) Β· output %s Β· reasoning %s Β· **%s**\n' \ "$IN_TOTAL" "$INP" "$CW" "$CR" "$OUT" "$RE" "$COSTF" else printf '\n\nπŸ’° **%s** Β· in %s Β· out %s tokens (cache %sw / %sr)' "$COSTF" "$IN_TOTAL" "$OUT" "$CW" "$CR" diff --git a/.gitea/workflows/scripts/fetch-thread.sh b/.gitea/workflows/scripts/fetch-thread.sh index 58f459c..99549ef 100755 --- a/.gitea/workflows/scripts/fetch-thread.sh +++ b/.gitea/workflows/scripts/fetch-thread.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash # Fetch the full issue thread (shared memory) into /tmp/thread.md. +# Agents post as their OWN Gitea users, so .user.login IS the agent name β€” attribute each comment +# 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. # # Required env (provided by the workflow step): GT NUM GITHUB_SERVER_URL GITHUB_REPOSITORY set -eu @@ -7,9 +10,8 @@ set -eu API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=100" 2>/dev/null \ | jq -r '.[] | - ( if (.body | test("delegated by")) then "an automated delegation" - elif (.user.login == "ffaerber") then "ffaerber (the maintainer / you)" - else "an AI teammate β€” the specific one is named in the πŸ€– @name line at the top of the comment" - end ) as $who | - "### comment by \($who):\n\(.body)\n"' > /tmp/thread.md 2>/dev/null || true + ( if (.user.login == "ffaerber") then "@ffaerber (the maintainer)" + else "@" + .user.login end ) as $who | + "### comment by \($who):\n\(.body | gsub("\\s*"; ""))\n"' \ + > /tmp/thread.md 2>/dev/null || true echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0)" diff --git a/.gitea/workflows/scripts/install-opencode.sh b/.gitea/workflows/scripts/install-opencode.sh index c1e91e7..0707268 100755 --- a/.gitea/workflows/scripts/install-opencode.sh +++ b/.gitea/workflows/scripts/install-opencode.sh @@ -5,7 +5,11 @@ # GITHUB_PATH HOME set -eu -curl -fsSL https://opencode.ai/install | bash +# 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 +# at once. Bump deliberately by changing this default (or set OPENCODE_VERSION in the step env). +OPENCODE_VERSION="${OPENCODE_VERSION:-1.17.13}" +curl -fsSL https://opencode.ai/install | bash -s -- --version "$OPENCODE_VERSION" echo "$HOME/.opencode/bin" >> "$GITHUB_PATH" mkdir -p ~/.config/opencode # Playwright browser MCP only for agents that need to drive a web app diff --git a/.gitea/workflows/scripts/rescue-pr.sh b/.gitea/workflows/scripts/rescue-pr.sh index 2579696..23c12a9 100755 --- a/.gitea/workflows/scripts/rescue-pr.sh +++ b/.gitea/workflows/scripts/rescue-pr.sh @@ -6,7 +6,7 @@ # the run. Comment-only roles (pm/qa) push nothing, so they are skipped. # # Required env (provided by the workflow step): -# GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA +# GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA TOKEN_OPS # NAME MODE NUM TITLE BRANCH GITHUB_SERVER_URL GITHUB_REPOSITORY set +e @@ -17,9 +17,11 @@ set +e # Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot. case "$NAME" in pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";; - lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; *) TOK="";; + lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";; esac [ -z "$TOK" ] && TOK="$GT" +# Trigger token: the @pm hand-back below must FIRE a new run, which the built-in token cannot. +TTOK="$TOK"; [ "$TTOK" = "$GT" ] && TTOK="" API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json") @@ -38,18 +40,30 @@ resp=$(curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \ | jq -r --arg br "$BRANCH" 'if type=="array" then (map(select(.head.ref==$br)) | .[0] // empty) else empty end' 2>/dev/null) url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null) if [ -z "$url" ]; then - body=$(printf 'The run failed before it could publish, but pushed work exists on this branch β€” opening a PR so it is not lost.\n\n---\nResolves #%s Β· πŸ€– @%s (auto-rescued after a failed run)' "$NUM" "$NAME") + body=$(printf 'The run failed before it could publish, but pushed work exists on this branch β€” opening a PR so it is not lost.\n\n---\nResolves #%s (auto-rescued after a failed run)' "$NUM") resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \ -d "$(jq -nc --arg t "@$NAME: $TITLE" --arg h "$BRANCH" --arg b "$body" \ '{title:$t, head:$h, base:"main", body:$b}')") echo "rescue PR create ($BRANCH): $resp" url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null) + prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null) +else + prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null) fi if [ -n "$url" ]; then + # Status note on the issue (marked β€” must not trigger)… curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \ - -d "$(jq -nc --arg b "$(printf 'πŸ€– **@%s** β€” ⚠️ the run failed, but your pushed work was not lost β€” a PR was opened for branch \`%s\`:\n- %s' "$NAME" "$BRANCH" "$url")" '{body:$b}')" \ + -d "$(jq -nc --arg b "$(printf '⚠️ The run failed, but the pushed work was not lost β€” a PR was opened for branch \`%s\`:\n- %s\n\n' "$BRANCH" "$url")" '{body:$b}')" \ -w '\nrescue comment -> HTTP %{http_code}\n' || true + # …then hand the rescued PR back into the flow: without this, the pmβ†’qa choreography would stall + # here (the normal "PR ready" trigger never fired). Unmarked + PAT so it starts @pm's run. + if [ -n "$TTOK" ]; then + curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \ + "$API/issues/$NUM/comments" \ + -d "$(jq -nc --arg b "@pm β€” PR #${prnum:-?} was auto-rescued after a failed run (issue #$NUM). Please route it for review." '{body:$b}')" \ + -w '\nrescue trigger @pm -> HTTP %{http_code}\n' || true + fi else echo "rescue: could not open/find a PR for $BRANCH" fi diff --git a/.gitea/workflows/scripts/route.sh b/.gitea/workflows/scripts/route.sh index 8d1fe15..b691290 100755 --- a/.gitea/workflows/scripts/route.sh +++ b/.gitea/workflows/scripts/route.sh @@ -27,6 +27,10 @@ cp "$AGENTS_JSON" /tmp/agents.json # here β€” see agent.yml: this reusable workflow sees it as 'workflow_call'.) if [ -n "$CID" ]; then scan="$BODY"; else scan="$IBODY"; fi name="" +# FIRST MATCH IN THIS LIST ORDER WINS when a comment mentions several agents. The order 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 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 case "$scan" in *"@$a"*) name=$a; break;; esac done diff --git a/README.md b/README.md index 9a76d9e..84aaa7c 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,33 @@ Shared **AI dev-team** workflow for Gitea Actions, reusable across repos. It giv | Agent | Model | Vision | Mode | Skills | Role | |-------|-------|:------:|------|--------|------| -| `@pm` | `ollama-cloud/gemma4:cloud` | yes | comment | `gitea-api` | Product manager β€” research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files. | +| `@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`. | -| `@senior` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api`, `node1-ssh` | Senior dev β€” complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). | -| `@lead` | `anthropic/claude-opus-4-8` | yes | pr | `gitea-api`, `node1-ssh` | Tech lead β€” the hardest problems, architecture, and final calls. | -| `@qa` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | QA β€” verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs. | +| `@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. | +| `@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. | -`agent.yml`'s agent registry is the source of truth for this mapping β€” if you change a model -or an agent's skills there, update this table too. +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 +deploy-host SSH skill, live in the consuming repo under `.gitea/agent-skills/` β€” not in this table.) + +## How a task flows + +`@pm` orchestrates from the **issue thread**; the review happens on the **PR**; `@pm` never reads the PR +(keeps its context small) and `@qa` never merges. + +1. **Issue opened** β†’ `@pm` plans and names a dev, then asks the creator *"ready? reply yes"* + (with the `autopilot` label it skips the question and delegates immediately). +2. **Dev builds** on `ai/issue-N`, a PR opens automatically, and the dev pings `@pm` on the issue. +3. `@pm` hands the PR to **`@qa`**. +4. `@qa` reviews **on the PR** β€” either recommendations + `BOUNCE: @dev` (dev fixes β†’ `@qa` + re-verifies, direct loop, max 3 rounds) or `APPROVE`. +5. On approval `@qa` posts the verdict **on the issue** β†’ `@pm` tells the creator *"ready to merge"* + and a **human merges** β€” or, with the `autopilot` label, `@pm` merges and closes the issue itself. + +`@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). ### Per-agent skill scoping @@ -76,12 +94,13 @@ points `$SCRIPTS` at it. Keep the workflow and its scripts moving together on `m |--------|-----| | `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) | -| `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, comments, and (for `@qa` autopilot) merges as itself, and its `gitea-api` skill acts with its own scopes. `TOKEN_QA` needs `write:repository` to merge. | +| `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`. | Each agent authenticates as **itself**: the Run-agent step selects that agent's `TOKEN_*` into -`SELF_TOKEN` (never another agent's), and `publish.sh` uses the same token for the delegation/autopilot -trigger comment and `@qa` merges β€” the two things the built-in `GITEA_TOKEN` can't do (it won't start +`SELF_TOKEN` (never another agent's), and `publish.sh` uses the same token for the trigger comments +that drive the flow (delegation, `@qa` hand-offs, bounces) and for `@pm`'s autopilot merge β€” the two +things the built-in `GITEA_TOKEN` can't do (it won't start new runs, and a merge under it won't fire downstream deploys). So **every consuming repo must carry the per-agent `TOKEN_*` secrets** (org-level for `gitea/*`, user-level for `ffaerber/*`); there is no shared fallback token. `GITEA_TOKEN` is auto-provided (used for reads). Tip: set the `TOKEN_*` once at the