Compare commits

..
Author SHA1 Message Date
hermes 04b15a2c27 fix(agents): wire XAI_API_KEY through reusable workflow (#122)
ci / lint (push) Successful in 14s
Declare workflow_call secrets, diagnostics, explicit caller template.
2026-07-30 15:47:35 +02:00
hermes 9dc1c203cb fix(agents): declare workflow_call secrets + diagnose empty XAI_API_KEY
ci / lint (pull_request) Successful in 14s
Cross-owner reusable calls left XAI_API_KEY empty in the runner while
OLLAMA_CLOUD_API_KEY worked. Declare secrets on workflow_call, accept
alternate secret names, log key lengths (not values), and ship an
ai-agent.yml caller template with an explicit secrets map.
2026-07-30 16:47:13 +03:00
hermes 58db2996ad feat(agents): automatic Ollama Cloud → xAI fallback (#121)
ci / lint (push) Successful in 14s
Primary stays ollama-cloud; on quota/provider failure run-agent fails over once to agents.json fallback (xAI).
2026-07-30 15:28:44 +02:00
hermes 0ddde87b40 feat(agents): automatic model fallback when Ollama Cloud is exhausted
ci / lint (pull_request) Successful in 13s
Keep ollama-cloud/* as primary. Each agent may declare fallback in
agents.json (xai-oc/grok-*). On quota/auth/provider failure, run-agent
switches once to the fallback and continues retries.

- agents.json: fallback fields for pm/junior/senior/qa/intern
- route.sh: emit fallback to GITHUB_OUTPUT
- agent.yml: pass FALLBACK env into run-agent
- install-opencode.sh: register primary+fallback models in provider maps
- run-agent.sh: failover on quota/credit/429/capacity-class errors
2026-07-30 16:28:30 +03:00
hermes bbdb200f32 fix(agents): route pm/junior/senior/qa to xAI while Ollama Cloud is exhausted
ci / lint (pull_request) Successful in 13s
Ollama Cloud usage is at 100% so ollama-cloud/* agents fail. Move
pm, junior, senior, qa to xai-oc (grok-4.5 / grok-4.3). leave intern on
local ollama/ornith:35b; lead/ops already on xAI.
2026-07-30 16:25:46 +03:00
ffaerber d7d54546a3 Merge pull request 'Allow hermes user to trigger AI agent workflow' (#120) from ai/issue-218-allow-hermes into main
ci / lint (push) Successful in 13s
Reviewed-on: #120
2026-07-30 14:07:49 +02:00
hermes 38b36b2304 feat: allow hermes user to trigger AI agent workflow
ci / lint (pull_request) Successful in 14s
Add 'hermes' to the trusted issue creators and comment authors in the
workflow gate so the Hermes Agent can create issues and comment with
@mentions to trigger the AI agent pipeline.
2026-07-30 15:06:05 +03:00
ffaerber e040247097 Merge pull request 'fix(agents): rename xai provider key to xai-oc to avoid opencode catalog collision' (#119) from hotfix/xai-provider-name-collision into main
ci / lint (push) Successful in 14s
2026-07-30 13:08:37 +02:00
Felix Faerber c8efc17729 fix(agents): rename xai provider key to xai-oc to avoid opencode catalog collision (#118)
ci / lint (pull_request) Successful in 17s
opencode ships a built-in `xai` model-catalog entry (a real @ai-sdk/xai
integration with Responses-API support). Naming our custom OpenAI-compatible
shim `xai` too made opencode's --auto permission-check path assume the
catalog's provider object once a `permission` block is present (always, in
this pipeline) and call .responses(), which our shim doesn't implement —
crashing every @lead/@ops run with "Z.responses is not a function".

Reproduced locally with the production config shape on opencode 1.17.13;
renaming the provider key to xai-oc (model id xai-oc/grok-4.5) fixes it
reliably across repeated fresh-state runs.
2026-07-30 14:08:21 +03:00
ffaerber 60f9e95ecc Merge pull request 'hotfix: wire the xai provider into install-opencode.sh' (#115) from hotfix/xai-provider-wiring into main
ci / lint (push) Successful in 14s
2026-07-27 15:49:45 +02:00
Felix Faerber dd25c0ac2b hotfix: wire the xai provider into install-opencode.sh
ci / lint (pull_request) Successful in 21s
PR #114 pointed @lead/@ops at xai/grok-4.5 but never gave opencode a
provider config for it — xai is not a built-in like anthropic was, it
needs the same explicit OpenAI-compatible block as ollama/ollama-cloud
(confirmed by the prior, never-merged investigation in PR #110). Without
this, any @lead/@ops run fails immediately on an unresolvable provider.

Adds the xai provider block (baseURL https://api.x.ai/v1, models derived
from agents.json) and passes XAI_API_KEY into the Install-opencode step
(it was already added to the Run-agent step in #114).
2026-07-27 16:49:20 +03:00
ffaerber 8ae6abd9d0 Merge pull request 'chore: switch @lead/@ops from Anthropic to xAI grok-4.5' (#114) from chore/xai-grok-4.5 into main
ci / lint (push) Successful in 17s
2026-07-27 15:43:21 +02:00
Felix Faerber b30db8a4c9 chore: switch @lead/@ops from Anthropic to xAI grok-4.5
ci / lint (pull_request) Successful in 15s
Removes the last Anthropic-model usage from the agent registry.
XAI_API_KEY was already provisioned as an org secret; wire it into
the run-agent step and update every doc/comment that referenced
ANTHROPIC_API_KEY or claude-opus-4-8.
2026-07-27 16:43:05 +03:00
ffaerber 6955bc4f71 Merge pull request '@junior: publish.sh: PR-creation and push failures are silently swallowed' (#113) from ai/issue-112 into main
ci / lint (push) Successful in 32s
2026-07-27 15:33:43 +02:00
junior 0a06c813bc publish.sh: address review — fix PR-failure format, capture HTTP status, append activity to push failure 2026-07-27 13:32:47 +00:00
junior c4fb117b93 publish.sh: surface PR-open and git-push failures as issue comments
ci / lint (pull_request) Successful in 13s
2026-07-27 13:30:49 +00:00
ffaerber eeae1fdaaa fix: deterministic dedup of agent double-posts (self-post + framework reply) (#109)
ci / lint (push) Successful in 10s
2026-07-08 09:35:19 +02:00
ffaerber 7abbbb1b8d feat(ci): re-enable dependency caching (runner cache server fixed) (#108)
ci / lint (push) Skipped
2026-07-07 17:57:33 +02:00
ffaerber 95bc254640 revert(ci): remove cache steps until runner cache server works (#107)
ci / lint (push) Skipped
2026-07-07 17:52:25 +02:00
ffaerber 75a2493dee feat(ci): dependency caching for agent runs (opencode + Playwright) (#106)
ci / lint (push) Skipped
2026-07-07 17:16:24 +02:00
ffaerber 53ac2b7ba8 fix(agent): reactions show the agent, not "Ghost" (#105)
ci / lint (push) Skipped
2026-07-07 15:20:11 +02:00
ffaerber 4b73d0b8e9 fix(run-agent): stop agents self-posting their reply as a duplicate comment (#104)
ci / lint (push) Skipped
2026-07-07 15:09:45 +02:00
ffaerber b587a7d9a7 Merge pull request 'agents: discussion mode — devs talk in threads, build only on explicit signal' (#102) from feat/discussion-mode into main
ci / lint (push) Skipped
2026-07-07 13:34:55 +02:00
Felix FaerberandClaude Opus 4.8 ef43d69309 agents: discussion mode — devs talk in threads; building starts only on explicit signal
ci / lint (push) Skipped
ci / lint (pull_request) Successful in 11s
Mentioning a dev agent no longer spawns a branch + PR machinery. It is a
CONVERSATION by default: the dev reads what it needs and replies in-thread.

- route.sh: workmode=build|discuss for mode=pr agents. Build ONLY on the
  explicit signals: PR-thread comments (resume), the pm delegation template
  ("please proceed with issue …" — a human can write it too), or the qa bounce
  ("please address my review …"). Everything else = discuss: no branch prep,
  no "🔨 Building" notice.
- run-agent.sh: discussion ACTION for devs (read-only consult, no edits/
  commits/markers). @pm gains 'ASK: @<dev> <question>' to consult devs before
  planning — gather feasibility/effort input, then DELEGATE when enough is
  known. One ASK per reply; never ASK+DELEGATE together.
- publish.sh: pm ASK handler posts the question as a discussion trigger; dev
  discuss path posts the reply (+run report) and skips ALL git/PR machinery
  (stray edits discarded). ASK marker stripped from visible replies.
- agent.yml: WORKMODE threaded to Run-agent + Publish. README documents it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:34:12 +03:00
ffaerber 63d9147cd7 Merge pull request 'agents: review round 2 — split-PR flow, thread recency, mention boundaries, learnings cap, pm model' (#96) from fix/review-round2 into main
ci / lint (push) Successful in 10s
2026-07-06 19:12:28 +02:00
Felix FaerberandClaude Opus 4.8 e9df9d6bdd agents: review round 2 — split-PR flow, thread recency, mention boundaries, learnings cap, pm model
ci / lint (push) Skipped
ci / lint (pull_request) Successful in 11s
- publish.sh: resolve_prs matches ai/issue-N AND ai/issue-N-<slug> split branches
  (exact-only silently stalled DELEGATE:@qa and autopilot MERGE_PR on slugged
  work); qa hand-off lists all open PRs; autopilot refuses to merge multi-PR
  issues (drops the label, hands to the human) — merging one of several open
  PRs is half a change deployed. Retro's state=all resolve matches slugs too.
- fetch-thread.sh: paginate and keep the NEWEST 100 comments — a bare limit=100
  kept the OLDEST page and dropped recent decisions on long threads.
- route.sh: word-boundary mention matching ("@internal" no longer routes to
  @intern); a comment with no real agent mention now SKIPS gracefully
  (mode=skip outputs + step guards in agent.yml) instead of a red run.
- run-agent.sh: LEARNINGS.md cap is line-aware and keeps the NEWEST entries
  (retros append at the bottom; the old head -c cut the latest lessons first).
- agents.json/README: @pm gemma4 -> minimax-m3 — the retro rule demands an
  investigating PM, and gemma4 posted 0 tool calls on every run; minimax is the
  model @qa demonstrably uses tools with.
- agent.yml: document the PR-thread concurrency-group caveat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:11:45 +03:00
ffaerber 84467f6dfd Merge pull request 'agents: hard timeout on agent runs (hung model blocked the runner ~1h)' (#95) from fix/agent-run-timeout into main
ci / lint (push) Successful in 11s
2026-07-06 16:58:19 +02:00
Felix FaerberandClaude Opus 4.8 0771d394bd agents: hard timeout on agent runs — a hung model must not block the runner
ci / lint (push) Skipped
ci / lint (pull_request) Successful in 10s
A stalled local-ollama generate on a trivial @intern question held the single
runner slot for ~1h, queueing every agent run instance-wide (homelab run 869).

- run-agent.sh: each opencode attempt wrapped in `timeout -k 30 $AGENT_TIMEOUT`
  (default 1200s). rc=124 is not retried — a hung backend stays hung; fail fast
  and free the runner.
- agent.yml: job-level timeout-minutes: 45 as backstop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:41:49 +03:00
ffaerber 099a7c97be Merge pull request '@senior: model name in comment' (#93) from ai/issue-92 into main
ci / lint (push) Successful in 11s
Reviewed-on: #93
2026-07-06 13:39:40 +02:00
senior fa3005b7b5 report: include model name in the run-report details line
ci / lint (pull_request) Successful in 10s
2026-07-06 11:35:01 +00:00
ffaerber 1db36d4968 Merge pull request 'gitea-admin skill: packages / container-registry section' (#91) from docs/ops-packages into main
ci / lint (push) Successful in 11s
2026-07-06 12:11:32 +02:00
Felix FaerberandClaude Opus 4.8 ffd42eb3d3 gitea-admin skill: packages section (linking + registry auth facts)
ci / lint (push) Skipped
ci / lint (pull_request) Successful in 11s
TOKEN_OPS re-minted with write:package (was 403 on package endpoints despite ops
being site admin — token scopes gate admins too). Document package↔repo linking
and the registry auth constraints in the skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 13:10:54 +03:00
ffaerber 4e1ed9a4c5 Merge pull request 'report: static details label on comment dropdowns' (#90) from fix/static-details-label into main
ci / lint (push) Successful in 11s
2026-07-06 11:59:50 +02:00
Felix FaerberandClaude Opus 4.8 cbb938a28f report: static 'details' summary label on every comment dropdown
ci / lint (push) Skipped
ci / lint (pull_request) Successful in 11s
The dropdown summary carried dynamic numbers, so every comment's collapsed line
looked different. Now the label is literally 'details' everywhere; the headline
numbers moved to the first body line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:59:12 +03:00
ffaerber f1b0896f7e Merge pull request 'report: one uniform dropdown (tokens + tool calls) on every agent comment' (#89) from fix/uniform-run-report into main
ci / lint (push) Successful in 10s
2026-07-06 10:36:02 +02:00
Felix FaerberandClaude Opus 4.8 4a92f4686f report: one uniform dropdown for every agent comment
ci / lint (push) Skipped
ci / lint (pull_request) Successful in 10s
Comments looked different depending on the run: tool-using agents got a
collapsed <details> report while text-only replies got a bare one-line <sub>
footer. Every comment now gets the SAME dropdown — summary '🔧 N tool calls ·
in X · out Y · <cost>', body = the tool list (or '(no tool calls)') plus the
full token/cost breakdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:35:24 +03:00
ffaerber bc197f9568 Merge pull request '@senior: retro: issue #83' (#87) from ai/issue-86 into main
ci / lint (push) Successful in 11s
2026-07-06 10:16:44 +02:00
ffaerber 36c374d746 Merge pull request 'publish: qa approval detail on the PR, terse verdict on the issue' (#88) from fix/qa-verdict-terse into main
ci / lint (push) Successful in 11s
2026-07-06 10:10:50 +02:00
Felix FaerberandClaude Opus 4.8 e22a4cf98c publish: qa APPROVE detail goes to the PR — issue gets the terse verdict only
ci / lint (push) Skipped
ci / lint (pull_request) Successful in 11s
Seen on issue #83: qa's approval pasted whitespace-nit detail into the issue
thread. The issue is for the creator/orchestration; review internals belong on
the PR. On APPROVE the full reply (+run report) now posts to the PR and the
issue gets exactly ' Reviewed PR #N — looks good.' (falls back to the issue
only if no PR could be resolved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:10:12 +03:00
senior 59a60740fa retro #83: LEARNINGS.md — agent-add checklist, prompt-heredoc discipline, one-round bounce
ci / lint (pull_request) Successful in 11s
Distilled from issue #83 / PR #84 (adding @intern):
- missed agent.yml trigger gate (broke all @intern comments until round 3)
- 2 of 8 touched files missed on first pass
- stray leading-space edits to run-agent.sh prompt heredoc bounced 2 rounds
- @qa quoted the fix-attempt trigger string, inflating bounce counter 1/3 -> 3/3
- @qa split whitespace + gate findings across rounds, hitting the 3-round cap
2026-07-06 08:09:26 +00:00
ffaerber d0345617d5 Merge pull request '@junior: intern' (#84) from ai/issue-83 into main
ci / lint (push) Successful in 12s
2026-07-06 10:04:59 +02:00
ffaerber 9073dc0406 Merge pull request 'agents: retro loop — @pm retro distills learnings into LEARNINGS.md' (#85) from feat/retro-learnings into main
ci / lint (push) Successful in 11s
2026-07-06 10:03:16 +02:00
Felix FaerberandClaude Opus 4.8 b40f547740 agents: retro loop — "@pm retro" distills learnings into LEARNINGS.md
ci / lint (push) Skipped
ci / lint (pull_request) Successful in 11s
New learning step: ask @pm for a retrospective on any issue and the system
turns what happened into prompt-visible rules for future runs.

- run-agent.sh: @pm gains a RETRO marker (emit only when the maintainer asks);
  LEARNINGS.md (caller repo root, capped at 4KB) is injected into EVERY agent's
  prompt as "TEAM LEARNINGS" — the feedback loop that makes delegation more
  robust over time.
- publish.sh: on @pm's RETRO marker, open a "retro: issue #N" issue pointing at
  the issue + its PR (state=all resolve, works after merge) and trigger @senior
  on it (has gitea-api to read both threads). The retro produces a LEARNINGS.md
  PR through the NORMAL choreography (senior → pm → qa), so retros are reviewed
  like any change. Strip the RETRO marker from visible replies.
- publish.sh: bounce counter now counts only @qa-authored comments matching the
  exact trigger template — on PR #84 it jumped 1/3 → 3/3 because a qa review
  QUOTED our own "(fix attempt …)" template from the diff, halving the fix
  budget. Template + regex pinned together with a sync note.
- README: document the retro loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:02:19 +03:00
junior 6abb8874cd agent.yml: add intern to trigger gate trusted author and mention list 2026-07-06 07:56:44 +00:00
junior c95d52aabb fixup: remove stray leading spaces in run-agent.sh prompts and blank line in publish.sh 2026-07-06 07:41:31 +00:00
junior fc4217f1cb fixup: remove stray leading spaces in run-agent.sh prompts 2026-07-06 07:36:10 +00:00
junior bf1256ecec agents: add @intern routed to local ollama/ornith:35b
ci / lint (pull_request) Successful in 12s
2026-07-06 07:30:30 +00:00
ffaerber 950c639b0a Merge pull request 'publish: trigger comments speak in first person' (#81) from fix/first-person-triggers into main
ci / lint (push) Successful in 10s
2026-07-06 09:13:14 +02:00
Felix FaerberandClaude Opus 4.8 305aeb085c publish: trigger comments speak in first person (no agent self-mention)
ci / lint (push) Skipped
ci / lint (pull_request) Successful in 12s
The fixed trigger templates referred to their own author in the third person —
e.g. @qa posting 'please address @qa's review above' (seen on PR #80) or a dev
posting 'the dev has pushed an update'. Reworded to first person ('my review',
'I have pushed', 'per my plan'), which also makes routing order-independent:
each trigger now mentions ONLY its target agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:09:52 +03:00
ffaerber d491a249e9 Merge pull request 'agents: review fixes — thread attribution, concurrency, rescue stall, docs, CI, pin, cost label' (#77) from fix/review-findings into main
ci / lint (push) Successful in 10s
2026-07-06 08:48:36 +02:00
Felix FaerberandClaude Opus 4.8 9b9e1e945d agents: fix review findings — thread attribution, races, rescue stall, docs, CI
ci / lint (push) Skipped
ci / lint (pull_request) Successful in 11s
Fixes from a full repo review:

- fetch-thread.sh: attribute every 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:47:34 +03:00
ffaerber 88237d3f4d Merge pull request 'agents: report tokens + $ cost on every agent comment' (#75) from feat/comment-cost-report into main 2026-07-06 08:22:04 +02:00
Felix FaerberandClaude Opus 4.8 6334ebe8c6 agents: report tokens + $ cost on every agent comment
Each run now appends a report to the agent's reply: the tool calls it made plus
input/output token totals and the dollar cost. opencode's --format json emits
per-step `tokens` and `cost` (USD, priced from the model) on step_finish events;
build-activity-log.sh sums them across the run.

- build-activity-log.sh: compute for EVERY agent (not just devs — @pm/@qa also
  call tools and cost money); output a collapsed <details> report (summary line
  shows "N tool calls · in X · out Y · $Z"; body lists the tools + a token/cost
  breakdown). Zero-tool runs get a one-line "$Z · in X · out Y" footer.
- publish.sh: build $activity once (near the top) and append it to every agent's
  reply — @pm plan/finalize, @qa verdict/recommendations, and dev PR comments.
- agent.yml: rename the step accordingly.

Models without pricing (self-hosted ollama) report cost $0.0000.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:22:00 +03:00
ffaerber af38a44d86 Merge pull request 'chore: revert PM-flow test edit' (#74) from chore/revert-flow-test into main 2026-07-05 20:23:44 +02:00
Felix FaerberandClaude Opus 4.8 a1f7fa584b chore: revert PM-flow autopilot test edit to AGENTS.template.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:23:41 +03:00
pm c339400503 Merge pull request '@junior: test: PM flow autopilot' (#73) from ai/issue-72 into main 2026-07-05 20:21:20 +02:00
junior 479679ef4c test: add managed-by workflow comment to top of AGENTS.template.md (#72) 2026-07-05 18:19:43 +00:00
ffaerber 5ddad786f1 Merge pull request 'agents: PM-orchestrated flow (@pm hub, @qa reviews, @pm/human merges)' (#69) from feat/pm-orchestrated-flow into main 2026-07-05 20:12:19 +02:00
Felix FaerberandClaude Opus 4.8 84cec444ef agents: PM-orchestrated flow — @pm hub, @qa reviews, human/@pm merges
Rework the delegation/trigger choreography so @pm orchestrates end-to-end from
the issue thread while @qa reviews on the PR and never merges.

Flow: issue → @pm plans (asks creator; autopilot skips) → dev builds PR → dev
pings @pm on the issue → @pm hands to @qa → @qa reviews ON THE PR (recommends /
BOUNCE:@dev, direct qa↔dev loop, 3 max) → @qa APPROVEs → verdict on the ISSUE →
@pm → normal: tells creator "ready to merge" (human merges); autopilot: @pm
MERGE_PR. @pm never reads or comments on the PR (small context); @qa never merges.

- run-agent.sh: @pm prompt = 3 phases (plan / hand PR to @qa / finalize),
  autopilot only drops the approval gate + adds @pm MERGE_PR. @qa prompt =
  reviewer, ends with APPROVE / BOUNCE:@dev / HALT; identical in both flows.
- publish.sh: shared post_to()/trig()/resolve_pr() helpers + ISSN; @qa routes
  reply to PR (bounce) vs issue (approve) and fires @pm/@dev; @pm handles
  DELEGATE:@dev, DELEGATE:@qa (resolves the PR), and autopilot MERGE_PR; dev
  NEW=true fires @pm on the issue, NEW=false fires @qa on the PR. Strip
  APPROVE/HALT markers.

Tokens re-minted to match: TOKEN_PM gains write:repository (it merges now),
TOKEN_QA drops to read:repository (never merges).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:06:39 +03:00
ffaerber 01497e9ebe Merge pull request 'agents: fix ${SECRETS_JSON:-{}} brace bug (caller skills silently skipped)' (#68) from fix/caller-skills-brace-bug into main 2026-07-05 17:32:24 +02:00
Felix FaerberandClaude Opus 4.8 e0ef954454 agents: fix ${SECRETS_JSON:-{}} brace bug that silently skipped caller skills
`${VAR:-{}}` appends a stray '}' when VAR is set (bash brace-matching), so the
JSON handed to a caller skill's setup.sh was corrupted and its jq failed with
"Unmatched '}'" — install-caller-skills.sh then caught the non-zero exit and
skipped the skill. Default SECRETS_JSON in two safe steps and pass it as a plain
var. This is why node1-ssh never installed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:32:21 +03:00
ffaerber baccb09df1 Merge pull request 'agents: document single-line requirement for caller-skill secrets' (#67) from docs/caller-skills-singleline-secrets into main 2026-07-05 17:11:55 +02:00
Felix FaerberandClaude Opus 4.8 ed17613d0d agents: document single-line requirement for caller-skill secrets
toJSON(secrets) leaks multiline secret values (their newlines are escaped, so the
runner's log mask misses them). Note in install-caller-skills.sh that caller-skill
secrets exposed via SECRETS_JSON must be single-line (base64-encode keys/multiline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:11:14 +03:00
ffaerber 62c86e77b0 Merge pull request 'agents: drop redundant agent-name headers from comments (+ fix self-trigger loop)' (#66) from fix/comment-headers into main 2026-07-05 17:02:34 +02:00
Felix FaerberandClaude Opus 4.8 4c05abac63 agents: drop redundant agent-name headers from comments (+ fix self-trigger loop)
Gitea already attributes every comment/PR to its author, so the "🤖 **@name**" /
"🔨 **@name**" header at the top of agent comments was redundant noise. Remove it
everywhere and, in the process, close a self-trigger loop.

- publish.sh: post()/prpost() now append a hidden `<!-- 🤖 … -->` marker instead
  of each message carrying a visible "🤖 **@name**" header. The gate keys on the
  '🤖' char to skip agent replies, so the marker preserves loop-prevention while
  being invisible. All reply/status/autopilot strings drop the name header.
- route.sh: the "building on branch" notice is posted with the agent's PAT and
  previously had NO 🤖 marker + an "@name" mention, so it re-triggered the agent
  (the observed loop on issue #139). Reword without the self-name and add the
  hidden marker so it can't fire a new run.
- publish.sh: broaden the leading self-header stripper to drop any leading line
  referencing the agent's own @handle (e.g. "## 🔨 @senior — …"), not just bare
  "**@name**" lines. Legit headings like "## Summary" are preserved.
- run-agent.sh: tell the model not to begin its reply with its own name/handle.
- Trigger comments (delegation / autopilot / bounce) stay marker-free by design,
  so they still fire the next agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:00:58 +03:00
ffaerber 269e932b19 Merge pull request 'agents: caller-provided skills hook (move node1-ssh to the owning repo)' (#65) from feat/caller-provided-skills into main 2026-07-05 16:37:12 +02:00
Felix FaerberandClaude Opus 4.8 0f8893330f agents: caller-provided skills hook — repos can ship their own opencode skills
A consuming repo can now add repo-specific skills under .gitea/agent-skills/<name>/
(SKILL.md + skill.json + optional setup.sh); the framework installs the ones
allowed for the running agent. This keeps deploy-target / infra specifics in the
repo they belong to instead of hardcoded in the shared workflow.

- install-caller-skills.sh: scans the caller workspace, installs each skill whose
  skill.json `agents` list includes the running agent, runs its optional setup.sh
  with all inherited secrets available as $SECRETS_JSON (toJSON(secrets) — so a
  caller's setup can read repo-specific secret names the framework can't know),
  and merges the allowed skills into the permission.skill allow-list.
- agent.yml: replace the hardcoded node1-ssh step with the generic caller-skills
  step (passes NAME, WORKSPACE, SECRETS_JSON).
- Remove the built-in node1-ssh skill: delete skill-node1-ssh.sh, drop "node1-ssh"
  from agents.json (senior/lead). The homelab repo now owns that skill.
- run-agent.sh: note caller-provided skills aren't in the roster (route them via
  the caller's AGENTS.md).

toJSON(secrets) verified supported on this Gitea (1.27) via an isolated probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:35:45 +03:00
ffaerber 63dbd2727f Merge pull request 'agents: remove shared AGENT_TOKEN — per-agent tokens only' (#63) from chore/remove-agent-token into main 2026-07-05 15:51:37 +02:00
Felix FaerberandClaude Opus 4.8 3c66220f6d agents: remove shared AGENT_TOKEN fallback — per-agent tokens only
All three consuming repos (gitea/agents, gitea/ops, ffaerber/homelab) now
carry the per-agent TOKEN_* secrets (org-level for gitea/*, user-level for
ffaerber/*), so the shared AGENT_TOKEN fallback is no longer needed.

- agent.yml: SELF_TOKEN ternary ends in '' instead of secrets.AGENT_TOKEN;
  removed AGENT_TOKEN from the publish + rescue step env.
- publish.sh: TTOK is just the agent's own token; if an agent somehow has none
  (TOK fell back to the built-in GT), TTOK is left empty so the trigger/merge
  is skipped rather than silently no-op'ing under the built-in token.
- README: drop the AGENT_TOKEN row; state that every consuming repo must carry
  the per-agent TOKEN_* (no shared fallback).

The AGENT_TOKEN Actions secrets (org + user) and its gitea/secrets/.env entry
are removed separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:51:06 +03:00
ffaerber c792228e2c Merge pull request 'docs: README reflects per-agent SELF_TOKEN model' (#62) from docs/token-model-selftoken into main 2026-07-05 15:40:29 +02:00
Felix FaerberandClaude Opus 4.8 1c4e4ce950 docs: README reflects per-agent SELF_TOKEN model
The secret table still described AGENT_TOKEN as primary and TOKEN_* as
optional "falls back to the bot". The per-agent-token refactor inverted that:
each agent's own TOKEN_* is primary (selected into SELF_TOKEN), AGENT_TOKEN is
now only the fallback for repos without per-agent tokens. Document TOKEN_OPS,
the SELF_TOKEN selection, and that TOKEN_QA needs write:repository to merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:40:01 +03:00
ffaerber 738848304e Merge pull request 'agents: per-agent Gitea identity — each agent uses its own token' (#59) from feat/per-agent-tokens into main 2026-07-05 15:33:54 +02:00
Felix FaerberandClaude Opus 4.8 06f1924441 agents: per-agent Gitea identity — each agent uses its own token
Drop the shared AGENT_TOKEN as the primary credential; every agent now acts
as its own Gitea user (TOKEN_PM for @pm, TOKEN_OPS for @ops, …) for API
calls, delegation/autopilot trigger comments, and PR merges.

- agent.yml: Run-agent step injects SELF_TOKEN — a ternary selecting the
  running agent's own token by name, falling back to AGENT_TOKEN for repos
  not yet migrated to per-agent tokens (e.g. homelab). Only that one token
  enters the agent process, so no agent can act as another. The gitea-api /
  gitea-admin skill-setup steps no longer carry a token (they only write docs).
- Gate: trust the agent roster (pm/junior/senior/lead/qa/ops) as comment
  authors so an agent's own delegation/autopilot trigger comment (posted with
  its PAT, no 🤖 prefix) fires the next run. @ops added to the mention set.
- publish.sh: TOK = agent identity (comments/replies); new TTOK = trigger/merge
  token (agent PAT, else AGENT_TOKEN fallback) for delegation, autopilot @qa
  triggers, and PR merges that must fire downstream workflows.
- skill-gitea-api.sh / skill-gitea-admin.sh / run-agent.sh: AGENT_TOKEN/
  TOKEN_OPS → SELF_TOKEN in the emitted skill docs and env contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:20:29 +03:00
ffaerber e53e5caf8c Merge pull request 'feat(ops): @ops uses dedicated TOKEN_OPS not AGENT_TOKEN' (#58) from feat/ops-dedicated-token into main 2026-07-05 14:52:09 +02:00
17 changed files with 1013 additions and 434 deletions
+142 -57
View File
@@ -4,34 +4,76 @@ 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:
agent: 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.
# 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:
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 # 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.comment == null && (github.event.issue.user.login == 'ffaerber' || github.event.issue.user.login == 'hermes')) ||
(github.event.comment != null && github.event.comment.user.login == 'ffaerber' && (github.event.comment != null &&
(github.event.comment.user.login == 'ffaerber' ||
github.event.comment.user.login == 'hermes' ||
github.event.comment.user.login == 'pm' ||
github.event.comment.user.login == 'junior' ||
github.event.comment.user.login == 'senior' ||
github.event.comment.user.login == 'lead' ||
github.event.comment.user.login == 'qa' ||
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, '@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
@@ -93,58 +135,90 @@ 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: Set up read-only SSH alias `node1` (+ opencode skill so the agent actually knows about it) - name: Install caller-provided skills (from the caller repo's .gitea/agent-skills/)
# 1) Writes the deploy key + an SSH config alias so the agent can run if: steps.prep.outputs.mode != 'skip'
# `ssh node1 <read-only cmd>` (matches the homelab opencode.json allowlist). # Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under
# 2) Emits a `node1-ssh` opencode Skill file under ~/.config/opencode/skills/ so any # `.gitea/agent-skills/<name>/` (SKILL.md + skill.json + optional setup.sh) — e.g. homelab's
# downstream repo's dev agent discovers this capability via OpenCode's skill registry # "ssh into the deploy host" skill. This installs the ones allowed for the running agent, so
# rather than having to trial against the permission allowlist. Only emitted when the # deploy-target / infra specifics live in the repo they belong to, not in this framework.
# swarm plumbing is actually wired for that caller (SWARM_HOST/SWARM_USER/SSH_PRIV_KEY). # SECRETS_JSON = toJSON(secrets): a caller's setup.sh reads the repo-specific secrets it needs
# All three secrets are passed via env and never inlined into shell — this shared workflow # (whose names this framework can't know) via jq; it never touches disk here in the clear.
# runs in repos that don't have them and must not fail there.
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
SWARM_HOST: ${{ secrets.SWARM_HOST }} NAME: ${{ steps.prep.outputs.name }}
SWARM_USER: ${{ secrets.SWARM_USER }} WORKSPACE: ${{ github.workspace }}
SSH_PRIV_KEY: ${{ secrets.SSH_PRIV_KEY }} SECRETS_JSON: ${{ toJSON(secrets) }}
run: bash "$SCRIPTS/skill-node1-ssh.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)
# Mirrors the node1-ssh pattern: emit an opencode Skill file under if: steps.prep.outputs.mode != 'skip'
# ~/.config/opencode/skills/ so any dev agent discovers the capability via OpenCode's # Emits an opencode Skill file. The skill uses SELF_TOKEN — the running agent's OWN token
# skill registry. The credential is the shared AGENT_TOKEN (a PAT whose scopes the # (e.g. TOKEN_PM for @pm), injected into the Run-agent step below — so each agent talks to
# maintainer set at creation time — issue/repository/organization/misc read+write, cross-repo). # Gitea as itself. This step only writes the doc; permission.skill scopes who may load it.
# Only emitted when AGENT_TOKEN is actually present, so repos without it don't get a
# broken skill. The token is passed via env and never inlined into shell.
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
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), so the admin how-to never # written ONLY for @ops (skill-gitea-admin.sh gates on NAME) and permission.skill also denies
# reaches other agents; permission.skill also denies it to everyone but @ops. Uses the # it to every other agent. It uses SELF_TOKEN (which for @ops is TOKEN_OPS), injected into the
# dedicated admin token TOKEN_OPS (the `ops` user), NOT AGENT_TOKEN — this step only writes # Run-agent step. This step only writes the doc.
# the doc; the token itself is injected into the Run-agent step only when NAME==ops.
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
NAME: ${{ steps.prep.outputs.name }} NAME: ${{ steps.prep.outputs.name }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
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
@@ -154,6 +228,7 @@ jobs:
run: bash "$SCRIPTS/fetch-images.sh" run: bash "$SCRIPTS/fetch-images.sh"
- name: Fetch the full issue thread (shared memory) - name: Fetch the full issue thread (shared memory)
if: steps.prep.outputs.mode != 'skip'
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }} GT: ${{ secrets.GITEA_TOKEN }}
@@ -161,21 +236,23 @@ jobs:
run: bash "$SCRIPTS/fetch-thread.sh" run: bash "$SCRIPTS/fetch-thread.sh"
- name: Run agent - name: Run agent
if: steps.prep.outputs.mode != 'skip'
id: run id: run
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY || secrets.XAI_KEY || secrets.GROK_API_KEY || secrets.XAI_TOKEN }}
# AGENT_TOKEN powers the `gitea-api` skill (cross-repo issue/PR/Actions read+write). # SELF_TOKEN = the RUNNING agent's OWN token (TOKEN_PM for @pm, TOKEN_OPS for @ops, …).
# It is already a required secret for the delegation step below; exposing it here too # Only this agent's token is placed in its process env, so no agent can act as another.
# lets the agent process itself call the Gitea API on demand. # Powers the gitea-api / gitea-admin skills — each agent calls Gitea as itself. Every
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }} # consuming repo now carries the per-agent TOKEN_* secrets (org-level for gitea/*, user-level
# The dedicated admin token for the `gitea-admin` skill — injected into the agent's process # for ffaerber/*), so there is no shared-token fallback.
# ONLY when it is @ops, so no other agent ever holds an admin credential in its 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 || '' }}
TOKEN_OPS: ${{ 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
@@ -186,26 +263,30 @@ jobs:
FILES: ${{ steps.imgs.outputs.files }} # opencode -f image flags (vision agents only) FILES: ${{ steps.imgs.outputs.files }} # opencode -f image flags (vision agents only)
run: bash "$SCRIPTS/run-agent.sh" run: bash "$SCRIPTS/run-agent.sh"
- name: Build activity log (tool calls + reasoning) 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
MODE: ${{ steps.prep.outputs.mode }} 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" 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 }}
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
TOKEN_PM: ${{ secrets.TOKEN_PM }} TOKEN_PM: ${{ secrets.TOKEN_PM }}
TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }} TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }}
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }} TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
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 }}
@@ -220,7 +301,7 @@ jobs:
# This best-effort step opens a PR for the pushed branch so nothing is silently lost. Runs from # This best-effort step opens a PR for the pushed branch so nothing is silently lost. Runs from
# $SCRIPTS (outside the workspace) so it works even if the tree was mangled by the agent. # $SCRIPTS (outside the workspace) so it works even if the tree was mangled by the agent.
- name: Rescue — open a PR for pushed work if the run failed - name: Rescue — open a PR for pushed work if the run failed
if: failure() if: failure() && steps.prep.outputs.mode != 'skip'
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }} GT: ${{ secrets.GITEA_TOKEN }}
@@ -230,6 +311,7 @@ jobs:
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }} TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
TOKEN_QA: ${{ secrets.TOKEN_QA }} TOKEN_QA: ${{ secrets.TOKEN_QA }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }} TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }}
NAME: ${{ steps.prep.outputs.name }} NAME: ${{ steps.prep.outputs.name }}
MODE: ${{ steps.prep.outputs.mode }} MODE: ${{ steps.prep.outputs.mode }}
NUM: ${{ github.event.issue.number }} NUM: ${{ github.event.issue.number }}
@@ -238,24 +320,27 @@ jobs:
run: bash "$SCRIPTS/rescue-pr.sh" || true run: bash "$SCRIPTS/rescue-pr.sh" || true
- name: Mark done with 🚀 (remove 👀) - name: Mark done with 🚀 (remove 👀)
if: steps.prep.outputs.mode != 'skip'
env: env:
GT: ${{ secrets.GITEA_TOKEN }} SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
CID: ${{ github.event.comment.id }} CID: ${{ github.event.comment.id }}
NUM: ${{ github.event.issue.number }} NUM: ${{ github.event.issue.number }}
run: | run: |
[ -n "$SELF_TOKEN" ] || exit 0
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues" B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true curl -sS -X DELETE -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"rocket"}' -w '\nreact -> HTTP %{http_code}\n' || true curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"rocket"}' -w '\nreact -> HTTP %{http_code}\n' || true
- name: Mark failed with 😕 (remove 👀) - name: Mark failed with 😕 (remove 👀)
if: failure() if: failure() && steps.prep.outputs.mode != 'skip'
env: env:
GT: ${{ secrets.GITEA_TOKEN }} SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
CID: ${{ github.event.comment.id }} CID: ${{ github.event.comment.id }}
NUM: ${{ github.event.issue.number }} NUM: ${{ github.event.issue.number }}
run: | run: |
[ -n "$SELF_TOKEN" ] || exit 0
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues" B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true curl -sS -X DELETE -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true
+14 -1
View File
@@ -12,4 +12,17 @@ on:
jobs: jobs:
agent: agent:
uses: gitea/agents/.gitea/workflows/agent.yml@main uses: gitea/agents/.gitea/workflows/agent.yml@main
secrets: inherit # Explicit secret map (plus inherit) so XAI_API_KEY / OLLAMA_* always reach the reusable
# 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 }}
+56
View File
@@ -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"
+64 -6
View File
@@ -1,8 +1,66 @@
{ {
"pm": {"model":"ollama-cloud/gemma4:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"Product manager — research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files."}, "pm": {
"junior": {"model":"ollama-cloud/kimi-k2.7-code:cloud","vision":false,"mode":"pr", "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."}, "model": "ollama-cloud/minimax-m3:cloud",
"senior": {"model":"ollama-cloud/glm-5.2:cloud","vision":false,"mode":"pr", "skills":["gitea-api","node1-ssh"],"desc":"Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."}, "fallback": "xai-oc/grok-4.5",
"lead": {"model":"anthropic/claude-opus-4-8","vision":true, "mode":"pr", "skills":["gitea-api","node1-ssh"],"desc":"Tech lead — the hardest problems, architecture, and final calls."}, "vision": true,
"qa": {"model":"ollama-cloud/minimax-m3:cloud","vision":true, "mode":"comment","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."}, "mode": "comment",
"ops": {"model":"anthropic/claude-opus-4-8","vision":false,"mode":"comment","skills":["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)."} "skills": [
"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."
},
"junior": {
"model": "ollama-cloud/kimi-k2.7-code:cloud",
"fallback": "xai-oc/grok-4.3",
"vision": false,
"mode": "pr",
"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."
},
"senior": {
"model": "ollama-cloud/glm-5.2:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": false,
"mode": "pr",
"skills": [
"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."
},
"lead": {
"model": "xai-oc/grok-4.5",
"vision": true,
"mode": "pr",
"skills": [
"gitea-api"
],
"desc": "Tech lead — the hardest problems, architecture, and final calls."
},
"qa": {
"model": "ollama-cloud/minimax-m3:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": true,
"mode": "comment",
"skills": [
"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."
},
"ops": {
"model": "xai-oc/grok-4.5",
"vision": false,
"mode": "comment",
"skills": [
"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)."
},
"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."
}
} }
+47 -14
View File
@@ -1,24 +1,57 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build the activity log — the list of TOOL CALLS the agent made — into /tmp/activity_log.md. # Build the RUN REPORT appended to the agent's reply comment: the TOOL CALLS the agent made plus a
# Only dev agents (mode=pr) get an activity-log comment — comment-only roles (pm/qa) do no tool calls. # usage line (input / output tokens + $ cost). Written to /tmp/activity_log.md; the Publish step
# NOTE: we deliberately DO NOT include the agent's prose text parts. That final "here's what I did" # appends it to the agent's reply. Applies to EVERY agent — @pm/@qa also call tools and cost money.
# text is just a restatement of the PR description (already published as the PR body), not a tool
# call — so it was noise in a section titled "tool calls". The log is the record of ACTIONS taken.
# #
# Required env (provided by the workflow step): MODE # opencode --format json emits one JSON event per line. `step_finish` events carry, per LLM step,
# .part.tokens {input, output, reasoning, cache:{read, write}} and .part.cost (USD, already computed
# by opencode from the model's pricing). We sum them across all steps of the run. Models without
# pricing (e.g. self-hosted ollama) report cost 0 — shown as $0.0000.
#
# Required env (provided by the workflow step): (none needed; reads /tmp/events.jsonl)
set -u set -u
E=/tmp/events.jsonl
: > /tmp/activity_log.md
[ -s "$E" ] || { echo "no events — empty report"; exit 0; }
if [ "$MODE" != "pr" ]; then # Tool calls = the ACTIONS taken (not the agent's prose text parts).
echo "skipping activity log for comment-mode agent"; : > /tmp/activity_log.md; exit 0
fi
jq -r ' jq -r '
def trunc(n): if length > n then (.[0:n] + "…") else . end; def trunc(n): if length > n then (.[0:n] + "…") else . end;
select(.type=="tool_use") | select(.type=="tool_use") |
(.part.tool // "?") as $t | (.part.tool // "?") as $t |
((.part.state.title // (.part.state.input | tojson | trunc(160)) // "")) as $title | ((.part.state.title // (.part.state.input | tojson | trunc(160)) // "")) as $title |
"🔧 **" + $t + "**: `" + ($title | trunc(240)) + "`" "🔧 **" + $t + "**: `" + ($title | trunc(240)) + "`"
' /tmp/events.jsonl > /tmp/activity_log.md 2>/dev/null || true ' "$E" > /tmp/tools.md 2>/dev/null || true
n=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0) n=$(wc -l < /tmp/tools.md 2>/dev/null || echo 0); n=${n:-0}
echo "activity log: $n tool calls"
[ "$n" -eq 0 ] && : > /tmp/activity_log.md # Usage: sum per-step tokens + cost across every step_finish event (tab-separated for `read`).
head -3 /tmp/activity_log.md read -r COST INP OUT CR CW RE < <(jq -rs '
[ .[] | select(.type=="step_finish") | .part ] as $s
| [ ([$s[].cost // 0]|add // 0),
([$s[].tokens.input // 0]|add // 0),
([$s[].tokens.output // 0]|add // 0),
([$s[].tokens.cache.read // 0]|add // 0),
([$s[].tokens.cache.write // 0]|add // 0),
([$s[].tokens.reasoning // 0]|add // 0) ]
| @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
# 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 (xai-oc/…) 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"
# 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'
printf '🔧 %s tool calls · in %s · out %s tokens · %s · model %s\n\n' "$n" "$IN_TOTAL" "$OUT" "$COSTF" "${MODEL:-?}"
if [ "$n" -gt 0 ]; then cat /tmp/tools.md; else printf '_(no tool calls — text-only reply)_\n'; fi
printf '\n\n<sub>tokens — input %s (fresh %s · cache %sw / %sr) · output %s · reasoning %s · **%s**</sub>\n</details>' \
"$IN_TOTAL" "$INP" "$CW" "$CR" "$OUT" "$RE" "$COSTF"
} > /tmp/activity_log.md
+27 -8
View File
@@ -1,15 +1,34 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Fetch the full issue thread (shared memory) into /tmp/thread.md. # 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.
#
# PAGINATION: Gitea returns comments ASCENDING and `limit` caps a single page — a bare ?limit=100
# used to keep the OLDEST 100 comments and silently drop the newest (the exact opposite of what an
# agent needs on a long thread). Fetch all pages (up to 10 = 500 comments) and keep the LAST 100.
# #
# Required env (provided by the workflow step): GT NUM GITHUB_SERVER_URL GITHUB_REPOSITORY # Required env (provided by the workflow step): GT NUM GITHUB_SERVER_URL GITHUB_REPOSITORY
set -eu set -eu
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=100" 2>/dev/null \ : > /tmp/thread_pages.json
| jq -r '.[] | for page in $(seq 1 10); do
( if (.body | test("delegated by")) then "an automated delegation" pg=$(curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=50&page=$page" 2>/dev/null) || pg='[]'
elif (.user.login == "ffaerber") then "ffaerber (the maintainer / you)" n=$(printf '%s' "$pg" | jq 'if type=="array" then length else 0 end' 2>/dev/null || echo 0)
else "an AI teammate — the specific one is named in the 🤖 @name line at the top of the comment" [ "${n:-0}" -gt 0 ] && printf '%s\n' "$pg" >> /tmp/thread_pages.json
end ) as $who | [ "${n:-0}" -lt 50 ] && break
"### comment by \($who):\n\(.body)\n"' > /tmp/thread.md 2>/dev/null || true done
echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0)" jq -rs '
add // [] | .[-100:] | .[] |
( if (.user.login == "ffaerber") then "@ffaerber (the maintainer)"
else "@" + .user.login end ) as $who |
"### comment by \($who):\n\(.body | gsub("\\s*<!-- 🤖 agent reply — do not trigger -->"; ""))\n"' \
/tmp/thread_pages.json > /tmp/thread.md 2>/dev/null || : > /tmp/thread.md
echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0) (newest 100 kept)"
# Record the newest comment id on the thread BEFORE the agent runs. publish.sh compares against
# it to detect an agent that self-posted its reply mid-run (via the gitea-api skill, despite the
# prompt telling it not to) and skips the duplicate framework reply. Ids are monotonic — no dates.
jq -rs '[ (add // [])[].id ] | max // 0' /tmp/thread_pages.json > /tmp/thread_max_cid 2>/dev/null || echo 0 > /tmp/thread_max_cid
echo "pre-run newest comment id: $(cat /tmp/thread_max_cid)"
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Install CALLER-PROVIDED opencode skills — the framework's skill-plugin hook.
#
# The reusable workflow ships a few built-in skills (gitea-api, gitea-admin). A consuming repo can
# add its OWN, repo-specific skills (e.g. a homelab "ssh into the deploy host" skill) without any
# change to this framework: it commits them under `.gitea/agent-skills/<name>/` in its own repo.
# This step discovers them in the checked-out caller workspace and installs the ones allowed for the
# running agent. That keeps deploy-target / infra specifics in the repo they belong to, not here.
#
# Layout the framework expects, per skill, in the CALLER repo:
# .gitea/agent-skills/<name>/
# SKILL.md (required) — the opencode Skill doc; copied verbatim into the skill registry.
# skill.json (required) — {"agents":["senior","lead"]} — which agents may load this skill.
# setup.sh (optional) — runtime setup (e.g. write an SSH alias). Runs ONLY when this agent is
# allowed the skill. Receives $SECRETS_JSON (all inherited secrets, as JSON) and must
# extract what it needs via jq; it must no-op cleanly if its secrets aren't set.
#
# Required env (provided by the workflow step): NAME WORKSPACE SECRETS_JSON
# (SECRETS_JSON = toJSON(secrets); passed so a caller's setup.sh can read repo-specific secrets
# whose names this framework cannot know in advance.)
#
# IMPORTANT — caller-skill secrets read from SECRETS_JSON MUST be single-line. The runner masks a
# secret's value in logs by exact match, but toJSON(secrets) escapes newlines to '\n', so a MULTILINE
# secret (e.g. a raw PEM key) no longer matches the mask and would print in cleartext in the step's
# "expression evaluated to …" log line. Store multiline values base64-encoded (single-line) and
# decode them inside setup.sh. Single-line values mask correctly.
set -eu
# Safe default for SECRETS_JSON (see note at the setup.sh call below re: the ${x:-{}} brace bug).
SJ="${SECRETS_JSON:-}"; [ -n "$SJ" ] || SJ='{}'
DIR="${WORKSPACE:-$GITHUB_WORKSPACE}/.gitea/agent-skills"
CFG="$HOME/.config/opencode/opencode.json"
[ -d "$DIR" ] || { echo "no caller skills (.gitea/agent-skills/ absent) — nothing to install"; exit 0; }
allow='{}' # skills to flip to "allow" in permission.skill for THIS agent
for skill_dir in "$DIR"/*/; do
[ -d "$skill_dir" ] || continue
name=$(basename "$skill_dir")
md="$skill_dir/SKILL.md"; meta="$skill_dir/skill.json"
if [ ! -f "$md" ] || [ ! -f "$meta" ]; then
echo "caller skill '$name': missing SKILL.md or skill.json — skipping"; continue
fi
# Is this agent allowed the skill?
if ! jq -e --arg n "$NAME" '(.agents // []) | index($n)' "$meta" >/dev/null 2>&1; then
echo "caller skill '$name': not allowed for @$NAME — skipping"; continue
fi
# Install the doc.
dest="$HOME/.config/opencode/skills/$name"
mkdir -p "$dest" && chmod 700 "$dest"
cp "$md" "$dest/SKILL.md"
chmod -R o=rX "$dest"
# Optional runtime setup, with all inherited secrets available as JSON (never printed here).
# NOTE: pass SECRETS_JSON via a plain variable — do NOT inline ${SECRETS_JSON:-{}} here or in
# setup.sh: bash brace-matching appends a stray '}' when the var is set, corrupting the JSON so
# the skill's `jq` fails ("Unmatched '}'") and the skill is silently skipped.
if [ -f "$skill_dir/setup.sh" ]; then
echo "caller skill '$name': running setup.sh for @$NAME"
SECRETS_JSON="$SJ" NAME="$NAME" WORKSPACE="${WORKSPACE:-$GITHUB_WORKSPACE}" \
bash "$skill_dir/setup.sh" || { echo "caller skill '$name': setup.sh failed — skipping this skill"; continue; }
fi
allow=$(jq -nc --argjson a "$allow" --arg n "$name" '$a + {($n):"allow"}')
echo "caller skill '$name': installed + allowed for @$NAME"
done
# Merge the allowed caller skills into the permission allow-list opencode already wrote.
if [ "$allow" != '{}' ] && [ -f "$CFG" ]; then
tmp=$(mktemp)
jq --argjson add "$allow" '.permission.skill = ((.permission.skill // {}) + $add)' "$CFG" > "$tmp" && mv "$tmp" "$CFG"
echo "permission.skill updated with caller skills: $(jq -c '.permission.skill' "$CFG")"
fi
+46 -12
View File
@@ -1,11 +1,30 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Install opencode + provider config (+ Playwright MCP for browser agents). # Install opencode + provider config (+ Playwright MCP for browser agents).
# #
# Required env (provided by the workflow step): OLLAMA_URL OLLAMA_CLOUD_API_KEY NAME SKILLS # Required env (provided by the workflow step): OLLAMA_URL OLLAMA_CLOUD_API_KEY XAI_API_KEY
# GITHUB_PATH HOME # NAME SKILLS GITHUB_PATH HOME
set -eu set -eu
curl -fsSL https://opencode.ai/install | bash # 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
# --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}"
# Skip the download when a cache hit already restored the pinned binary (see the Cache
# opencode CLI step in agent.yml). The installer always re-fetches otherwise.
OC_BIN="$HOME/.opencode/bin/opencode"
if [ -x "$OC_BIN" ] && "$OC_BIN" --version 2>/dev/null | grep -qF "$OPENCODE_VERSION"; then
echo "opencode $OPENCODE_VERSION already present (cache hit) — skipping install"
else
curl -fsSL https://opencode.ai/install | bash -s -- --version "$OPENCODE_VERSION"
fi
echo "$HOME/.opencode/bin" >> "$GITHUB_PATH" echo "$HOME/.opencode/bin" >> "$GITHUB_PATH"
mkdir -p ~/.config/opencode mkdir -p ~/.config/opencode
# Playwright browser MCP only for agents that need to drive a web app # Playwright browser MCP only for agents that need to drive a web app
@@ -27,17 +46,32 @@ esac
SKILLS="${SKILLS:-[]}" SKILLS="${SKILLS:-[]}"
PERM=$(jq -nc --argjson s "$SKILLS" ' PERM=$(jq -nc --argjson s "$SKILLS" '
{skill: ( {"*":"deny"} + (reduce $s[] as $k ({}; . + {($k):"allow"})) )}') {skill: ( {"*":"deny"} + (reduce $s[] as $k ({}; . + {($k):"allow"})) )}')
# Two ollama providers: local self-hosted (ornith) + Ollama Cloud (gemma4/kimi-k2.7-code/glm-5.2/minimax-m3). # Three OpenAI-compatible providers: local self-hosted ollama (ornith) + Ollama Cloud
# The ollama-cloud `models:` map is DERIVED from agents.json (the single source of truth, shared with # (gemma4/kimi-k2.7-code/glm-5.2/minimax-m3) + xAI (grok-4.5). The provider `models:` maps are
# route.sh) so every model an agent is routed to is always declared in the provider config. Only the # DERIVED from agents.json (the single source of truth, shared with route.sh) so every model an
# `ollama-cloud/` provider prefix models participate — e.g. `anthropic/claude-opus-4-8` (@lead) is a # agent is routed to is always declared in the provider config. `ollama-cloud/` prefix models go to
# built-in provider and `ornith:35b` is local-only, neither belongs here. See issue #31. # the cloud provider; `ollama/` prefix models go to the local provider; `xai-oc/` prefix models go
# 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"
CLOUD_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama-cloud/")) | sub("^ollama-cloud/";"")] | map({(.):{}}) | add // {}' "$AGENTS_JSON") # Include primary `.model` AND optional `.fallback` so failover models are always registered
jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --argjson cloud "$CLOUD_MODELS" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" '{ # in opencode provider maps (issue: Ollama Cloud quota → xAI).
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:{"ornith:35b":{}}}, ollama: {npm:"@ai-sdk/openai-compatible", options:{baseURL:($url+"/v1")}, models:$local},
"ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:$cloud} "ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:$cloud},
"xai-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
+231 -126
View File
@@ -2,22 +2,56 @@
# Publish — PR (dev agents) or comment (pm/qa), always reply in the issue. # Publish — PR (dev agents) or comment (pm/qa), always reply in the issue.
# #
# Required env (provided by the workflow step): # Required env (provided by the workflow step):
# GT AGENT_TOKEN TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA # GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA
# NAME MODE NUM TITLE BRANCH NEW GITHUB_SERVER_URL GITHUB_REPOSITORY # NAME MODE NUM TITLE BRANCH NEW GITHUB_SERVER_URL GITHUB_REPOSITORY
# IS_PR AUTOPILOT ISSNUM (autopilot: @qa label-gated merge/halt + auto-trigger @qa on a fresh PR) # IS_PR AUTOPILOT ISSNUM (autopilot: @qa label-gated merge/halt + auto-trigger @qa on a fresh PR)
set +e # publish is best-effort: a grep-no-match / curl non-zero must NOT kill the step set +e # publish is best-effort: a grep-no-match / curl non-zero must NOT kill the step
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot. # Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
case "$NAME" in case "$NAME" in
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";; pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";; lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; intern) TOK="$TOKEN_INTERN";; *) TOK="";;
esac esac
[ -z "$TOK" ] && TOK="$GT" [ -z "$TOK" ] && TOK="$GT"
# Trigger token: comments that must FIRE the next workflow (delegation, autopilot) and PR merges
# cannot use the built-in GITEA_TOKEN (Gitea won't start new runs from it) — they need a real PAT.
# Every agent now has its own token, so TTOK is just the agent's token. If an agent somehow has none
# (TOK fell back to the built-in GT), TTOK is left empty so the trigger/merge is skipped rather than
# silently no-op'ing under the built-in token.
TTOK="$TOK"
[ "$TTOK" = "$GT" ] && TTOK=""
git config user.name "$NAME" git config user.name "$NAME"
git config user.email "$NAME@ffaerber.duckdns.org" 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")
# Hidden loop-prevention marker appended to every agent REPLY/STATUS comment. Gitea already shows
# who authored a comment, so we don't repeat the agent's name in the body; but the trigger gate keys
# on the '🤖' character to know "this is an agent's own comment, don't fire a new run". An HTML
# comment renders as nothing, so the marker is invisible while still tripping the gate's guard.
# NOTE: trigger comments (delegation / autopilot / bounce) are posted with inline curl, NOT post()/
# prpost(), so they never get this marker and therefore DO fire the next run — that is intended.
MARK=$'\n\n<!-- 🤖 agent reply — do not trigger -->'
post() { curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \ post() { curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \
"$API/issues/$NUM/comments" -d "$(jq -nc --arg b "$1" '{body:$b}')"; } "$API/issues/$NUM/comments" -d "$(jq -nc --arg b "$1$MARK" '{body:$b}')"; }
# Post a MARKED status/reply comment to an ARBITRARY thread (issue or PR) — never fires a run.
post_to() { curl -sS -w "comment(#$1) -> HTTP %{http_code}\n" -X POST "${hdr[@]}" \
"$API/issues/$1/comments" -d "$(jq -nc --arg b "$2$MARK" '{body:$b}')"; }
# Post an UNMARKED TRIGGER comment on a thread — fires the mentioned agent's next run. Must use a PAT
# (TTOK); the built-in GITEA_TOKEN cannot start new runs. No-op (logged) if this agent has no PAT.
trig() { if [ -z "$TTOK" ]; then echo "no trigger token — cannot fire on #$1"; return; fi
curl -sS -w "trigger(#$1) -> HTTP %{http_code}\n" -X POST \
-H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$1/comments" -d "$(jq -nc --arg b "$2" '{body:$b}')"; }
# 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.
ISSN="${ISSNUM:-$NUM}"
# All OPEN PRs belonging to this issue, oldest→newest. Matches ai/issue-N AND the ai/issue-N-<slug>
# split branches AGENTS.md tells devs to use — an exact-only match silently stalled the flow on
# slugged branches (DELEGATE:@qa found "no open PR"; autopilot MERGE_PR couldn't merge).
resolve_prs() { curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \
| jq -r --arg br "ai/issue-$ISSN" 'if type=="array" then
([ .[] | select(.head.ref==$br or (.head.ref|startswith($br+"-"))) | .number ] | sort | join(" "))
else "" end' 2>/dev/null; }
resolve_pr() { resolve_prs | awk '{print $NF}'; } # newest open PR (empty if none)
# Remove the 'autopilot' label from an issue by resolving its ID first (Gitea's DELETE label # Remove the 'autopilot' label from an issue by resolving its ID first (Gitea's DELETE label
# endpoint is by ID, not name). Arg $1 = issue number. Used as the autopilot kill switch. # endpoint is by ID, not name). Arg $1 = issue number. Used as the autopilot kill switch.
del_autopilot_label() { del_autopilot_label() {
@@ -32,39 +66,92 @@ del_autopilot_label() {
fi fi
} }
# drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / HALT_AUTOPILOT, and the # drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / RETRO / APPROVE / HALT / BOUNCE,
# BEGIN_SUBTASKS..END_SUBTASKS and BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR # and the BEGIN_SUBTASKS..END_SUBTASKS and BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR
# description is published separately). # description is published separately).
reply=$(awk ' reply=$(awk '
/^[[:space:]]*BEGIN_SUBTASKS/{s=1} /^[[:space:]]*BEGIN_SUBTASKS/{s=1}
/^[[:space:]]*BEGIN_PR_DESCRIPTION/{p=1} /^[[:space:]]*BEGIN_PR_DESCRIPTION/{p=1}
/^[[:space:]]*DELEGATE:[[:space:]]*@/{next} /^[[:space:]]*DELEGATE:[[:space:]]*@/{next}
/^[[:space:]]*ASK:[[:space:]]*@/{next}
/^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next} /^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next}
/^[[:space:]]*MERGE_PR[[:space:]]*$/{next} /^[[:space:]]*MERGE_PR[[:space:]]*$/{next}
/^[[:space:]]*HALT_AUTOPILOT[[:space:]]*$/{next} /^[[:space:]]*RETRO[[:space:]]*$/{next}
/^[[:space:]]*APPROVE[[:space:]]*$/{next}
/^[[:space:]]*HALT([_ ]AUTOPILOT)?[[:space:]]*$/{next}
/^[[:space:]]*BOUNCE:[[:space:]]*@/{next} /^[[:space:]]*BOUNCE:[[:space:]]*@/{next}
s{ if(/^[[:space:]]*END_SUBTASKS/){s=0}; next } s{ if(/^[[:space:]]*END_SUBTASKS/){s=0}; next }
p{ if(/^[[:space:]]*END_PR_DESCRIPTION/){p=0}; next } p{ if(/^[[:space:]]*END_PR_DESCRIPTION/){p=0}; next }
{print} {print}
' /tmp/agent_out.md 2>/dev/null) ' /tmp/agent_out.md 2>/dev/null)
# Strip a leading self-header the model sometimes emits ("🤖 **@pm**" on its own line) so we don't # Strip a leading self-identification header the model sometimes emits, e.g. "🤖 **@pm**",
# double it when we prepend our own. Removes a leading run of such header lines and blank lines. # "🔨 **@senior**", or a heading like "## 🔨 @senior — <title>". Gitea already attributes the comment
reply=$(printf '%s' "$reply" | awk ' # to its author, so we drop any leading line that references the agent's OWN @handle — or a bare
# "**@name**" line — together with surrounding blank lines, up to the first real content line.
reply=$(printf '%s' "$reply" | awk -v me="@$NAME" '
BEGIN{s=1} BEGIN{s=1}
s && /^[^A-Za-z0-9]*\*\*@[A-Za-z]+\*\*[[:space:]]*$/ {next}
s && /^[[:space:]]*$/ {next} s && /^[[:space:]]*$/ {next}
s && index($0, me) {next}
s && /^[^A-Za-z0-9]*\*\*@[A-Za-z]+\*\*[[:space:]]*$/ {next}
{s=0; print} {s=0; print}
') ')
[ -z "$reply" ] && reply="_(Made changes without a text summary — see the diff below.)_" [ -z "$reply" ] && reply="_(Made changes without a text summary — see the diff below.)_"
# Prefer the agent's clean delimited PR description; fall back to the whole reply. # Prefer the agent's clean delimited PR description; fall back to the whole reply.
prdesc=$(awk '/BEGIN_PR_DESCRIPTION/{f=1;next} /END_PR_DESCRIPTION/{f=0} f' /tmp/agent_out.md) prdesc=$(awk '/BEGIN_PR_DESCRIPTION/{f=1;next} /END_PR_DESCRIPTION/{f=0} f' /tmp/agent_out.md)
[ -z "$prdesc" ] && prdesc="$reply" [ -z "$prdesc" ] && prdesc="$reply"
# Run report (tool calls + input/output tokens + $ cost) built by build-activity-log.sh. Appended to
# every agent's reply comment so each run reports what it did and what it cost.
activity="$(cat /tmp/activity_log.md 2>/dev/null || true)"
# comment-only roles (pm/qa): never change files # comment-only roles (pm/qa): never change files
if [ "$MODE" != "pr" ]; then if [ "$MODE" != "pr" ]; then
git checkout -- . 2>/dev/null || true git checkout -- . 2>/dev/null || true
git clean -fd 2>/dev/null || true git clean -fd 2>/dev/null || true
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:]')
# ---------- @qa: reviewer only — never edits, never merges ----------
# ALL technical review detail lands ON THE PR (onsite the diff); the ISSUE gets only the terse
# pass/fail verdict so @pm (who never reads the PR) can act on it and the issue thread — which is
# for the creator/orchestration — stays free of review internals. APPROVE / BOUNCE: @dev / HALT.
if [ "$NAME" = "qa" ]; then
PRN=$(resolve_pr)
if grep -qiE '^[[:space:]]*APPROVE[[:space:]]*$' /tmp/agent_out.md; then
if [ -n "$PRN" ]; then
post_to "$PRN" "$reply$activity" # the review detail belongs on the PR
post_to "$ISSN" "✅ Reviewed PR #$PRN — looks good."
else # no PR resolved — nowhere better than the issue
post_to "$ISSN" "$(printf '✅ Reviewed — looks good.\n\n%s%s' "$reply" "$activity")"
fi
trig "$ISSN" "@pm — I have reviewed and approved PR #${PRN:-?} (issue #$ISSN). Over to you."
elif grep -qiE '^[[:space:]]*BOUNCE:[[:space:]]*@(junior|senior|lead|intern)' /tmp/agent_out.md; then
dev=$(grep -oiE 'BOUNCE:[[:space:]]*@(junior|senior|lead|intern)' /tmp/agent_out.md | head -1 | grep -oiE '(junior|senior|lead|intern)' | tr '[:upper:]' '[:lower:]')
[ -z "$dev" ] && [ -n "$PRN" ] && dev=$(curl -sS "${hdr[@]}" "$API/pulls/$PRN" | jq -r '.user.login // "junior"')
dest="${PRN:-$NUM}"
post_to "$dest" "$reply$activity" # recommendations, on the PR
# Bounce budget: count prior bounce TRIGGERS on the PR thread — only @qa-authored comments
# matching the exact "(fix attempt N/3)" template. A loose substring match would also count
# review text QUOTING our own templates (seen on PR #84: the counter jumped 1/3 → 3/3 because
# a qa review quoted publish.sh lines containing the phrase), halving the fix budget.
prior=$(curl -sS "${hdr[@]}" "$API/issues/$dest/comments?limit=100" | jq -r 'if type=="array" then [.[]|select(.user.login=="qa")|select(.body|test("^@[a-z]+ please address my review above and update PR #[0-9?]+ \\(fix attempt [0-9]+/3\\)\\.$"))]|length else 0 end' 2>/dev/null); prior=${prior:-0}
if [ "$prior" -ge 3 ]; then
[ "$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)."
else
n=$((prior + 1))
# NOTE: this template and the counter regex above MUST stay in sync — if you reword one,
# reword the other, or the count resets to 0 and the 3-round cap stops working.
trig "$dest" "@${dev:-junior} please address my review above and update PR #${PRN:-?} (fix attempt $n/3)."
fi
elif grep -qiE '^[[:space:]]*HALT([_ ]AUTOPILOT)?[[:space:]]*$' /tmp/agent_out.md; then
[ "$AUTOPILOT" = "true" ] && del_autopilot_label "$ISSN"
post_to "$ISSN" "$(printf '🛑 This needs a human decision (not a dev fix) — @ffaerber please take a look.\n\n%s%s' "$reply" "$activity")"
else
post_to "${PRN:-$NUM}" "$reply$activity" # no verdict yet (a question) — post where qa works
fi
exit 0
fi
# ---------- @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:]')
# 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
@@ -106,96 +193,125 @@ if [ "$MODE" != "pr" ]; then
echo "created sub-issue #${n:-?}: $title" echo "created sub-issue #${n:-?}: $title"
[ -n "$n" ] && links="$links\n- #$n$title" [ -n "$n" ] && links="$links\n- #$n$title"
done < /tmp/subtasks.txt done < /tmp/subtasks.txt
subtext=$(printf '\n\n---\n🤖 **@%s** — created sub-issues%s (mention an agent on each when ready):%b' "$NAME" "${ms:+ under milestone **$ms**}" "$links") subtext=$(printf '\n\n---\nCreated sub-issues%s (mention an agent on each when ready):%b' "${ms:+ under milestone **$ms**}" "$links")
fi fi
post "$(printf '🤖 **@%s**\n\n%s%s' "$NAME" "$msg" "$subtext")" # 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
# --- AUTOPILOT: @qa's narrow, label-gated merge / halt authority --- # during the run (any comment by $NAME newer than the pre-run newest id from fetch-thread.sh),
# Only @qa, only when 'autopilot' is set, and only on a PR thread. The MERGE_PR / HALT_AUTOPILOT # its self-post IS the reply — skip the duplicate framework comment. Markers (CLOSE_ISSUE,
# markers come from the QA prompt. Merge + label ops use TOKEN_QA (the QA user's PAT, which the # DELEGATE, MERGE_PR, subtasks) were already processed above and are unaffected.
# maintainer must grant write+merge scope). ISSNUM is the origin issue (resolved from the branch). # FAIL OPEN: if the pre-run marker is missing (fetch-thread hiccup), pre_cid=0 would make the
if [ "$NAME" = "qa" ] && [ "$AUTOPILOT" = "true" ]; then # agent's comments from PREVIOUS runs count as self-posts and wrongly suppress the reply.
if grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then # Without the marker, skip the guard and post normally.
if [ -z "$IS_PR" ]; then pre_cid=$(cat /tmp/thread_max_cid 2>/dev/null || echo "")
echo "MERGE_PR marker but this run is not on a PR thread — skipping merge" 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 else
echo "@qa autopilot: merging PR #$NUM (origin issue #${ISSNUM:-$NUM})" post "$(printf '%s%s%s' "$msg" "$subtext" "$activity")"
# Merge with AGENT_TOKEN (a PAT) — NOT the built-in Actions token — so the resulting push to fi
# main TRIGGERS downstream workflows (e.g. deploy). A merge made with the built-in GITEA_TOKEN
# does not fire new runs (loop-prevention), which silently skips the deploy. Fall back to the # --- @pm autopilot merge: @pm is the ONLY agent that merges, and ONLY under the autopilot label ---
# agent's own token only if AGENT_TOKEN isn't set (then the deploy would need a manual run). # (@qa never merges — it approves and hands back here.) Merge with the PAT (TTOK), not the built-in
mtok="${AGENT_TOKEN:-$TOK}" # 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
PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w)
if [ -z "$PRN" ]; then
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
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 \
-H "Authorization: token $mtok" -H "Content-Type: application/json" \ -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/pulls/$NUM/merge" -d '{"Do":"merge"}') "$API/pulls/$PRN/merge" -d '{"Do":"merge"}')
echo "merge -> HTTP $mc"; cat /tmp/merge_resp.txt 2>/dev/null || true echo "merge -> HTTP $mc"; cat /tmp/merge_resp.txt 2>/dev/null || true
case "$mc" in case "$mc" in
200|201|204) 200|201|204)
echo "closing origin issue #${ISSNUM:-$NUM}" curl -sS -X PATCH "${hdr[@]}" "$API/issues/$ISSN" -d '{"state":"closed"}' -w '\nclose -> HTTP %{http_code}\n' || true
curl -sS -X PATCH "${hdr[@]}" "$API/issues/${ISSNUM:-$NUM}" \ post_to "$ISSN" "✅ Merged PR #$PRN (autopilot) and closed this issue." ;;
-d '{"state":"closed"}' -w '\nclose -> HTTP %{http_code}\n' || true
post "$(printf '🤖 **@qa** — ✅ verified & merged PR #%s (autopilot). Closed issue #%s.' "$NUM" "${ISSNUM:-$NUM}")"
;;
*) *)
# Merge failed (checks not green, conflicts, or TOKEN_QA lacks merge scope) — do NOT del_autopilot_label "$ISSN"
# silently proceed: drop the label so it reverts to human control and report. post_to "$ISSN" "⚠️ Tried to merge PR #$PRN but the API returned HTTP $mc (checks not green, a conflict, or TOKEN_PM lacks merge scope). Removed the autopilot label — @ffaerber please take a look." ;;
del_autopilot_label "${ISSNUM:-$NUM}"
post "$(printf '🤖 **@qa** — ⚠️ tried to merge PR #%s but the API returned HTTP %s (checks not green, a conflict, or missing merge permission on TOKEN_QA). Removed the `autopilot` label — @ffaerber please take a look.' "$NUM" "$mc")"
;;
esac esac
fi fi
elif grep -qiE '^[[:space:]]*BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md; then
# @qa wants the dev to fix something. Send it back — never fix it ourselves. After 3 bounces,
# stop and hand to the human. QA's feedback is already posted (the reply comment above).
if [ -z "$IS_PR" ]; then
echo "BOUNCE marker but this run is not on a PR thread — skipping"
else
target=$(grep -oiE 'BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md | head -1 \
| grep -oiE '(junior|senior|lead)' | tr '[:upper:]' '[:lower:]')
[ -z "$target" ] && target=$(curl -sS "${hdr[@]}" "$API/pulls/$NUM" | jq -r '.user.login // "junior"')
# Count how many times this PR has already been bounced (marker in the trigger comment).
prior=$(curl -sS "${hdr[@]}" "$API/issues/$NUM/comments?limit=100" \
| jq -r 'if type=="array" then [.[]|select(.body|test("autopilot fix attempt"))]|length else 0 end' 2>/dev/null)
prior=${prior:-0}
if [ "$prior" -ge 3 ]; then
echo "@qa autopilot: 3 bounces already — halting"
del_autopilot_label "${ISSNUM:-$NUM}"
post "$(printf '🤖 **@qa** — 🛑 still not right after 3 fix attempts. Stopping autopilot (removed the `autopilot` label). @ffaerber please take over — details in the comments above.')"
else
n=$((prior + 1))
echo "@qa autopilot: bounce $n/3 -> @$target"
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \
"$API/issues/$NUM/comments" \
-d "$(jq -nc --arg b "@$target please address @qa's feedback above and update this PR (autopilot fix attempt $n/3)." '{body:$b}')" \
-w '\nbounce -> HTTP %{http_code}\n' || true
fi
fi
elif grep -qiE '^[[:space:]]*HALT_AUTOPILOT[[:space:]]*$' /tmp/agent_out.md; then
echo "@qa autopilot: HALT — removing 'autopilot' label from #${ISSNUM:-$NUM}"
del_autopilot_label "${ISSNUM:-$NUM}"
post "$(printf '🤖 **@qa** — 🛑 this needs a human decision (not a dev fix). Removed the `autopilot` label (back to human control). @ffaerber please decide next steps (details above).')"
fi
exit 0 exit 0
fi fi
# Auto-delegate: if the plan names a teammate, trigger them via AGENT_TOKEN (a PAT, so it # --- @pm RETRO: open a retrospective issue for this thread (maintainer asked for a retro) ---
# fires a new workflow run — the built-in token cannot). Never targets @pm or self, so the # Creates a retro issue pointing at this issue + its PR and triggers @senior on it (has gitea-api
# chain always terminates at a dev. The '🤖' guard on the trigger stops status-comment loops. # to read both threads). The retro produces a LEARNINGS.md PR via the NORMAL choreography (senior →
if [ -n "$AGENT_TOKEN" ]; then # pm → qa → merge), and run-agent.sh injects LEARNINGS.md into every future prompt — closing the loop.
# Only delegate on an explicit "DELEGATE: @<agent>" line — never on a prose mention, if [ "$NAME" = "pm" ] && grep -qiE '^[[:space:]]*RETRO[[:space:]]*$' /tmp/agent_out.md; then
# so an agent that is asking the maintainer a question does not hand off prematurely. PRN=$(curl -sS "${hdr[@]}" "$API/pulls?state=all&limit=50" \
target=$(grep -oiE 'DELEGATE:[[:space:]]*@(junior|senior|lead|qa)' /tmp/agent_out.md 2>/dev/null \ | 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)
| head -1 | grep -oiE '(junior|senior|lead|qa)' | tr '[:upper:]' '[:lower:]') 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 ---
# 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
# 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
echo "auto-delegating to @$target" if [ "$target" = "qa" ]; then
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w)
"$API/issues/$NUM/comments" \ if [ -n "$PRN" ] && [ "$CNT" -gt 1 ]; then
-d "$(jq -nc --arg b "@$target please proceed with issue #$NUM per the plan above (delegated by $NAME)." '{body:$b}')" \ 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."
-w '\ndelegate -> HTTP %{http_code}\n' || true elif [ -n "$PRN" ]; then
trig "$ISSN" "@qa please review PR #$PRN for issue #$ISSN — put your recommendations on the PR, or approve."
else
echo "DELEGATE:@qa but no open PR yet for issue #$ISSN — not firing"
fi
else
trig "$ISSN" "@$target please proceed with issue #$ISSN per my plan above."
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)"
fi fi
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 exit 0
fi fi
@@ -218,22 +334,21 @@ if [ -n "$(git status --porcelain)" ]; then
git add -A git add -A
git commit -m "@$NAME: issue #$NUM" git commit -m "@$NAME: issue #$NUM"
fi fi
git push origin "HEAD:$BRANCH" || true if git push origin "HEAD:$BRANCH"; then
:
else
status=$?
echo "git push failed for $BRANCH (exit $status)"
post "$(printf '⚠️ Push to branch `%s` failed (git exit %s). The PR will not open until the push succeeds. Please check the Actions log.%s' "$BRANCH" "$status" "$activity")"
exit 0
fi
git fetch -q origin 2>/dev/null || true git fetch -q origin 2>/dev/null || true
prbody=$(printf '%s\n\n---\nResolves #%s · 🤖 @%s' "$prdesc" "$NUM" "$NAME") prbody=$(printf '%s\n\n---\nResolves #%s' "$prdesc" "$NUM")
owner=${GITHUB_REPOSITORY%%/*} owner=${GITHUB_REPOSITORY%%/*}
# Post the agent's activity trail (tool calls + reasoning) inline in the same comment so # $activity (the run report: tool calls + tokens + $ cost) was built once near the top, so every
# each run produces exactly ONE comment (issue #38). Computed once here so every dev-agent # dev-agent exit path (no-changes, PR-open-failed, normal) appends it to the single reply comment.
# exit path (no-changes, PR-open-failed, normal) appends it to the single reply comment.
activity=""
if [ -s /tmp/activity_log.md ]; then
entries=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0)
log=$(cat /tmp/activity_log.md)
activity=$(printf '\n\n<details>\n<summary>🔧 activity — %s tool calls</summary>\n\n%s\n\n</details>' "$entries" "$log")
fi
# One PR per run: publish ONLY this run's own branch ($BRANCH), never sibling # One PR per run: publish ONLY this run's own branch ($BRANCH), never sibling
# ai/issue-N-* branches. This removes the multi-PR ambiguity that left the # ai/issue-N-* branches. This removes the multi-PR ambiguity that left the
# activity log stranded on the triggering issue instead of the PR thread. # activity log stranded on the triggering issue instead of the PR thread.
@@ -241,7 +356,7 @@ br="$BRANCH"
ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0) ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0)
if [ "${ahead:-0}" -eq 0 ]; then if [ "${ahead:-0}" -eq 0 ]; then
# No changes on this branch — a plan / questions / analysis only. # No changes on this branch — a plan / questions / analysis only.
post "$(printf '🤖 **@%s**\n\n%s%s' "$NAME" "$reply" "$activity")" post "$(printf '%s%s' "$reply" "$activity")"
exit 0 exit 0
fi fi
@@ -252,14 +367,21 @@ url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null) prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
if [ -z "$url" ]; then if [ -z "$url" ]; then
title="@$NAME: $TITLE" title="@$NAME: $TITLE"
resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \ resp_body=/tmp/pr_create_resp.json
http_status=$(curl -sS -o "$resp_body" -w '%{http_code}' -X POST "${hdr[@]}" "$API/pulls" \
-d "$(jq -nc --arg t "$title" --arg h "$br" --arg b "$prbody" \ -d "$(jq -nc --arg t "$title" --arg h "$br" --arg b "$prbody" \
'{title:$t, head:$h, base:"main", body:$b}')") '{title:$t, head:$h, base:"main", body:$b}')")
echo "PR create ($br): $resp" resp=$(cat "$resp_body" 2>/dev/null || true)
echo "PR create ($br): HTTP $http_status$resp"
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null) url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null) prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
fi fi
[ -z "$url" ] && { echo "PR open/lookup failed for $br — posting reply on issue instead"; post "$(printf '🤖 **@%s**\n\n%s%s' "$NAME" "$reply" "$activity")"; exit 0; } if [ -z "$url" ]; then
err_msg=$(printf '%s' "$resp" | jq -r 'if type=="object" and .message then .message else "(no error message in response)" end' 2>/dev/null)
echo "PR open/lookup failed for $br — HTTP $http_status — response: $resp"
post "$(printf '⚠️ Failed to open PR for branch `%s`.\n\nHTTP status: %s\nGitea message: %s%s' "$br" "$http_status" "$err_msg" "$activity")"
exit 0
fi
# Posts to the PR thread when we have a PR number, else to the origin issue ($NUM). # Posts to the PR thread when we have a PR number, else to the origin issue ($NUM).
prpost() { prpost() {
@@ -267,36 +389,19 @@ prpost() {
[ -n "$n" ] && [ "$n" != "$NUM" ] && t="$n" [ -n "$n" ] && [ "$n" != "$NUM" ] && t="$n"
echo "posting to #$t" echo "posting to #$t"
curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \ curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \
"$API/issues/$t/comments" -d "$(jq -nc --arg b "$1" '{body:$b}')" "$API/issues/$t/comments" -d "$(jq -nc --arg b "$1$MARK" '{body:$b}')"
} }
if [ "$NEW" = "true" ]; then if [ "$NEW" = "true" ]; then
prpost "$prnum" "$(printf '🤖 **@%s** — ✅ PR ready for review — @ffaerber please review & merge:\n- %s%s' "$NAME" "$url" "$activity")" # First PR for this issue: record it on the PR thread, then notify @pm on the ISSUE. @pm never
# AUTOPILOT: hand the fresh PR to @qa automatically (via AGENT_TOKEN, so it fires a new run). # reads the PR, so the issue gets only this one-line ping — @pm then routes it to @qa for review.
# @qa then verifies and — if green — merges + closes via its MERGE_PR marker. The comment lands prpost "$prnum" "$(printf 'Opened PR #%s for review.%s' "$prnum" "$activity")"
# on the PR thread ($prnum) so the next run resolves the origin issue's label from the branch trig "$ISSN" "@pm — PR #$prnum is ready for review (issue #$ISSN)."
# name. The '🤖' guard on the trigger gate stops status-comment loops.
if [ "$AUTOPILOT" = "true" ] && [ -n "$AGENT_TOKEN" ] && [ -n "$prnum" ]; then
echo "autopilot: auto-triggering @qa to review PR #$prnum"
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \
"$API/issues/$prnum/comments" \
-d "$(jq -nc --arg b "@qa please verify this PR (autopilot: issue #$NUM is labeled autopilot). Merge it if correct, or bounce it back to the dev with exactly what needs fixing." '{body:$b}')" \
-w '\ntrigger-qa -> HTTP %{http_code}\n' || true
fi
else else
# Resume: just link the PR — its body and the diff already carry the description, so we don't # A fix (usually after a @qa bounce): update the PR and hand straight back to @qa to re-verify,
# repeat the full write-up in the comment (the reasoning trail below shows what this run did). # on the PR thread. The qa↔dev loop is direct — it does NOT go back through @pm each round.
prpost "$prnum" "$(printf '🤖 **@%s** — pushed an update to the PR:\n- %s%s' "$NAME" "$url" "$activity")" prpost "$prnum" "$(printf 'Pushed an update to PR #%s.%s' "$prnum" "$activity")"
# AUTOPILOT: after a dev pushes a fix (e.g. following a @qa bounce), hand back to @qa to re-verify.
if [ "$AUTOPILOT" = "true" ] && [ -n "$AGENT_TOKEN" ] && [ -n "$prnum" ]; then
case "$NAME" in case "$NAME" in
junior|senior|lead) junior|senior|lead|intern) trig "$prnum" "@qa please re-verify PR #$prnum — I have pushed an update." ;;
echo "autopilot: dev pushed a fix — re-triggering @qa to re-verify PR #$prnum"
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \
"$API/issues/$prnum/comments" \
-d "$(jq -nc --arg b "@qa please re-verify this PR (autopilot). Merge it if now correct, or bounce it back with exactly what still needs fixing." '{body:$b}')" \
-w '\ntrigger-qa -> HTTP %{http_code}\n' || true
;;
esac esac
fi fi
fi
+18 -4
View File
@@ -6,7 +6,7 @@
# the run. Comment-only roles (pm/qa) push nothing, so they are skipped. # the run. Comment-only roles (pm/qa) push nothing, so they are skipped.
# #
# Required env (provided by the workflow step): # 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 # NAME MODE NUM TITLE BRANCH GITHUB_SERVER_URL GITHUB_REPOSITORY
set +e 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. # 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";; *) TOK="";; lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; intern) TOK="$TOKEN_INTERN";; *) TOK="";;
esac esac
[ -z "$TOK" ] && TOK="$GT" [ -z "$TOK" ] && TOK="$GT"
# Trigger token: the @pm hand-back below must FIRE a new run, which the built-in token cannot.
TTOK="$TOK"; [ "$TTOK" = "$GT" ] && TTOK=""
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")
@@ -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) | 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) url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
if [ -z "$url" ]; then 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" \ resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \
-d "$(jq -nc --arg t "@$NAME: $TITLE" --arg h "$BRANCH" --arg b "$body" \ -d "$(jq -nc --arg t "@$NAME: $TITLE" --arg h "$BRANCH" --arg b "$body" \
'{title:$t, head:$h, base:"main", body:$b}')") '{title:$t, head:$h, base:"main", body:$b}')")
echo "rescue PR create ($BRANCH): $resp" echo "rescue PR create ($BRANCH): $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)
else
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
fi fi
if [ -n "$url" ]; then if [ -n "$url" ]; then
# Status note on the issue (marked — must not trigger)…
curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \ 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<!-- 🤖 agent reply — do not trigger -->' "$BRANCH" "$url")" '{body:$b}')" \
-w '\nrescue comment -> HTTP %{http_code}\n' || true -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 else
echo "rescue: could not open/find a PR for $BRANCH" echo "rescue: could not open/find a PR for $BRANCH"
fi fi
+47 -8
View File
@@ -27,24 +27,58 @@ cp "$AGENTS_JSON" /tmp/agents.json
# here — see agent.yml: this reusable workflow sees it as 'workflow_call'.) # here — see agent.yml: this reusable workflow sees it as 'workflow_call'.)
if [ -n "$CID" ]; then scan="$BODY"; else scan="$IBODY"; fi if [ -n "$CID" ]; then scan="$BODY"; else scan="$IBODY"; fi
name="" name=""
for a in pm junior senior lead qa ops; do # FIRST MATCH IN THIS LIST ORDER WINS when a comment mentions several agents. The order is
case "$scan" in *"@$a"*) name=$a; break;; esac # 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.
# WORD-BOUNDARY match, not substring: "@internal" or "x@internet.com" must NOT route to @intern
# (the workflow gate can only do contains(), so this is where its false positives get filtered).
for a in pm junior senior lead qa ops intern; do
if printf '%s' "$scan" | grep -qE "(^|[^[:alnum:]_])@$a([^[:alnum:]_-]|\$)"; then name=$a; break; fi
done done
if [ -z "$name" ]; then if [ -z "$name" ]; then
if [ -z "$CID" ]; then name=pm; else echo "no known agent mentioned"; exit 1; fi if [ -z "$CID" ]; then
name=pm
else
# Not an agent task (e.g. the gate's contains() matched "@internal"). Skip GRACEFULLY: emit
# mode=skip so every later step no-ops — a red run for a non-agent comment is just noise.
echo "no known agent mentioned (word-boundary) — skipping run"
{ echo "name=none"; echo "model=none"; echo "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)"
{ echo "name=$name"; echo "model=$model"; echo "vision=$vision"; echo "mode=$mode"; echo "skills=$skills"; } >> "$GITHUB_OUTPUT" # --- WORKMODE for dev (mode=pr) agents: build vs DISCUSS. ---
# Mentioning a dev is a CONVERSATION by default — it replies in the thread without creating a
# branch or PR. Actual building starts ONLY on the explicit signals:
# - a comment on a PR thread (resuming existing work), or
# - the pm delegation template ".. please proceed with issue .." (also usable by a human), or
# - the qa bounce template ".. please address my review ..".
# This lets @pm (via its ASK marker) and the maintainer consult devs to gather information first,
# and explicitly start the build later — see publish.sh / run-agent.sh.
workmode=build
if [ "$mode" = "pr" ] && [ -z "$IS_PR" ]; then
if printf '%s' "$scan" | grep -qiE 'please (proceed with issue|address my review)'; then
workmode=build
else
workmode=discuss
fi
fi
echo "Routing to @$name (model=$model 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";; *) TOK="";; lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; intern) TOK="$TOKEN_INTERN";; *) TOK="";;
esac esac
[ -z "$TOK" ] && TOK="$GT" [ -z "$TOK" ] && TOK="$GT"
git config user.name "$name" git config user.name "$name"
@@ -52,7 +86,10 @@ git config user.email "$name@ffaerber.duckdns.org"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json") hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
branch_ref="" branch_ref=""
if [ -n "$IS_PR" ]; then # comment on a PR -> resume its branch if [ "$workmode" = "discuss" ]; then # conversation only — no branch, no PR machinery
echo "discussion mode — staying on main, no branch prep"
{ echo "branch=main"; echo "new=false"; } >> "$GITHUB_OUTPUT"
elif [ -n "$IS_PR" ]; then # comment on a PR -> resume its branch
ref=$(curl -s -H "Authorization: token $GT" "$API/pulls/$NUM" | jq -r .head.ref) ref=$(curl -s -H "Authorization: token $GT" "$API/pulls/$NUM" | jq -r .head.ref)
branch_ref="$ref" branch_ref="$ref"
git fetch origin "$ref" && git checkout "$ref" git fetch origin "$ref" && git checkout "$ref"
@@ -71,7 +108,9 @@ else # comment on an issue, no branch ye
git push -u origin "HEAD:ai/issue-$NUM" || true git push -u origin "HEAD:ai/issue-$NUM" || true
url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/src/branch/ai/issue-$NUM" url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/src/branch/ai/issue-$NUM"
curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \ curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \
-d "$(jq -nc --arg b "🔨 **@$name** is on it — building on branch [\`ai/issue-$NUM\`]($url). I'll open a PR when it's ready." '{body:$b}')" >/dev/null || true -d "$(jq -nc --arg b "🔨 Building on branch [\`ai/issue-$NUM\`]($url) I'll open a PR when it's ready.
<!-- 🤖 agent reply — do not trigger -->" '{body:$b}')" >/dev/null || true
fi fi
fi fi
+125 -46
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):
# ANTHROPIC_API_KEY AGENT_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE # XAI_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
# IBODY CMT # IBODY CMT
# FILES (the opencode -f image flags, from the imgs step output) # FILES (the opencode -f image flags, from the imgs step output)
# AUTOPILOT is 'true' when the issue carries the `autopilot` label (label-gated autopilot mode). # AUTOPILOT is 'true' when the issue carries the `autopilot` label (label-gated autopilot mode).
@@ -12,9 +12,10 @@ set -u
[ -z "$CMT" ] && CMT="(a new issue was just opened — assess it)" [ -z "$CMT" ] && CMT="(a new issue was just opened — assess it)"
THREAD=$(cat /tmp/thread.md 2>/dev/null); [ -z "$THREAD" ] && THREAD="(no prior comments)" THREAD=$(cat /tmp/thread.md 2>/dev/null); [ -z "$THREAD" ] && THREAD="(no prior comments)"
DESC=$(jq -r --arg a "$NAME" '.[$a].desc' /tmp/agents.json) DESC=$(jq -r --arg a "$NAME" '.[$a].desc' /tmp/agents.json)
# Include each teammate's skills so an agent (esp. @pm) can route by capability — e.g. only # Include each teammate's registry skills so an agent (esp. @pm) can route by capability — e.g. only
# @senior/@lead hold node1-ssh, so a node1 task must not go to @junior. Skill *names* only; the # skill-holders should get a task that needs that skill. Skill *names* only; the scoped how-to detail
# scoped how-to detail stays hidden per the permission.skill allow-list. # stays hidden per the permission.skill allow-list. (Caller-provided skills from a repo's
# .gitea/agent-skills/ are not in this roster — document that routing in the caller's AGENTS.md.)
ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision); skills: \(.value.skills | if length>0 then join(", ") else "none" end))") | join("\n")' /tmp/agents.json) ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision); skills: \(.value.skills | if length>0 then join(", ") else "none" end))") | join("\n")' /tmp/agents.json)
if [ "$VISION" = "true" ]; then CAP="You CAN read images attached to the issue."; else CAP="You CANNOT read images — you are a text-only model."; fi if [ "$VISION" = "true" ]; then CAP="You CAN read images attached to the issue."; else CAP="You CANNOT read images — you are a text-only model."; fi
NOTE="" NOTE=""
@@ -25,7 +26,7 @@ if [ "$MODE" = "comment" ]; then
ACTION="You do NOT edit files, create branches, or write a PR description. Respond with your analysis, ACTION="You do NOT edit files, create branches, or write a PR description. Respond with your analysis,
plan, research, or clarifying questions — your reply becomes a comment on the issue. plan, research, or clarifying questions — your reply becomes a comment on the issue.
To hand work to a teammate, end your reply with EXACTLY one line: 'DELEGATE: @<agent>' (one of To hand work to a teammate, end your reply with EXACTLY one line: 'DELEGATE: @<agent>' (one of
@junior @senior @lead @qa) — but ONLY when you are ready to hand off AND need nothing further from the @junior @senior @lead @qa @intern) — but ONLY when you are ready to hand off AND need nothing further from the
maintainer. If you are asking @ffaerber to confirm or decide ANYTHING, do NOT include a DELEGATE line; maintainer. If you are asking @ffaerber to confirm or decide ANYTHING, do NOT include a DELEGATE line;
just ask and wait. Never ask for confirmation and delegate in the same reply. Mentioning a teammate in just ask and wait. Never ask for confirmation and delegate in the same reply. Mentioning a teammate in
prose does NOT delegate — only the DELEGATE line does. prose does NOT delegate — only the DELEGATE line does.
@@ -34,59 +35,81 @@ if [ "$MODE" = "comment" ]; then
obviously not needed; when in doubt, ask instead." obviously not needed; when in doubt, ask instead."
if [ "$NAME" = "pm" ]; then if [ "$NAME" = "pm" ]; then
ACTION="$ACTION ACTION="$ACTION
As PM you work in two phases and NEVER skip the approval gate: As PM you ORCHESTRATE this issue from the ISSUE THREAD ONLY — you never read or comment on the PR
PLAN — when the task is clear, present a SHORT plan naming which teammate should build it (keep your context on the issue). Read the thread and act for the CURRENT phase:
(@junior for small/low-risk, @senior/@lead for complex, @qa to verify), then END by asking
'@ffaerber ready to start building? reply yes to proceed.' Do NOT include a DELEGATE line yet. PHASE 1 — PLAN (a fresh request; no dev is building yet). Present a SHORT plan naming which
DELEGATE — ONLY after the maintainer has explicitly approved starting in the thread (a clear teammate should build it (@junior small/low-risk YAML/compose/config; @senior/@lead complex or
'yes' / 'go' / 'proceed' / 'start building' answering your ready-to-build question) do you end multi-file). Then END by asking '@ffaerber ready to start building? reply yes to proceed.' — do
your reply with a 'DELEGATE: @<agent>' line to hand off. NOT delegate yet. ONLY after an explicit 'yes'/'go'/'proceed' do you end a reply with a
Never present a plan and delegate on the same turn. If anything is unclear or needs a decision, 'DELEGATE: @<dev>' line to hand off. Never plan and delegate in the same reply.
START your reply with '@ffaerber', ask specific questions, and do NOT delegate.
BREAKDOWN (for a feature too big for one PR): first PLAN — propose a milestone name and the list PHASE 2 — REVIEW (a dev has reported 'PR #<n> is ready'). Do NOT re-plan. Briefly acknowledge and
of sub-tasks (title + one line each), then ask '@ffaerber create these N sub-issues? reply yes.' hand the PR to QA: end your reply with EXACTLY 'DELEGATE: @qa'. (The automation tells @qa which PR
Do NOT emit the block yet. ONLY after the maintainer approves, end your reply with EXACTLY: to review; @qa reviews it on the PR, not here — you never see the diff.)
PHASE 3 — FINALIZE (@qa has reported the PR is approved / 'code OK'). Tell the issue creator it is
ready: e.g. 'PR #<n> is reviewed and ready to merge, @ffaerber.' Do NOT delegate and do NOT merge —
the human merges.
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
marker line does.
ASK — to CONSULT a dev before (or instead of) planning, end your reply with EXACTLY one line:
'ASK: @<dev> <one concrete question>'. The dev replies in this thread WITHOUT starting any work —
use it to gather feasibility/effort/approach input, then present your plan (and later DELEGATE)
once you know enough. One ASK per reply; never ASK and DELEGATE in the same reply.
RETRO — when the maintainer asks for a retrospective on this issue (e.g. 'run a retro',
'@pm retro'), briefly acknowledge and end your reply with EXACTLY one line: 'RETRO'. The
automation opens a retro issue (read this issue + its PR, distill learnings into LEARNINGS.md)
and assigns it. Emit RETRO only when explicitly asked.
BREAKDOWN (a feature too big for one PR): in PHASE 1, propose a milestone name and the sub-task
list, then ask '@ffaerber create these N sub-issues? reply yes.' ONLY after approval, end with:
BEGIN_SUBTASKS BEGIN_SUBTASKS
milestone: <feature name> milestone: <feature name>
- <task title> :: <one-line description> - <task title> :: <one-line description>
- <task title> :: <one-line description> - <task title> :: <one-line description>
END_SUBTASKS END_SUBTASKS
The automation creates the milestone + one sub-issue per line (each linked to this issue). It The automation creates the milestone + one sub-issue per line (each linked here); it does NOT
does NOT auto-start any dev — the maintainer @mentions an agent on each sub-issue when ready." auto-start any dev — the maintainer @mentions an agent on each sub-issue when ready."
if [ "$AUTOPILOT" = "true" ]; then if [ "$AUTOPILOT" = "true" ]; then
ACTION="$ACTION ACTION="$ACTION
AUTOPILOT MODE IS ACTIVE (this issue carries the 'autopilot' label). This OVERRIDES the AUTOPILOT MODE IS ACTIVE (this issue carries the 'autopilot' label) — it changes exactly TWO
two-phase approval gate above: do NOT ask '@ffaerber ready to start building?' and do NOT wait things for you; everything else above is unchanged:
for a 'yes'. When the task is clear, present your SHORT plan naming the best teammate to build it - PHASE 1: do NOT ask '@ffaerber ready to build?'. Present your SHORT plan AND end with a
AND end your reply with a 'DELEGATE: @<agent>' line in the SAME turn to hand off immediately. 'DELEGATE: @<dev>' line in the SAME reply. Only skip delegating (and ask @ffaerber) if the task
Prefer @junior for small/low-risk (mostly YAML/compose/config), @senior/@lead for complex or is genuinely ambiguous or unsafe.
multi-file work. Only skip delegating (and instead ask @ffaerber) if the task is genuinely - PHASE 3: do NOT ask the human to merge. When @qa has approved, end your reply with EXACTLY
ambiguous or unsafe — otherwise plan-and-delegate now." 'MERGE_PR' — the automation merges the PR and closes this issue. You are the ONLY agent that
merges, and only here."
fi fi
fi fi
if [ "$NAME" = "qa" ]; then if [ "$NAME" = "qa" ]; then
ACTION="$ACTION ACTION="$ACTION
As QA you verify a change works: read the PR/issue, drive the web app with your headless As QA you are the REVIEWER — you NEVER edit code and NEVER merge. @pm points you at a PR; review
browser if there is a URL, and report bugs or confirm behavior. You normally do NOT merge — it: read the diff, drive the web app with your headless browser if there is a URL, and put your
a human does that." detailed, specific recommendations ON THE PR (the automation posts your reply to the PR thread).
if [ "$AUTOPILOT" = "true" ]; then After actually verifying, end your reply with EXACTLY one of:
ACTION="$ACTION - 'APPROVE' — the change is correct and any CI is green. The automation records your verdict on the
AUTOPILOT MODE IS ACTIVE (this issue/PR carries the 'autopilot' label). You are the quality gate. issue and hands back to @pm (who tells the creator, or in autopilot merges). You do NOT merge.
You do NOT edit code or fix anything yourself — you either accept the PR or send it back to the dev
with precise instructions. After actually verifying, end your reply with EXACTLY one of:
- 'MERGE_PR' — the change is correct and any CI is green. The automation merges the PR and closes
the linked issue. Do NOT merge by any other means; only this marker triggers the 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 the dev must change (name the file, label, value, hostname, etc.), THEN end with the BOUNCE what to change (file, label, value, hostname, ), THEN end with the BOUNCE line naming who fixes
line naming who should fix it (@junior / @senior / @lead — usually whoever built it; @senior or it (@junior / @senior / @lead / @intern — usually whoever built it). The automation sends the PR back and
@lead for something harder). The automation sends the PR back to that dev and then re-verifies re-verifies with you. After 3 rounds it stops and hands to @ffaerber — so list ALL problems at
with you. After 3 bounces it stops automatically and hands to @ffaerber — so make each round once, not one at a time.
count and list ALL problems at once, not one at a time. - 'HALT' — the problem is NOT something a dev can fix (the request is ambiguous / needs a human
Use BOUNCE for anything a dev can fix. Only use 'HALT_AUTOPILOT' when the problem is NOT fixable by decision). Hands back to @ffaerber.
a dev — the request itself is ambiguous or needs a human decision — to hand back to @ffaerber. Emit AT MOST one marker, and only after you have actually verified."
Emit AT MOST one of MERGE_PR / BOUNCE / HALT_AUTOPILOT, and only after you have actually verified."
fi
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,
@@ -96,10 +119,41 @@ 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
shown by Gitea. Do NOT begin your reply with your own name, an '@${NAME}' header, or a '🤖/🔨 @you'
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}
@@ -127,14 +181,39 @@ 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)" echo "opencode attempt $attempt/3 for @$NAME ($MODEL, timeout ${AGENT_TIMEOUT}s)"
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
+30 -19
View File
@@ -3,20 +3,18 @@
# Emits an opencode Skill file under ~/.config/opencode/skills/ documenting how to create # Emits an opencode Skill file under ~/.config/opencode/skills/ documenting how to create
# orgs/users/repos, manage labels & secrets, and mint scoped per-user tokens via the Gitea API. # orgs/users/repos, manage labels & secrets, and mint scoped per-user tokens via the Gitea API.
# #
# The credential is TOKEN_OPS (BOOTSTRAP: currently an admin PAT — temporary). This skill doc is # The credential is SELF_TOKEN (BOOTSTRAP: currently an admin PAT — temporary). This skill doc is
# written ONLY for @ops (gated on NAME) so the how-to never reaches other agents. NOTE: while # written ONLY for @ops (gated on NAME) so the how-to never reaches other agents. NOTE: while
# TOKEN_OPS is admin, every agent's process technically holds an admin credential in its env — # SELF_TOKEN is admin, every agent's process technically holds an admin credential in its env —
# that is the bootstrap trade-off. Once @ops is minting scoped per-user tokens, TOKEN_OPS should be # that is the bootstrap trade-off. Once @ops is minting scoped per-user tokens, SELF_TOKEN should be
# narrowed and a dedicated admin token injected only for @ops. # narrowed and a dedicated admin token injected only for @ops.
# #
# Required env (provided by the workflow step): NAME TOKEN_OPS # Required env (provided by the workflow step): NAME SELF_TOKEN
set -eu set -eu
[ "${NAME:-}" = "ops" ] || { echo "not @ops — skipping gitea-admin skill"; exit 0; } [ "${NAME:-}" = "ops" ] || { echo "not @ops — skipping gitea-admin skill"; exit 0; }
if [ -z "${TOKEN_OPS:-}" ]; then # The doc references $SELF_TOKEN (@ops's own admin token, present in the Run-agent step). This step
echo "TOKEN_OPS not set — skipping gitea-admin skill" # only writes the doc for @ops; permission.skill also denies the skill to every other agent.
exit 0
fi
mkdir -p ~/.config/opencode/skills/gitea-admin && chmod 700 ~/.config/opencode/skills/gitea-admin mkdir -p ~/.config/opencode/skills/gitea-admin && chmod 700 ~/.config/opencode/skills/gitea-admin
cat > ~/.config/opencode/skills/gitea-admin/SKILL.md <<'SKILLET' cat > ~/.config/opencode/skills/gitea-admin/SKILL.md <<'SKILLET'
--- ---
@@ -29,7 +27,7 @@ tags: [gitea, admin, api, curl, bootstrap]
# `gitea-admin` Skill (operator / @ops only) # `gitea-admin` Skill (operator / @ops only)
Administer the Gitea instance via its REST API at `${GITHUB_SERVER_URL}/api/v1`, authenticated with Administer the Gitea instance via its REST API at `${GITHUB_SERVER_URL}/api/v1`, authenticated with
`Authorization: token ${TOKEN_OPS}` (a site-admin token during bootstrap). Both env vars are `Authorization: token ${SELF_TOKEN}` (a site-admin token during bootstrap). Both env vars are
already set. Work from the issue instructions; report what you did. already set. Work from the issue instructions; report what you did.
## Golden rules ## Golden rules
@@ -42,7 +40,7 @@ already set. Work from the issue instructions; report what you did.
## Create an organisation ## Create an organisation
``` ```
curl -sS -X POST -H "Authorization: token $TOKEN_OPS" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$API/orgs" -d '{"username":"acme","visibility":"private"}' "$API/orgs" -d '{"username":"acme","visibility":"private"}'
``` ```
@@ -53,14 +51,14 @@ password you just set) to mint a scoped token, and store the token straight into
API="${GITHUB_SERVER_URL}/api/v1" API="${GITHUB_SERVER_URL}/api/v1"
PW=$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24) # generated, never printed PW=$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24) # generated, never printed
# 1) create the user # 1) create the user
curl -sS -X POST -H "Authorization: token $TOKEN_OPS" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$API/admin/users" -d "$(jq -nc --arg u inter --arg e inter@ffaerber.duckdns.org --arg p "$PW" \ "$API/admin/users" -d "$(jq -nc --arg u inter --arg e inter@ffaerber.duckdns.org --arg p "$PW" \
'{username:$u,email:$e,password:$p,must_change_password:false,source_id:0,visibility:"private"}')" '{username:$u,email:$e,password:$p,must_change_password:false,source_id:0,visibility:"private"}')"
# 2) mint a scoped token AS that user (pick the narrowest scopes needed) # 2) mint a scoped token AS that user (pick the narrowest scopes needed)
tok=$(curl -sS -u "inter:$PW" -H "Content-Type: application/json" -X POST "$API/users/inter/tokens" \ tok=$(curl -sS -u "inter:$PW" -H "Content-Type: application/json" -X POST "$API/users/inter/tokens" \
-d '{"name":"inter","scopes":["read:repository","write:issue"]}' | jq -r '.sha1') -d '{"name":"inter","scopes":["read:repository","write:issue"]}' | jq -r '.sha1')
# 3) store the value in BOTH places (see "Secret storage" below) — never print $tok # 3) store the value in BOTH places (see "Secret storage" below) — never print $tok
curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" -H "Content-Type: application/json" \ curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')" "$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')"
``` ```
Token **scopes** are groups of `read:`/`write:` on: `repository`, `issue`, `organization`, `user`, Token **scopes** are groups of `read:`/`write:` on: `repository`, `issue`, `organization`, `user`,
@@ -84,23 +82,23 @@ then overwrite the stored secret.
``` ```
curl -sS -u "inter:$PW" -X DELETE "$API/users/inter/tokens/<name-or-id>" # needs the password again curl -sS -u "inter:$PW" -X DELETE "$API/users/inter/tokens/<name-or-id>" # needs the password again
tok=$(curl -sS -u "inter:$PW" -X POST "$API/users/inter/tokens" -d '{"name":"inter","scopes":[…new…]}' | jq -r '.sha1') tok=$(curl -sS -u "inter:$PW" -X POST "$API/users/inter/tokens" -d '{"name":"inter","scopes":[…new…]}' | jq -r '.sha1')
curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')" curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')"
``` ```
(If you no longer hold the user's password, reset it first via `PATCH /admin/users/{username}` with a (If you no longer hold the user's password, reset it first via `PATCH /admin/users/{username}` with a
new generated password, then re-mint.) new generated password, then re-mint.)
## Actions secrets & variables ## Actions secrets & variables
``` ```
curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$API/orgs/{org}/actions/secrets/{NAME}" -d '{"data":"<value>"}' curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$API/orgs/{org}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$API/repos/{owner}/{repo}/actions/secrets/{NAME}" -d '{"data":"<value>"}' curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$API/repos/{owner}/{repo}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$API/user/actions/secrets/{NAME}" -d '{"data":"<value>"}' # user-level curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$API/user/actions/secrets/{NAME}" -d '{"data":"<value>"}' # user-level
``` ```
## Labels (repo or org-wide). Scoped labels (name `scope/value`) are mutually exclusive if `exclusive:true`. ## Labels (repo or org-wide). Scoped labels (name `scope/value`) are mutually exclusive if `exclusive:true`.
``` ```
curl -sS -X POST -H "Authorization: token $TOKEN_OPS" "$API/repos/{owner}/{repo}/labels" \ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$API/repos/{owner}/{repo}/labels" \
-d '{"name":"status/review","color":"1d76db","description":"…","exclusive":true}' -d '{"name":"status/review","color":"1d76db","description":"…","exclusive":true}'
curl -sS -X POST -H "Authorization: token $TOKEN_OPS" "$API/orgs/{org}/labels" -d '{…}' curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$API/orgs/{org}/labels" -d '{…}'
``` ```
## Bootstrap a new repo (create + wire it up for the agents) ## Bootstrap a new repo (create + wire it up for the agents)
@@ -109,9 +107,22 @@ curl -sS -X POST -H "Authorization: token $TOKEN_OPS" "$API/orgs/{org}/labels" -
3. Commit the standard caller so it gets the agents — `PUT /repos/{owner}/{repo}/contents/.gitea/workflows/ai-agent.yml` 3. Commit the standard caller so it gets the agents — `PUT /repos/{owner}/{repo}/contents/.gitea/workflows/ai-agent.yml`
with base64 `content`, `message`, `branch:"main"` (copy the exact caller from the `agents` repo README). with base64 `content`, `message`, `branch:"main"` (copy the exact caller from the `agents` repo README).
4. Add the agent bot users as collaborators: `PUT /repos/{owner}/{repo}/collaborators/{username}` (`{"permission":"write"}`). 4. Add the agent bot users as collaborators: `PUT /repos/{owner}/{repo}/collaborators/{username}` (`{"permission":"write"}`).
5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_API_KEY, TOKEN_OPS, 5. Ensure the repo can run agents — the org must hold the runtime secrets (XAI_API_KEY, SELF_TOKEN,
TOKEN_* , OLLAMA_URL, OLLAMA_CLOUD_API_KEY); set any missing via the secrets calls above. TOKEN_* , OLLAMA_URL, OLLAMA_CLOUD_API_KEY); set any missing via the secrets calls above.
## Packages / container registry
Container images pushed by CI land in the **owner's** package namespace (e.g. `ffaerber/-/packages`)
and are NOT automatically shown on the repo's Packages page — link once after the first push:
```
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$API/packages/{owner}/container/{name}/-/link/{repo}"
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$API/packages/{owner}/container/{name}/-/unlink"
```
Registry auth facts (for wiring CI): the internal Actions token (`GITHUB_TOKEN`) is REJECTED by the
container registry — a real PAT is required. A **user**-namespace package is writable only by that
user or a site admin, so CI pushing to `<user>/<image>` needs a PAT minted BY that user with scope
`write:package` only (stored as a repo/user secret, e.g. `REGISTRY_TOKEN`). Your own token carries
`write:package`, so you can link/unlink and (if ever needed) push to any namespace.
## Admin user management ## Admin user management
- Create: `POST /admin/users`. Edit: `PATCH /admin/users/{username}`. Delete: `DELETE /admin/users/{username}` (**confirm first**). - Create: `POST /admin/users`. Edit: `PATCH /admin/users/{username}`. Delete: `DELETE /admin/users/{username}` (**confirm first**).
- List: `GET /admin/users`. - List: `GET /admin/users`.
+19 -25
View File
@@ -1,19 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos). # Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos).
# Mirrors the node1-ssh pattern: emit an opencode Skill file under # Emits an opencode Skill file under ~/.config/opencode/skills/. The credential is SELF_TOKEN — the
# ~/.config/opencode/skills/ so any dev agent discovers the capability via OpenCode's # RUNNING agent's OWN token (e.g. TOKEN_PM for @pm), present in the Run-agent step's env. So each
# skill registry. The credential is the shared AGENT_TOKEN (a PAT whose scopes the # agent talks to Gitea as itself, with its own scopes. This step only writes the doc, so it always
# maintainer set at creation time — issue/repository/organization/misc read+write, cross-repo). # emits; permission.skill decides which agents may actually load it.
# Only emitted when AGENT_TOKEN is actually present, so repos without it don't get a
# broken skill. The token is passed via env and never inlined into shell.
# #
# Required env (provided by the workflow step): AGENT_TOKEN # Required env (provided by the workflow step): (none — the token is in the Run-agent step)
set -eu set -eu
if [ -z "$AGENT_TOKEN" ]; then
echo "AGENT_TOKEN not set — skipping gitea-api skill"
exit 0
fi
mkdir -p ~/.config/opencode/skills/gitea-api && chmod 700 ~/.config/opencode/skills/gitea-api mkdir -p ~/.config/opencode/skills/gitea-api && chmod 700 ~/.config/opencode/skills/gitea-api
cat > ~/.config/opencode/skills/gitea-api/SKILL.md <<'SKILLET' cat > ~/.config/opencode/skills/gitea-api/SKILL.md <<'SKILLET'
--- ---
@@ -33,14 +27,14 @@ Use this skill to talk to the **Gitea REST API** (`${GITHUB_SERVER_URL}/api/v1`)
## How it works ## How it works
Calls go via `curl` with the header `Authorization: token ${AGENT_TOKEN}`. Both Calls go via `curl` with the header `Authorization: token ${SELF_TOKEN}`. Both
`${GITHUB_SERVER_URL}` (the instance root, e.g. `https://git.example.com`) and `${GITHUB_SERVER_URL}` (the instance root, e.g. `https://git.example.com`) and
`${AGENT_TOKEN}` are present in your environment. The API root is `${SELF_TOKEN}` are present in your environment. The API root is
`${GITHUB_SERVER_URL}/api/v1`. `${GITHUB_SERVER_URL}/api/v1`.
## What you're actually allowed to do — the token's scopes are the source of truth ## What you're actually allowed to do — the token's scopes are the source of truth
The shared `AGENT_TOKEN` was granted **read and write** on the `issue`, The shared `SELF_TOKEN` was granted **read and write** on the `issue`,
`repository`, `organization`, and `misc` scope groups, **cross-repo** (any repo the `repository`, `organization`, and `misc` scope groups, **cross-repo** (any repo the
token's account can see). That covers: token's account can see). That covers:
- issues, PRs, comments, labels, milestones, reviewers (read + write) - issues, PRs, comments, labels, milestones, reviewers (read + write)
@@ -64,9 +58,9 @@ in `agent.yml` exists to enforce.
## Never echo the token ## Never echo the token
**Never print, log, or exfiltrate `AGENT_TOKEN`.** Do not pass it to `echo`, do not **Never print, log, or exfiltrate `SELF_TOKEN`.** Do not pass it to `echo`, do not
include it in a comment, do not write it to a file. If you need to show a curl command, include it in a comment, do not write it to a file. If you need to show a curl command,
redact the header as `Authorization: token $AGENT_TOKEN`. redact the header as `Authorization: token $SELF_TOKEN`.
## Examples ## Examples
@@ -77,9 +71,9 @@ All examples assume `API="${GITHUB_SERVER_URL}/api/v1"`.
```bash ```bash
API="${GITHUB_SERVER_URL}/api/v1" API="${GITHUB_SERVER_URL}/api/v1"
# Get issue/PR #12 on repo owner/repo (a PR if the number is a pull; issues/PRs share one number space) # Get issue/PR #12 on repo owner/repo (a PR if the number is a pull; issues/PRs share one number space)
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/repos/owner/repo/issues/12" | jq '{title,state,body,user:.user.login}' curl -sS -H "Authorization: token $SELF_TOKEN" "$API/repos/owner/repo/issues/12" | jq '{title,state,body,user:.user.login}'
# Its comment thread # Its comment thread
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/repos/owner/repo/issues/12/comments?limit=100" \ curl -sS -H "Authorization: token $SELF_TOKEN" "$API/repos/owner/repo/issues/12/comments?limit=100" \
| jq -r '.[] | "### @\(.user.login):\n\(.body)\n"' | jq -r '.[] | "### @\(.user.login):\n\(.body)\n"'
``` ```
@@ -91,27 +85,27 @@ find the owner/repo for a `#N` in *this* repo, just use `${GITHUB_REPOSITORY}`.
```bash ```bash
API="${GITHUB_SERVER_URL}/api/v1" API="${GITHUB_SERVER_URL}/api/v1"
# Recent runs on a repo # Recent runs on a repo
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/repos/owner/repo/actions/runs?limit=10" | jq '.[] | {id,status,conclusion,head_branch,event}' curl -sS -H "Authorization: token $SELF_TOKEN" "$API/repos/owner/repo/actions/runs?limit=10" | jq '.[] | {id,status,conclusion,head_branch,event}'
# Jobs for a run # Jobs for a run
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/repos/owner/repo/actions/runs/$RUN_ID/jobs" | jq '.[] | {name,status,conclusion}' curl -sS -H "Authorization: token $SELF_TOKEN" "$API/repos/owner/repo/actions/runs/$RUN_ID/jobs" | jq '.[] | {name,status,conclusion}'
# Logs for a job (returns a text/plain stream) # Logs for a job (returns a text/plain stream)
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/repos/owner/repo/actions/jobs/$JOB_ID/logs" curl -sS -H "Authorization: token $SELF_TOKEN" "$API/repos/owner/repo/actions/jobs/$JOB_ID/logs"
``` ```
### List repos across an org ### List repos across an org
```bash ```bash
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/orgs/$ORG/repos?limit=50" | jq '.[] | .full_name' curl -sS -H "Authorization: token $SELF_TOKEN" "$API/orgs/$ORG/repos?limit=50" | jq '.[] | .full_name'
``` ```
### Write: comment / label / close on another repo's issue (only when your task requires it) ### Write: comment / label / close on another repo's issue (only when your task requires it)
```bash ```bash
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$API/repos/owner/repo/issues/12/comments" -d '{"body":"related to #N"}' "$API/repos/owner/repo/issues/12/comments" -d '{"body":"related to #N"}'
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$API/repos/owner/repo/issues/12/labels" -d '{"labels":["related"]}' "$API/repos/owner/repo/issues/12/labels" -d '{"labels":["related"]}'
curl -sS -X PATCH -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X PATCH -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$API/repos/owner/repo/issues/12" -d '{"state":"closed"}' "$API/repos/owner/repo/issues/12" -d '{"state":"closed"}'
``` ```
@@ -1,83 +0,0 @@
#!/usr/bin/env bash
# Set up read-only SSH alias `node1` (+ opencode skill so the agent actually knows about it).
# 1) Writes the deploy key + an SSH config alias so the agent can run
# `ssh node1 <read-only cmd>` (matches the homelab opencode.json allowlist).
# 2) Emits a `node1-ssh` opencode Skill file under ~/.config/opencode/skills/ so any
# downstream repo's dev agent discovers this capability via OpenCode's skill registry
# rather than having to trial against the permission allowlist. Only emitted when the
# swarm plumbing is actually wired for that caller (SWARM_HOST/SWARM_USER/SSH_PRIV_KEY).
# All three secrets are passed via env and never inlined into shell — this shared workflow
# runs in repos that don't have them and must not fail there.
#
# Required env (provided by the workflow step): SWARM_HOST SWARM_USER SSH_PRIV_KEY
set -eu
if [ -z "$SWARM_HOST" ] || [ -z "$SWARM_USER" ] || [ -z "$SSH_PRIV_KEY" ]; then
echo "swarm secrets not set in this repo — skipping node1 SSH alias + skill"
exit 0
fi
mkdir -p ~/.ssh ~/.config/opencode/skills/node1-ssh && chmod 700 ~/.ssh ~/.config/opencode/skills/node1-ssh
# Write the private key with 600 perms; never echo its contents.
printf '%s\n' "$SSH_PRIV_KEY" > ~/.ssh/agent_node1
chmod 600 ~/.ssh/agent_node1
# SSH config alias `node1` — last-match-wins in the homelab opencode allowlist
# (`deny ssh *` + specific `allow ssh node1 …`), so the alias name is fixed.
cat > ~/.ssh/config <<EOF
Host node1
HostName $SWARM_HOST
User $SWARM_USER
IdentityFile ~/.ssh/agent_node1
IdentitiesOnly yes
StrictHostKeyChecking accept-new
ConnectTimeout 10
EOF
chmod 600 ~/.ssh/config
echo "node1 SSH alias configured (host=$SWARM_HOST user=$SWARM_USER)"
# Emit a reusable opencode Skill that surfaces the capability to downstream agents.
# OpenCode's skill tool registers it via the <available_skills> block, so any dev agent
# can discover "I am allowed to ssh node1" without trial-and-error against the allowlist.
cat > ~/.config/opencode/skills/node1-ssh/SKILL.md <<'SKILLET'
---
name: node1-ssh
description: Read-only diagnostics on the swarm host via `ssh node1 …` — use when debugging a deploy or checking a running service.
domains: [swarm]
tags: [ssh, swarm, diagnostics, docker]
---
# `node1-ssh` Skill
Use this skill to run **read-only** commands against **node1** (the Docker Swarm host) when:
- A deploy failed and you need to inspect running services.
- You need to see a service's logs for debugging.
- You want to check the state of the stack on the swarm.
## How it works
Commands run via `ssh node1 <cmd>`. The SSH alias is configured in `${HOME}/.ssh/config`
during this workflow (only when swarm secrets are configured for the caller repo).
## What you're actually allowed to run — the allowlist is the source of truth
This skill does **not** define which commands are permitted, and you must not assume a fixed
list here. The single source of truth for exactly which `ssh node1 …` commands are allowed is
the **caller repo's own OpenCode permission config** (e.g. `opencode.json` in the homelab repo:
a `deny "ssh *"` with specific `allow "ssh node1 …"` entries, last-match-wins).
- Only read-only diagnostics are permitted; any write/mutating command on node1 is denied.
- The permission layer enforces this — if a command is not on the caller's allowlist it will be
blocked, regardless of what this skill or any other allowlist says.
- So: reach for `ssh node1 …` for read-only diagnostics, and treat the caller's `opencode.json`
`ssh node1` allow-entries as the authoritative list of what will actually run.
## Example
> The frontend returned a 5xx after a deploy.
>
> Action (a read-only log inspection, subject to the caller's allowlist):
> ```
> ssh node1 "docker service logs --tail 100 --timestamps homelab_frontend"
> ```
SKILLET
chmod -R o=rX ~/.config/opencode/skills/node1-ssh
echo "opencode skill node1-ssh installed ($(wc -l < ~/.config/opencode/skills/node1-ssh/SKILL.md) lines)"
+9
View File
@@ -0,0 +1,9 @@
# LEARNINGS — distilled from retros
Rules for the team. Each line: `symptom -> rule for next time`. Keep concrete and checkable.
- New agent added without the `agent.yml` trigger gate, breaking all `@intern` comments until round 3 -> adding an agent means editing BOTH the trusted-author list and the mention list in `agent.yml` (lines ~28 and ~37) in the same commit; @qa grep the gate for the new name.
- Two of the 8 files an agent touches were missed on the first PR -> when adding an agent, touch all of `agents.json`, `install-opencode.sh`, `route.sh`, `agent.yml`, `publish.sh`, `rescue-pr.sh`, `run-agent.sh`, `README.md`; @qa diff-stat the PR and confirm the name appears in each.
- Stray leading-space edits to `run-agent.sh` prompt heredoc bounced 2 review rounds -> only edit the exact token (the agent name) inside prompt heredocs, never re-indent surrounding lines; verify with `cat -A` against `main` before pushing.
- @qa quoted the `@${dev} ... (fix attempt $n/3)` trigger string from the diff, inflating the bounce counter 1/3 -> 3/3 -> @qa paraphrase the fix-attempt line, never reproduce it verbatim; the publish.sh template+regex must stay pinned together.
- @qa found whitespace and the gate miss in separate rounds, hitting the 3-round cap -> on a BOUNCE, list ALL problems (every file/line) in one round; the 3-round cap is hard.
+55 -12
View File
@@ -7,15 +7,52 @@ Shared **AI dev-team** workflow for Gitea Actions, reusable across repos. It giv
| Agent | Model | Vision | Mode | Skills | Role | | Agent | Model | Vision | Mode | Skills | Role |
|-------|-------|:------:|------|--------|------| |-------|-------|:------:|------|--------|------|
| `@pm` | `ollama-cloud/gemma4:cloud` | yes | comment | `gitea-api` | Product manager — research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files. | | `@pm` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | Product manager & orchestrator — plans, picks the dev, hands finished PRs to `@qa`, reports back to the issue creator (autopilot: merges approved PRs itself). Issue thread only; never edits files, never reads the PR diff. |
| `@junior` | `ollama-cloud/kimi-k2.7-code:cloud` | no | pr | — | Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to `@senior` or `@lead`. | | `@junior` | `ollama-cloud/kimi-k2.7-code:cloud` | no | pr | — | Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to `@senior` or `@lead`. |
| `@senior` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api`, `node1-ssh` | Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). | | `@senior` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api` | Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). |
| `@lead` | `anthropic/claude-opus-4-8` | yes | pr | `gitea-api`, `node1-ssh` | Tech lead — the hardest problems, architecture, and final calls. | | `@lead` | `xai-oc/grok-4.5` | yes | pr | `gitea-api` | Tech lead — the hardest problems, architecture, and final calls. |
| `@qa` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | QA — 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. | | `@qa` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | QA / reviewer — reads the PR diff, drives a headless browser (Playwright) to verify behavior; recommendations on the PR, pass/fail verdict on the issue. Never edits code, never merges. |
| `@ops` | `anthropic/claude-opus-4-8` | no | comment | `gitea-admin` | Gitea operator — administers the instance itself (create orgs/users/repos, labels, secrets, scoped per-user tokens, bootstrap repos). Comments only; never edits code. Confirms before destructive actions. | | `@ops` | `xai-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. |
| `@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`. |
`agent.yml`'s agent registry is the source of truth for this mapping — if you change a model The registry `.gitea/workflows/scripts/agents.json` is the source of truth for this mapping — if you
or an agent's skills there, update this table too. 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).
### 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
@@ -74,13 +111,19 @@ points `$SCRIPTS` at it. Keep the workflow and its scripts moving together on `m
| Secret | For | | Secret | For |
|--------|-----| |--------|-----|
| `ANTHROPIC_API_KEY` | `@lead` (and `@pm`/`@senior`/`@qa` if on Claude) | | `XAI_API_KEY` | `@lead`, `@ops` (and any other agent switched to a `xai-oc/…` model) |
| `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, glm-5.2, minimax-m3) | | `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, glm-5.2, minimax-m3) |
| `AGENT_TOKEN` | PAT (issue/repository/organization/misc read+write, cross-repo) — posts the delegation comment that fires the next agent **and** powers the `gitea-api` skill (read/write issues, PRs, comments, labels, and Actions runs/logs across any repo). Do not re-narrow its scopes without also removing the `gitea-api` skill. | | `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` | optional — post/commit as each agent's own Gitea user (falls back to the bot) | | `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`. |
`GITEA_TOKEN` is auto-provided. Tip: set these once at the **org** level so every repo inherits Each agent authenticates as **itself**: the Run-agent step selects that agent's `TOKEN_*` into
them via `secrets: inherit`. `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
**org / user** level so every repo inherits them via `secrets: inherit`.
## Also add to each consuming repo ## Also add to each consuming repo