Compare commits

..
Author SHA1 Message Date
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
Felix FaerberandClaude Opus 4.8 79ea9f68c9 feat(ops): @ops uses its dedicated TOKEN_OPS, not AGENT_TOKEN
The gitea-admin skill now authenticates with TOKEN_OPS (the ops user's admin token), and TOKEN_OPS
is injected into the agent process ONLY when name==ops. No other agent holds an admin credential in
its env. This unhooks @ops from AGENT_TOKEN, so AGENT_TOKEN can be narrowed to a non-admin token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:52:07 +03:00
Felix FaerberandClaude Opus 4.8 6c753dc0a0 chore: repo moved to gitea/agents — update self-references
Repo transferred ffaerber/agents -> gitea/agents. Point the scripts checkout (agent.yml
repository:), the standard caller (uses:), and the README at gitea/agents. The trigger gate still
keys on the maintainer 'ffaerber' (unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:43:34 +03:00
ffaerber 23c48e66c4 Merge pull request 'ops: .env is the token source of truth (not tokens.md)' (#57) from fix/ops-env-source-of-truth into main 2026-07-05 14:01:13 +02:00
Felix Faerber 6618de9c9f ops: .env is the token source of truth (not tokens.md)
Every token value must be written to gitea/secrets/.env (the master, mirrored to org Actions secrets)
AND the Actions secret, kept in sync, removed from both on delete. Drop the tokens.md instruction —
the @ops test dutifully re-created tokens.md because the skill still referenced it.
2026-07-05 15:01:10 +03:00
ffaerber 82c4b07fea Merge pull request 'feat(ops): make @ops routable + TOKEN_OPS identity' (#56) from feat/ops-routing into main 2026-07-05 13:53:11 +02:00
17 changed files with 932 additions and 434 deletions
+112 -53
View File
@@ -1,37 +1,53 @@
name: agent
# Reusable AI-agent workflow, shared across repos. A caller repo triggers on issue_comment/issues
# and invokes this via: uses: ffaerber/agents/.gitea/workflows/agent.yml@main (secrets: inherit).
# and invokes this via: uses: gitea/agents/.gitea/workflows/agent.yml@main (secrets: inherit).
# The gate + steps run in the caller's event context (github.event.* / github.repository are the caller's).
on:
workflow_call:
# Pinned opencode version — used to install it and to key the CI cache below.
env:
OPENCODE_VERSION: "1.17.13"
jobs:
agent:
# One run at a time PER ISSUE: two quick comments on the same issue would otherwise race —
# both checking out ai/issue-N, pushing (non-fast-forward loss) and double-posting. Queued
# runs wait (no cancel) so every trigger is still processed, just serially.
# 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
# defense against malicious-issue prompt injection — do not loosen it.
if: >
(github.event.comment == null && github.event.issue.user.login == 'ffaerber') ||
(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 == '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, '@pm') ||
contains(github.event.comment.body, '@junior') ||
contains(github.event.comment.body, '@senior') ||
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
# 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:
- 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
with:
fetch-depth: 0
@@ -54,7 +70,7 @@ jobs:
- name: Fetch shared agent scripts (this repo)
uses: actions/checkout@v4
with:
repository: ffaerber/agents
repository: gitea/agents
ref: main
path: .agents-workflow
token: ${{ secrets.GITEA_TOKEN }}
@@ -93,9 +109,42 @@ jobs:
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
TOKEN_QA: ${{ secrets.TOKEN_QA }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }}
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)
if: steps.prep.outputs.mode != 'skip'
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
OLLAMA_URL: ${{ secrets.OLLAMA_URL }}
@@ -104,46 +153,43 @@ jobs:
SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load
run: bash "$SCRIPTS/install-opencode.sh"
- name: 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.
- name: Install caller-provided skills (from the caller repo's .gitea/agent-skills/)
if: steps.prep.outputs.mode != 'skip'
# Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under
# `.gitea/agent-skills/<name>/` (SKILL.md + skill.json + optional setup.sh) — e.g. homelab's
# "ssh into the deploy host" skill. This installs the ones allowed for the running agent, so
# deploy-target / infra specifics live in the repo they belong to, not in this framework.
# SECRETS_JSON = toJSON(secrets): a caller's setup.sh reads the repo-specific secrets it needs
# (whose names this framework can't know) via jq; it never touches disk here in the clear.
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
SWARM_HOST: ${{ secrets.SWARM_HOST }}
SWARM_USER: ${{ secrets.SWARM_USER }}
SSH_PRIV_KEY: ${{ secrets.SSH_PRIV_KEY }}
run: bash "$SCRIPTS/skill-node1-ssh.sh"
NAME: ${{ steps.prep.outputs.name }}
WORKSPACE: ${{ github.workspace }}
SECRETS_JSON: ${{ toJSON(secrets) }}
run: bash "$SCRIPTS/install-caller-skills.sh"
- 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
# ~/.config/opencode/skills/ so any dev agent discovers the capability via OpenCode's
# skill registry. The credential is the shared AGENT_TOKEN (a PAT whose scopes the
# maintainer set at creation time — issue/repository/organization/misc read+write, cross-repo).
# 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.
if: steps.prep.outputs.mode != 'skip'
# Emits an opencode Skill file. The skill uses SELF_TOKEN — the running agent's OWN token
# (e.g. TOKEN_PM for @pm), injected into the Run-agent step below — so each agent talks to
# Gitea as itself. This step only writes the doc; permission.skill scopes who may load it.
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
run: bash "$SCRIPTS/skill-gitea-api.sh"
- 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
# written ONLY for @ops (skill-gitea-admin.sh gates on NAME), so the admin how-to never
# reaches other agents; permission.skill also denies it to everyone but @ops. Uses
# AGENT_TOKEN (an admin PAT during bootstrap) — see the script header for the token plan.
# written ONLY for @ops (skill-gitea-admin.sh gates on NAME) and permission.skill also denies
# it to every other agent. It uses SELF_TOKEN (which for @ops is TOKEN_OPS), injected into the
# Run-agent step. This step only writes the doc.
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
NAME: ${{ steps.prep.outputs.name }}
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
run: bash "$SCRIPTS/skill-gitea-admin.sh"
- name: Inspect / fetch image attachments (download only for vision agents)
if: steps.prep.outputs.mode != 'skip'
id: imgs
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
@@ -153,6 +199,7 @@ jobs:
run: bash "$SCRIPTS/fetch-images.sh"
- name: Fetch the full issue thread (shared memory)
if: steps.prep.outputs.mode != 'skip'
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }}
@@ -160,18 +207,22 @@ jobs:
run: bash "$SCRIPTS/fetch-thread.sh"
- name: Run agent
if: steps.prep.outputs.mode != 'skip'
id: run
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# AGENT_TOKEN powers the `gitea-api` skill (cross-repo issue/PR/Actions read+write).
# It is already a required secret for the delegation step below; exposing it here too
# lets the agent process itself call the Gitea API on demand.
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
# SELF_TOKEN = the RUNNING agent's OWN token (TOKEN_PM for @pm, TOKEN_OPS for @ops, …).
# Only this agent's token is placed in its process env, so no agent can act as another.
# Powers the gitea-api / gitea-admin skills — each agent calls Gitea as itself. Every
# consuming repo now carries the per-agent TOKEN_* secrets (org-level for gitea/*, user-level
# for ffaerber/*), so there is no shared-token fallback.
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
NAME: ${{ steps.prep.outputs.name }}
MODEL: ${{ steps.prep.outputs.model }}
VISION: ${{ steps.prep.outputs.vision }}
MODE: ${{ steps.prep.outputs.mode }}
WORKMODE: ${{ steps.prep.outputs.workmode }} # build | discuss (devs consulted in-thread)
HAS_IMAGES: ${{ steps.imgs.outputs.has_images }}
BRANCH: ${{ steps.prep.outputs.branch }}
AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the issue carries the `autopilot` label
@@ -182,26 +233,30 @@ jobs:
FILES: ${{ steps.imgs.outputs.files }} # opencode -f image flags (vision agents only)
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
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
MODE: ${{ steps.prep.outputs.mode }}
MODEL: ${{ steps.prep.outputs.model }} # ollama-cloud models are subscription-billed (no $/token)
run: bash "$SCRIPTS/build-activity-log.sh"
- name: Publish — PR (dev agents) or comment (pm), always reply in the issue
if: steps.prep.outputs.mode != 'skip'
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }}
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
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 }}
NAME: ${{ steps.prep.outputs.name }}
MODE: ${{ steps.prep.outputs.mode }}
WORKMODE: ${{ steps.prep.outputs.workmode }}
NUM: ${{ github.event.issue.number }}
TITLE: ${{ github.event.issue.title }}
BRANCH: ${{ steps.prep.outputs.branch }}
@@ -216,7 +271,7 @@ jobs:
# 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.
- name: Rescue — open a PR for pushed work if the run failed
if: failure()
if: failure() && steps.prep.outputs.mode != 'skip'
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }}
@@ -226,6 +281,7 @@ jobs:
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
TOKEN_QA: ${{ secrets.TOKEN_QA }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }}
NAME: ${{ steps.prep.outputs.name }}
MODE: ${{ steps.prep.outputs.mode }}
NUM: ${{ github.event.issue.number }}
@@ -234,24 +290,27 @@ jobs:
run: bash "$SCRIPTS/rescue-pr.sh" || true
- name: Mark done with 🚀 (remove 👀)
if: steps.prep.outputs.mode != 'skip'
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 }}
NUM: ${{ github.event.issue.number }}
run: |
[ -n "$SELF_TOKEN" ] || 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 DELETE -H "Authorization: token $GT" -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 DELETE -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"rocket"}' -w '\nreact -> HTTP %{http_code}\n' || true
- name: Mark failed with 😕 (remove 👀)
if: failure()
if: failure() && steps.prep.outputs.mode != 'skip'
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 }}
NUM: ${{ github.event.issue.number }}
run: |
[ -n "$SELF_TOKEN" ] || 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 DELETE -H "Authorization: token $GT" -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 DELETE -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true
+2 -2
View File
@@ -1,6 +1,6 @@
name: ai-agent
run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment
# Standard caller for the shared AI-agent workflow (ffaerber/agents). Copy this file VERBATIM into
# Standard caller for the shared AI-agent workflow (gitea/agents). Copy this file VERBATIM into
# any repo that should get the agents — it is identical in every repo. All logic + scripts live in
# agents/.gitea/workflows/; scripts are fetched from @main at run time. The `jobs.agent` wrapper is
# required: a reusable (workflow_call) workflow can only be invoked from a caller job, not top-level.
@@ -11,5 +11,5 @@ on:
types: [opened]
jobs:
agent:
uses: ffaerber/agents/.gitea/workflows/agent.yml@main
uses: gitea/agents/.gitea/workflows/agent.yml@main
secrets: inherit
+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"
+59 -6
View File
@@ -1,8 +1,61 @@
{
"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."},
"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."},
"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)."},
"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."},
"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."},
"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)."}
"pm": {
"model": "ollama-cloud/minimax-m3:cloud",
"vision": true,
"mode": "comment",
"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."
},
"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."
},
"senior": {
"model": "ollama-cloud/glm-5.2:cloud",
"vision": false,
"mode": "pr",
"skills": [
"gitea-api"
],
"desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."
},
"lead": {
"model": "anthropic/claude-opus-4-8",
"vision": true,
"mode": "pr",
"skills": [
"gitea-api"
],
"desc": "Tech lead — the hardest problems, architecture, and final calls."
},
"qa": {
"model": "ollama-cloud/minimax-m3:cloud",
"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."
},
"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)."
},
"intern": {
"model": "ollama/ornith:35b",
"vision": false,
"mode": "pr",
"skills": [],
"desc": "Intern — very basic tasks only, routed to the local Ollama model (ornith:35b). Text-only, cannot read images. Escalates anything non-trivial to @junior, @senior or @lead."
}
}
+47 -14
View File
@@ -1,24 +1,57 @@
#!/usr/bin/env bash
# Build the activity log — the list of TOOL CALLS the agent made — into /tmp/activity_log.md.
# Only dev agents (mode=pr) get an activity-log comment — comment-only roles (pm/qa) do no tool calls.
# NOTE: we deliberately DO NOT include the agent's prose text parts. That final "here's what I did"
# 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.
# Build the RUN REPORT appended to the agent's reply comment: the TOOL CALLS the agent made plus a
# usage line (input / output tokens + $ cost). Written to /tmp/activity_log.md; the Publish step
# appends it to the agent's reply. Applies to EVERY agent — @pm/@qa also call tools and cost money.
#
# 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
E=/tmp/events.jsonl
: > /tmp/activity_log.md
[ -s "$E" ] || { echo "no events — empty report"; exit 0; }
if [ "$MODE" != "pr" ]; then
echo "skipping activity log for comment-mode agent"; : > /tmp/activity_log.md; exit 0
fi
# Tool calls = the ACTIONS taken (not the agent's prose text parts).
jq -r '
def trunc(n): if length > n then (.[0:n] + "…") else . end;
select(.type=="tool_use") |
(.part.tool // "?") as $t |
((.part.state.title // (.part.state.input | tojson | trunc(160)) // "")) as $title |
"🔧 **" + $t + "**: `" + ($title | trunc(240)) + "`"
' /tmp/events.jsonl > /tmp/activity_log.md 2>/dev/null || true
n=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0)
echo "activity log: $n tool calls"
[ "$n" -eq 0 ] && : > /tmp/activity_log.md
head -3 /tmp/activity_log.md
' "$E" > /tmp/tools.md 2>/dev/null || true
n=$(wc -l < /tmp/tools.md 2>/dev/null || echo 0); n=${n:-0}
# Usage: sum per-step tokens + cost across every step_finish event (tab-separated for `read`).
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 (anthropic/…) get the real dollar cost opencode computed.
case "${MODEL:-}" in
ollama*|*"/ollama"*) COSTF="subscription" ;;
*) COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}') ;;
esac
echo "usage: in=$IN_TOTAL out=$OUT cost=$COSTF (fresh=$INP cache_r=$CR cache_w=$CW reasoning=$RE); tools=$n"
# 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
# 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
set -eu
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=100" 2>/dev/null \
| jq -r '.[] |
( if (.body | test("delegated by")) then "an automated delegation"
elif (.user.login == "ffaerber") then "ffaerber (the maintainer / you)"
else "an AI teammate — the specific one is named in the 🤖 @name line at the top of the comment"
end ) as $who |
"### comment by \($who):\n\(.body)\n"' > /tmp/thread.md 2>/dev/null || true
echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0)"
: > /tmp/thread_pages.json
for page in $(seq 1 10); do
pg=$(curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=50&page=$page" 2>/dev/null) || pg='[]'
n=$(printf '%s' "$pg" | jq 'if type=="array" then length else 0 end' 2>/dev/null || echo 0)
[ "${n:-0}" -gt 0 ] && printf '%s\n' "$pg" >> /tmp/thread_pages.json
[ "${n:-0}" -lt 50 ] && break
done
jq -rs '
add // [] | .[-100:] | .[] |
( if (.user.login == "ffaerber") then "@ffaerber (the maintainer)"
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
+20 -7
View File
@@ -5,7 +5,18 @@
# GITHUB_PATH HOME
set -eu
curl -fsSL https://opencode.ai/install | bash
# PIN the opencode version: an unpinned `latest` means a breaking release (CLI flags, or the
# --format json event schema that build-activity-log.sh parses) breaks every agent in every repo
# at once. Bump deliberately by changing this default (or set OPENCODE_VERSION in the step env).
OPENCODE_VERSION="${OPENCODE_VERSION:-1.17.13}"
# 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"
mkdir -p ~/.config/opencode
# Playwright browser MCP only for agents that need to drive a web app
@@ -28,15 +39,17 @@ SKILLS="${SKILLS:-[]}"
PERM=$(jq -nc --argjson s "$SKILLS" '
{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).
# The ollama-cloud `models:` map is DERIVED from agents.json (the single source of truth, shared with
# route.sh) so every model an agent is routed to is always declared in the provider config. Only the
# `ollama-cloud/` provider prefix models participate — e.g. `anthropic/claude-opus-4-8` (@lead) is a
# built-in provider and `ornith:35b` is local-only, neither belongs here. See issue #31.
# The provider `models:` maps are DERIVED from agents.json (the single source of truth, shared with
# route.sh) so every model an agent is routed to is always declared in the provider config.
# `ollama-cloud/` prefix models go to the cloud provider; `ollama/` prefix models go to the local
# provider. Built-in providers (e.g. `anthropic/claude-opus-4-8` for @lead) are not derived here.
# See issue #31.
AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json"
CLOUD_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama-cloud/")) | sub("^ollama-cloud/";"")] | map({(.):{}}) | add // {}' "$AGENTS_JSON")
jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --argjson cloud "$CLOUD_MODELS" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" '{
LOCAL_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama/")) | sub("^ollama/";"")] | map({(.):{}}) | add // {"ornith:35b":{}}' "$AGENTS_JSON")
jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --argjson cloud "$CLOUD_MODELS" --argjson local "$LOCAL_MODELS" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" '{
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}
},
permission: $perm,
+231 -126
View File
@@ -2,22 +2,56 @@
# Publish — PR (dev agents) or comment (pm/qa), always reply in the issue.
#
# 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
# 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
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
case "$NAME" in
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";;
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; intern) TOK="$TOKEN_INTERN";; *) TOK="";;
esac
[ -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.email "$NAME@ffaerber.duckdns.org"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
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[@]}" \
"$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
# endpoint is by ID, not name). Arg $1 = issue number. Used as the autopilot kill switch.
del_autopilot_label() {
@@ -32,39 +66,92 @@ del_autopilot_label() {
fi
}
# drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / HALT_AUTOPILOT, and the
# BEGIN_SUBTASKS..END_SUBTASKS and BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR
# drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / RETRO / APPROVE / HALT / BOUNCE,
# and the BEGIN_SUBTASKS..END_SUBTASKS and BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR
# description is published separately).
reply=$(awk '
/^[[:space:]]*BEGIN_SUBTASKS/{s=1}
/^[[:space:]]*BEGIN_PR_DESCRIPTION/{p=1}
/^[[:space:]]*DELEGATE:[[:space:]]*@/{next}
/^[[:space:]]*ASK:[[:space:]]*@/{next}
/^[[:space:]]*CLOSE_ISSUE[[: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}
s{ if(/^[[:space:]]*END_SUBTASKS/){s=0}; next }
p{ if(/^[[:space:]]*END_PR_DESCRIPTION/){p=0}; next }
{print}
' /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
# double it when we prepend our own. Removes a leading run of such header lines and blank lines.
reply=$(printf '%s' "$reply" | awk '
# Strip a leading self-identification header the model sometimes emits, e.g. "🤖 **@pm**",
# "🔨 **@senior**", or a heading like "## 🔨 @senior — <title>". Gitea already attributes the comment
# 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}
s && /^[^A-Za-z0-9]*\*\*@[A-Za-z]+\*\*[[:space:]]*$/ {next}
s && /^[[:space:]]*$/ {next}
s && index($0, me) {next}
s && /^[^A-Za-z0-9]*\*\*@[A-Za-z]+\*\*[[:space:]]*$/ {next}
{s=0; print}
')
[ -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.
prdesc=$(awk '/BEGIN_PR_DESCRIPTION/{f=1;next} /END_PR_DESCRIPTION/{f=0} f' /tmp/agent_out.md)
[ -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
if [ "$MODE" != "pr" ]; then
git checkout -- . 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.
msg="$reply"
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"
[ -n "$n" ] && links="$links\n- #$n$title"
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
post "$(printf '🤖 **@%s**\n\n%s%s' "$NAME" "$msg" "$subtext")"
# --- AUTOPILOT: @qa's narrow, label-gated merge / halt authority ---
# Only @qa, only when 'autopilot' is set, and only on a PR thread. The MERGE_PR / HALT_AUTOPILOT
# markers come from the QA prompt. Merge + label ops use TOKEN_QA (the QA user's PAT, which the
# maintainer must grant write+merge scope). ISSNUM is the origin issue (resolved from the branch).
if [ "$NAME" = "qa" ] && [ "$AUTOPILOT" = "true" ]; then
if grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then
if [ -z "$IS_PR" ]; then
echo "MERGE_PR marker but this run is not on a PR thread — skipping merge"
# DEDUP GUARD (issue: @pm double-posts its report). Prompt-level "do not self-post" is ignored
# by some models, so enforce it here: if the agent ALREADY posted a comment on this thread
# during the run (any comment by $NAME newer than the pre-run newest id from fetch-thread.sh),
# its self-post IS the reply — skip the duplicate framework comment. Markers (CLOSE_ISSUE,
# DELEGATE, MERGE_PR, subtasks) were already processed above and are unaffected.
# FAIL OPEN: if the pre-run marker is missing (fetch-thread hiccup), pre_cid=0 would make the
# agent's comments from PREVIOUS runs count as self-posts and wrongly suppress the reply.
# Without the marker, skip the guard and post normally.
pre_cid=$(cat /tmp/thread_max_cid 2>/dev/null || echo "")
selfposts=0
if [ -n "$pre_cid" ]; then
: > /tmp/all_comments.json
for pg in $(seq 1 10); do
cpg=$(curl -sS "${hdr[@]}" "$API/issues/$NUM/comments?limit=50&page=$pg" 2>/dev/null) || cpg='[]'
cn=$(printf '%s' "$cpg" | jq 'if type=="array" then length else 0 end' 2>/dev/null || echo 0)
[ "${cn:-0}" -gt 0 ] && printf '%s\n' "$cpg" >> /tmp/all_comments.json
[ "${cn:-0}" -lt 50 ] && break
done
selfposts=$(jq -rs --arg n "$NAME" --argjson c "${pre_cid:-0}" '[ (add // [])[] | select(.user.login==$n) | select(.id > $c) ] | length' /tmp/all_comments.json 2>/dev/null || echo 0)
fi
if [ "${selfposts:-0}" -gt 0 ]; then
echo "agent @$NAME already posted ${selfposts} comment(s) on #$NUM during this run — skipping duplicate framework reply"
else
echo "@qa autopilot: merging PR #$NUM (origin issue #${ISSNUM:-$NUM})"
# Merge with AGENT_TOKEN (a PAT) — NOT the built-in Actions token — so the resulting push to
# 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
# agent's own token only if AGENT_TOKEN isn't set (then the deploy would need a manual run).
mtok="${AGENT_TOKEN:-$TOK}"
post "$(printf '%s%s%s' "$msg" "$subtext" "$activity")"
fi
# --- @pm autopilot merge: @pm is the ONLY agent that merges, and ONLY under the autopilot label ---
# (@qa never merges — it approves and hands back here.) Merge with the PAT (TTOK), not the built-in
# token, so the push to main fires the deploy. TOKEN_PM must carry write:repository.
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 \
-H "Authorization: token $mtok" -H "Content-Type: application/json" \
"$API/pulls/$NUM/merge" -d '{"Do":"merge"}')
-H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/pulls/$PRN/merge" -d '{"Do":"merge"}')
echo "merge -> HTTP $mc"; cat /tmp/merge_resp.txt 2>/dev/null || true
case "$mc" in
200|201|204)
echo "closing origin issue #${ISSNUM:-$NUM}"
curl -sS -X PATCH "${hdr[@]}" "$API/issues/${ISSNUM:-$NUM}" \
-d '{"state":"closed"}' -w '\nclose -> HTTP %{http_code}\n' || true
post "$(printf '🤖 **@qa** — ✅ verified & merged PR #%s (autopilot). Closed issue #%s.' "$NUM" "${ISSNUM:-$NUM}")"
;;
curl -sS -X PATCH "${hdr[@]}" "$API/issues/$ISSN" -d '{"state":"closed"}' -w '\nclose -> HTTP %{http_code}\n' || true
post_to "$ISSN" "✅ Merged PR #$PRN (autopilot) and closed this issue." ;;
*)
# Merge failed (checks not green, conflicts, or TOKEN_QA lacks merge scope) — do NOT
# silently proceed: drop the label so it reverts to human control and report.
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")"
;;
del_autopilot_label "$ISSN"
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." ;;
esac
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
fi
# Auto-delegate: if the plan names a teammate, trigger them via AGENT_TOKEN (a PAT, so it
# fires a new workflow run — the built-in token cannot). Never targets @pm or self, so the
# chain always terminates at a dev. The '🤖' guard on the trigger stops status-comment loops.
if [ -n "$AGENT_TOKEN" ]; then
# Only delegate on an explicit "DELEGATE: @<agent>" line — never on a prose mention,
# so an agent that is asking the maintainer a question does not hand off prematurely.
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:]')
# --- @pm RETRO: open a retrospective issue for this thread (maintainer asked for a retro) ---
# Creates a retro issue pointing at this issue + its PR and triggers @senior on it (has gitea-api
# to read both threads). The retro produces a LEARNINGS.md PR via the NORMAL choreography (senior →
# pm → qa → merge), and run-agent.sh injects LEARNINGS.md into every future prompt — closing the loop.
if [ "$NAME" = "pm" ] && grep -qiE '^[[:space:]]*RETRO[[:space:]]*$' /tmp/agent_out.md; then
PRN=$(curl -sS "${hdr[@]}" "$API/pulls?state=all&limit=50" \
| jq -r --arg br "ai/issue-$ISSN" 'if type=="array" then ([.[]|select(.head.ref==$br or (.head.ref|startswith($br+"-")))] | sort_by(.number) | last | .number // empty) else empty end' 2>/dev/null)
rbody=$(printf 'Retrospective for issue #%s%s.\n\nRead the FULL issue thread%s using the gitea-api skill (issue comments%s and the PR diff). Identify what went wrong, slow, or needed human correction — missed wiring, review misses, bounced rounds, unclear delegation, missing context.\n\nThen APPEND the distilled learnings to `LEARNINGS.md` at the repo root (create it with a short header if missing). Rules for entries:\n- 3 to 6 bullets max, each ONE line: `symptom -> rule for next time`.\n- Concrete and checkable (name the file/step/marker), not generic advice.\n- Do not repeat an existing bullet; refine it instead.\n- Do not rewrite unrelated parts of the file.\n\nThese learnings are injected into every future agent prompt, so quality over quantity.' \
"$ISSN" "${PRN:+ / PR #$PRN}" "${PRN:+ and PR #$PRN thread}" "${PRN:+, PR comments}")
rnum=$(curl -sS -X POST "${hdr[@]}" "$API/issues" \
-d "$(jq -nc --arg t "retro: issue #$ISSN" --arg b "$rbody" '{title:$t,body:$b}')" | jq -r '.number // empty')
if [ -n "$rnum" ]; then
echo "opened retro issue #$rnum"
post_to "$ISSN" "📝 Opened retro issue #$rnum."
trig "$rnum" "@senior please run this retrospective per the issue body."
else
echo "retro issue creation failed"
fi
exit 0
fi
# --- @pm ASK: consult a dev in the thread WITHOUT starting a build ---
# 'ASK: @<dev> <question>' fires the dev in DISCUSSION mode (route.sh: the trigger below does not
# match any build phrase, so the dev replies in-thread — no branch, no PR). @pm gathers input this
# way, then DELEGATEs when enough is known.
ask_line=$(grep -oiE '^[[:space:]]*ASK:[[:space:]]*@(junior|senior|lead|intern)[[:space:]]+.*$' /tmp/agent_out.md 2>/dev/null | head -1)
if [ "$NAME" = "pm" ] && [ -n "$ask_line" ]; then
ask_dev=$(printf '%s' "$ask_line" | grep -oiE '@(junior|senior|lead|intern)' | head -1 | tr -d '@' | tr '[:upper:]' '[:lower:]')
ask_q=$(printf '%s' "$ask_line" | sed -E 's/^[[:space:]]*ASK:[[:space:]]*@[A-Za-z]+[[:space:]]+//')
trig "$ISSN" "@$ask_dev $ask_q — this is a discussion: reply in this thread with your assessment; do not start any work."
exit 0
fi
# --- @pm delegation: hand the build to a dev, or hand the finished PR to @qa for review ---
# 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
echo "auto-delegating to @$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 proceed with issue #$NUM per the plan above (delegated by $NAME)." '{body:$b}')" \
-w '\ndelegate -> HTTP %{http_code}\n' || true
if [ "$target" = "qa" ]; then
PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w)
if [ -n "$PRN" ] && [ "$CNT" -gt 1 ]; then
trig "$ISSN" "@qa please review the $CNT open PRs for issue #$ISSN (#${PRS// /, #}) — put your recommendations on each PR; approve only when ALL are good."
elif [ -n "$PRN" ]; then
trig "$ISSN" "@qa please review PR #$PRN for issue #$ISSN — put your recommendations on the PR, or approve."
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
echo "no DELEGATE marker — not delegating (agent is asking or finished)"
fi
exit 0
fi
# --- dev DISCUSSION mode: consulted for expertise, no build (route.sh workmode=discuss) ---
# The reply is a comment on the thread — discard any stray file edits, skip ALL git/PR machinery.
if [ "${WORKMODE:-build}" = "discuss" ]; then
git checkout -- . 2>/dev/null || true
git clean -fd 2>/dev/null || true
post "$(printf '%s%s' "$reply" "$activity")"
exit 0
fi
@@ -218,22 +334,21 @@ if [ -n "$(git status --porcelain)" ]; then
git add -A
git commit -m "@$NAME: issue #$NUM"
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
prbody=$(printf '%s\n\n---\nResolves #%s · 🤖 @%s' "$prdesc" "$NUM" "$NAME")
prbody=$(printf '%s\n\n---\nResolves #%s' "$prdesc" "$NUM")
owner=${GITHUB_REPOSITORY%%/*}
# Post the agent's activity trail (tool calls + reasoning) inline in the same comment so
# each run produces exactly ONE comment (issue #38). Computed once here so every dev-agent
# 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
# $activity (the run report: tool calls + tokens + $ cost) was built once near the top, so every
# dev-agent exit path (no-changes, PR-open-failed, normal) appends it to the single reply comment.
# 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
# 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)
if [ "${ahead:-0}" -eq 0 ]; then
# 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
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)
if [ -z "$url" ]; then
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" \
'{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)
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
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).
prpost() {
@@ -267,36 +389,19 @@ prpost() {
[ -n "$n" ] && [ "$n" != "$NUM" ] && t="$n"
echo "posting to #$t"
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
prpost "$prnum" "$(printf '🤖 **@%s** — ✅ PR ready for review — @ffaerber please review & merge:\n- %s%s' "$NAME" "$url" "$activity")"
# AUTOPILOT: hand the fresh PR to @qa automatically (via AGENT_TOKEN, so it fires a new run).
# @qa then verifies and — if green — merges + closes via its MERGE_PR marker. The comment lands
# on the PR thread ($prnum) so the next run resolves the origin issue's label from the branch
# 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
# First PR for this issue: record it on the PR thread, then notify @pm on the ISSUE. @pm never
# reads the PR, so the issue gets only this one-line ping — @pm then routes it to @qa for review.
prpost "$prnum" "$(printf 'Opened PR #%s for review.%s' "$prnum" "$activity")"
trig "$ISSN" "@pm — PR #$prnum is ready for review (issue #$ISSN)."
else
# Resume: just link the PR — its body and the diff already carry the description, so we don't
# repeat the full write-up in the comment (the reasoning trail below shows what this run did).
prpost "$prnum" "$(printf '🤖 **@%s** — pushed an update to the PR:\n- %s%s' "$NAME" "$url" "$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
# A fix (usually after a @qa bounce): update the PR and hand straight back to @qa to re-verify,
# on the PR thread. The qa↔dev loop is direct — it does NOT go back through @pm each round.
prpost "$prnum" "$(printf 'Pushed an update to PR #%s.%s' "$prnum" "$activity")"
case "$NAME" in
junior|senior|lead)
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
;;
junior|senior|lead|intern) trig "$prnum" "@qa please re-verify PR #$prnum — I have pushed an update." ;;
esac
fi
fi
+18 -4
View File
@@ -6,7 +6,7 @@
# the run. Comment-only roles (pm/qa) push nothing, so they are skipped.
#
# Required env (provided by the workflow step):
# GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA
# GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA TOKEN_OPS
# NAME MODE NUM TITLE BRANCH GITHUB_SERVER_URL GITHUB_REPOSITORY
set +e
@@ -17,9 +17,11 @@ set +e
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
case "$NAME" in
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; *) TOK="";;
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; intern) TOK="$TOKEN_INTERN";; *) TOK="";;
esac
[ -z "$TOK" ] && TOK="$GT"
# Trigger token: the @pm hand-back below must FIRE a new run, which the built-in token cannot.
TTOK="$TOK"; [ "$TTOK" = "$GT" ] && TTOK=""
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
@@ -38,18 +40,30 @@ resp=$(curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \
| jq -r --arg br "$BRANCH" 'if type=="array" then (map(select(.head.ref==$br)) | .[0] // empty) else empty end' 2>/dev/null)
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
if [ -z "$url" ]; then
body=$(printf 'The run failed before it could publish, but pushed work exists on this branch — opening a PR so it is not lost.\n\n---\nResolves #%s · 🤖 @%s (auto-rescued after a failed run)' "$NUM" "$NAME")
body=$(printf 'The run failed before it could publish, but pushed work exists on this branch — opening a PR so it is not lost.\n\n---\nResolves #%s (auto-rescued after a failed run)' "$NUM")
resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \
-d "$(jq -nc --arg t "@$NAME: $TITLE" --arg h "$BRANCH" --arg b "$body" \
'{title:$t, head:$h, base:"main", body:$b}')")
echo "rescue PR create ($BRANCH): $resp"
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
else
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
fi
if [ -n "$url" ]; then
# Status note on the issue (marked — must not trigger)…
curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \
-d "$(jq -nc --arg b "$(printf '🤖 **@%s** — ⚠️ the run failed, but your pushed work was not lost — a PR was opened for branch \`%s\`:\n- %s' "$NAME" "$BRANCH" "$url")" '{body:$b}')" \
-d "$(jq -nc --arg b "$(printf '⚠️ The run failed, but the pushed work was not lost — a PR was opened for branch \`%s\`:\n- %s\n\n<!-- 🤖 agent reply — do not trigger -->' "$BRANCH" "$url")" '{body:$b}')" \
-w '\nrescue comment -> HTTP %{http_code}\n' || true
# …then hand the rescued PR back into the flow: without this, the pm→qa choreography would stall
# here (the normal "PR ready" trigger never fired). Unmarked + PAT so it starts @pm's run.
if [ -n "$TTOK" ]; then
curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$NUM/comments" \
-d "$(jq -nc --arg b "@pm — PR #${prnum:-?} was auto-rescued after a failed run (issue #$NUM). Please route it for review." '{body:$b}')" \
-w '\nrescue trigger @pm -> HTTP %{http_code}\n' || true
fi
else
echo "rescue: could not open/find a PR for $BRANCH"
fi
+45 -8
View File
@@ -27,24 +27,56 @@ cp "$AGENTS_JSON" /tmp/agents.json
# here — see agent.yml: this reusable workflow sees it as 'workflow_call'.)
if [ -n "$CID" ]; then scan="$BODY"; else scan="$IBODY"; fi
name=""
for a in pm junior senior lead qa ops; do
case "$scan" in *"@$a"*) name=$a; break;; esac
# FIRST MATCH IN THIS LIST ORDER WINS when a comment mentions several agents. The order is
# load-bearing for the flow's trigger comments: "@pm — @qa approved …" must route to @pm (pm is
# checked first), while "@junior please address @qa's review …" must route to the dev (devs are
# checked before qa). If you add an agent or reword a trigger in publish.sh, re-check this order.
# 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
if [ -z "$name" ]; then
if [ -z "$CID" ]; then name=pm; else echo "no known agent mentioned"; exit 1; fi
if [ -z "$CID" ]; then
name=pm
else
# Not an agent task (e.g. the gate's contains() matched "@internal"). Skip GRACEFULLY: emit
# mode=skip so every later step no-ops — a red run for a non-agent comment is just noise.
echo "no known agent mentioned (word-boundary) — skipping run"
{ echo "name=none"; echo "model=none"; echo "vision=false"; echo "mode=skip"; echo "skills=[]";
echo "branch=main"; echo "new=false"; echo "autopilot=false"; echo "issnum=$NUM"; } >> "$GITHUB_OUTPUT"
exit 0
fi
fi
model=$(jq -r --arg a "$name" '.[$a].model' /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)
# 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)
echo "Routing to @$name (model=$model vision=$vision mode=$mode skills=$skills)"
{ echo "name=$name"; echo "model=$model"; echo "vision=$vision"; echo "mode=$mode"; echo "skills=$skills"; } >> "$GITHUB_OUTPUT"
# --- WORKMODE for dev (mode=pr) agents: build vs DISCUSS. ---
# Mentioning a dev is a CONVERSATION by default — it replies in the thread without creating a
# branch or PR. Actual building starts ONLY on the explicit signals:
# - a comment on a PR thread (resuming existing work), or
# - the pm delegation template ".. please proceed with issue .." (also usable by a human), or
# - the qa bounce template ".. please address my review ..".
# This lets @pm (via its ASK marker) and the maintainer consult devs to gather information first,
# and explicitly start the build later — see publish.sh / run-agent.sh.
workmode=build
if [ "$mode" = "pr" ] && [ -z "$IS_PR" ]; then
if printf '%s' "$scan" | grep -qiE 'please (proceed with issue|address my review)'; then
workmode=build
else
workmode=discuss
fi
fi
echo "Routing to @$name (model=$model vision=$vision mode=$mode workmode=$workmode skills=$skills)"
{ echo "name=$name"; echo "model=$model"; echo "vision=$vision"; echo "mode=$mode"; echo "workmode=$workmode"; echo "skills=$skills"; } >> "$GITHUB_OUTPUT"
# Act as the agent's own Gitea user when its token is set; else the built-in bot.
case "$name" in
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
[ -z "$TOK" ] && TOK="$GT"
git config user.name "$name"
@@ -52,7 +84,10 @@ git config user.email "$name@ffaerber.duckdns.org"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
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)
branch_ref="$ref"
git fetch origin "$ref" && git checkout "$ref"
@@ -71,7 +106,9 @@ else # comment on an issue, no branch ye
git push -u origin "HEAD:ai/issue-$NUM" || true
url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/src/branch/ai/issue-$NUM"
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
+107 -46
View File
@@ -3,7 +3,7 @@
# plain-text reply (/tmp/agent_out.md) plus the raw event stream (/tmp/events.jsonl).
#
# Required env (provided by the workflow step):
# ANTHROPIC_API_KEY AGENT_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
# ANTHROPIC_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
# IBODY CMT
# 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).
@@ -12,9 +12,10 @@ set -u
[ -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)"
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
# @senior/@lead hold node1-ssh, so a node1 task must not go to @junior. Skill *names* only; the
# scoped how-to detail stays hidden per the permission.skill allow-list.
# Include each teammate's registry skills so an agent (esp. @pm) can route by capability — e.g. only
# skill-holders should get a task that needs that skill. Skill *names* only; the scoped how-to detail
# 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)
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=""
@@ -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,
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
@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;
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.
@@ -34,59 +35,81 @@ if [ "$MODE" = "comment" ]; then
obviously not needed; when in doubt, ask instead."
if [ "$NAME" = "pm" ]; then
ACTION="$ACTION
As PM you work in two phases and NEVER skip the approval gate:
PLAN — when the task is clear, present a SHORT plan naming which teammate should build it
(@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.
DELEGATE — ONLY after the maintainer has explicitly approved starting in the thread (a clear
'yes' / 'go' / 'proceed' / 'start building' answering your ready-to-build question) do you end
your reply with a 'DELEGATE: @<agent>' line to hand off.
Never present a plan and delegate on the same turn. If anything is unclear or needs a decision,
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
of sub-tasks (title + one line each), then ask '@ffaerber create these N sub-issues? reply yes.'
Do NOT emit the block yet. ONLY after the maintainer approves, end your reply with EXACTLY:
As PM you ORCHESTRATE this issue from the ISSUE THREAD ONLY — you never read or comment on the PR
(keep your context on the issue). Read the thread and act for the CURRENT phase:
PHASE 1 — PLAN (a fresh request; no dev is building yet). Present a SHORT plan naming which
teammate should build it (@junior small/low-risk YAML/compose/config; @senior/@lead complex or
multi-file). Then END by asking '@ffaerber ready to start building? reply yes to proceed.' — do
NOT delegate yet. ONLY after an explicit 'yes'/'go'/'proceed' do you end a reply with a
'DELEGATE: @<dev>' line to hand off. Never plan and delegate in the same reply.
PHASE 2 — REVIEW (a dev has reported 'PR #<n> is ready'). Do NOT re-plan. Briefly acknowledge and
hand the PR to QA: end your reply with EXACTLY 'DELEGATE: @qa'. (The automation tells @qa which PR
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
milestone: <feature name>
- <task title> :: <one-line description>
- <task title> :: <one-line description>
END_SUBTASKS
The automation creates the milestone + one sub-issue per line (each linked to this issue). It
does NOT auto-start any dev — the maintainer @mentions an agent on each sub-issue when ready."
The automation creates the milestone + one sub-issue per line (each linked here); it does NOT
auto-start any dev — the maintainer @mentions an agent on each sub-issue when ready."
if [ "$AUTOPILOT" = "true" ]; then
ACTION="$ACTION
AUTOPILOT MODE IS ACTIVE (this issue carries the 'autopilot' label). This OVERRIDES the
two-phase approval gate above: do NOT ask '@ffaerber ready to start building?' and do NOT wait
for a 'yes'. When the task is clear, present your SHORT plan naming the best teammate to build it
AND end your reply with a 'DELEGATE: @<agent>' line in the SAME turn to hand off immediately.
Prefer @junior for small/low-risk (mostly YAML/compose/config), @senior/@lead for complex or
multi-file work. Only skip delegating (and instead ask @ffaerber) if the task is genuinely
ambiguous or unsafe — otherwise plan-and-delegate now."
AUTOPILOT MODE IS ACTIVE (this issue carries the 'autopilot' label) — it changes exactly TWO
things for you; everything else above is unchanged:
- PHASE 1: do NOT ask '@ffaerber ready to build?'. Present your SHORT plan AND end with a
'DELEGATE: @<dev>' line in the SAME reply. Only skip delegating (and ask @ffaerber) if the task
is genuinely ambiguous or unsafe.
- PHASE 3: do NOT ask the human to merge. When @qa has approved, end your reply with EXACTLY
'MERGE_PR' — the automation merges the PR and closes this issue. You are the ONLY agent that
merges, and only here."
fi
fi
if [ "$NAME" = "qa" ]; then
ACTION="$ACTION
As QA you verify a change works: read the PR/issue, drive the web app with your headless
browser if there is a URL, and report bugs or confirm behavior. You normally do NOT merge —
a human does that."
if [ "$AUTOPILOT" = "true" ]; then
ACTION="$ACTION
AUTOPILOT MODE IS ACTIVE (this issue/PR carries the 'autopilot' label). You are the quality gate.
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.
As QA you are the REVIEWER — you NEVER edit code and NEVER merge. @pm points you at a PR; review
it: read the diff, drive the web app with your headless browser if there is a URL, and put your
detailed, specific recommendations ON THE PR (the automation posts your reply to the PR thread).
After actually verifying, end your reply with EXACTLY one of:
- 'APPROVE' — the change is correct and any CI is green. The automation records your verdict on the
issue and hands back to @pm (who tells the creator, or in autopilot merges). You do NOT merge.
- 'BOUNCE: @<dev>' — something needs changing. FIRST spell out, specifically and actionably, exactly
what the dev must change (name the file, label, value, hostname, etc.), THEN end with the BOUNCE
line naming who should fix it (@junior / @senior / @lead — usually whoever built it; @senior or
@lead for something harder). The automation sends the PR back to that dev and then re-verifies
with you. After 3 bounces it stops automatically and hands to @ffaerber — so make each round
count and list ALL problems at once, not one at a time.
Use BOUNCE for anything a dev can fix. Only use 'HALT_AUTOPILOT' when the problem is NOT fixable by
a dev — the request itself is ambiguous or needs a human decision — to hand back to @ffaerber.
Emit AT MOST one of MERGE_PR / BOUNCE / HALT_AUTOPILOT, and only after you have actually verified."
fi
what to change (file, label, value, hostname, ), THEN end with the BOUNCE line naming who fixes
it (@junior / @senior / @lead / @intern — usually whoever built it). The automation sends the PR back and
re-verifies with you. After 3 rounds it stops and hands to @ffaerber — so 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
decision). Hands back to @ffaerber.
Emit AT MOST one marker, and only after you have actually verified."
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
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,
@@ -96,10 +119,41 @@ else
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."
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.
YOUR ROLE: ${DESC}
YOUR CAPABILITIES: model ${MODEL}. ${CAP}
${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):
${ROSTER}
@@ -127,14 +181,21 @@ echo "opencode version: $(opencode --version 2>&1)"
# keeps reading agent_out.md exactly as before. Success is exit code 0: the agent may
# make tool-only changes with no text summary, so DO NOT treat empty output as failure.
rc=1
# HARD per-attempt timeout: there is exactly ONE runner slot, and a hung model (a stalled local
# ollama generate on a trivial @intern question) once held it for ~1h, queueing every agent run
# instance-wide. 20 min is far above any legitimate attempt. timeout SIGTERMs, then SIGKILLs 30s
# later. rc=124 (timed out) is NOT retried — a hung backend stays hung; fail fast, free the runner.
AGENT_TIMEOUT="${AGENT_TIMEOUT:-1200}"
for attempt in 1 2 3; do
echo "opencode attempt $attempt/3 for @$NAME ($MODEL)"
echo "opencode attempt $attempt/3 for @$NAME ($MODEL, timeout ${AGENT_TIMEOUT}s)"
rc=0
timeout -k 30 "$AGENT_TIMEOUT" \
opencode run --model "$MODEL" --auto --format json "$PROMPT" ${FILES:-} \
>/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 "--- stderr (trace) ---"; cat /tmp/agent_err.log
[ $rc -eq 0 ] && break
if [ $rc -eq 124 ]; then echo "attempt timed out after ${AGENT_TIMEOUT}s — backend hung, not retrying"; break; fi
if grep -qiE 'overloaded|429|529|rate.?limit|timeout|ETIMEDOUT|ECONNRESET|EAI_AGAIN' /tmp/events.jsonl /tmp/agent_err.log; then
echo "transient error — backing off $((attempt*20))s"; sleep $((attempt * 20)); continue
fi
+42 -27
View File
@@ -3,20 +3,18 @@
# 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.
#
# The credential is AGENT_TOKEN (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
# AGENT_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, AGENT_TOKEN should be
# 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, SELF_TOKEN should be
# narrowed and a dedicated admin token injected only for @ops.
#
# Required env (provided by the workflow step): NAME AGENT_TOKEN
# Required env (provided by the workflow step): NAME SELF_TOKEN
set -eu
[ "${NAME:-}" = "ops" ] || { echo "not @ops — skipping gitea-admin skill"; exit 0; }
if [ -z "${AGENT_TOKEN:-}" ]; then
echo "AGENT_TOKEN not set — skipping gitea-admin skill"
exit 0
fi
# The doc references $SELF_TOKEN (@ops's own admin token, present in the Run-agent step). This step
# only writes the doc for @ops; permission.skill also denies the skill to every other agent.
mkdir -p ~/.config/opencode/skills/gitea-admin && chmod 700 ~/.config/opencode/skills/gitea-admin
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)
Administer the Gitea instance via its REST API at `${GITHUB_SERVER_URL}/api/v1`, authenticated with
`Authorization: token ${AGENT_TOKEN}` (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.
## Golden rules
@@ -42,7 +40,7 @@ already set. Work from the issue instructions; report what you did.
## Create an organisation
```
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/orgs" -d '{"username":"acme","visibility":"private"}'
```
@@ -53,26 +51,30 @@ password you just set) to mint a scoped token, and store the token straight into
API="${GITHUB_SERVER_URL}/api/v1"
PW=$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24) # generated, never printed
# 1) create the user
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/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"}')"
# 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" \
-d '{"name":"inter","scopes":["read:repository","write:issue"]}' | jq -r '.sha1')
# 3) store it as a secret (org / repo / user level) — never print $tok
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \
# 3) store the value in BOTH places (see "Secret storage" below) — never print $tok
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}')"
```
Token **scopes** are groups of `read:`/`write:` on: `repository`, `issue`, `organization`, `user`,
`package`, `notification`, `misc`, and (only for a privileged token) `admin`.
## Token inventory — record everything in `gitea/secrets`
The private repo **`gitea/secrets`** (readable only by @ffaerber and @ops) is the source of truth for
tokens. Whenever you mint, rotate, or re-scope a token, append/update a row in its `tokens.md` via the
contents API (`GET` the file for its `sha`, then `PUT` the updated base64 content with that `sha`):
`| <token/secret name> | <owner user> | <scopes> | <Actions secret it is stored in> | <notes> |`.
Storing the live value in the matching Actions secret is what workflows use; the `gitea/secrets` row
is the human-readable inventory. Never paste a token value into any issue/PR/comment/log.
## Secret storage — `gitea/secrets/.env` is the SOURCE OF TRUTH
Every token/secret value MUST live in **`gitea/secrets/.env`** (private, readable only by @ffaerber and
@ops) as a `KEY=value` line. That file is the master; the workflows only get a secret because `.env` is
mirrored into the org Actions secrets. So whenever you mint, rotate, or re-scope a token you MUST do
BOTH, in sync:
1. **`.env`**: `GET /repos/gitea/secrets/contents/.env` for its `sha`, add or replace the `KEY=value`
line, then `PUT` the updated base64 content with that `sha`.
2. **Actions secret**: `PUT /orgs/gitea/actions/secrets/{KEY}` with the same value (what runs use).
When you DELETE a token, remove it from BOTH. Keep `gitea/secrets/README.md` (the table describing what
each KEY is) up to date. Do NOT use `tokens.md` — the values live in `.env`. NEVER paste a token value
into any issue/PR/comment/log; it only ever goes into `.env` and the Actions secret.
## Change a user's token scope (the "update my token" flow)
Tokens are immutable — you can't edit scopes. Re-mint: delete the old token and create a new one,
@@ -80,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
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 $AGENT_TOKEN" "$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
new generated password, then re-mint.)
## Actions secrets & variables
```
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/orgs/{org}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/repos/{owner}/{repo}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/user/actions/secrets/{NAME}" -d '{"data":"<value>"}' # user-level
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 $SELF_TOKEN" "$API/repos/{owner}/{repo}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
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`.
```
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" "$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}'
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" "$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)
@@ -105,9 +107,22 @@ curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" "$API/orgs/{org}/labels"
3. Commit the standard caller so it gets the agents — `PUT /repos/{owner}/{repo}/contents/.gitea/workflows/ai-agent.yml`
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"}`).
5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_API_KEY, AGENT_TOKEN,
5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_API_KEY, SELF_TOKEN,
TOKEN_* , OLLAMA_URL, OLLAMA_CLOUD_API_KEY); set any missing via the secrets calls above.
## 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
- Create: `POST /admin/users`. Edit: `PATCH /admin/users/{username}`. Delete: `DELETE /admin/users/{username}` (**confirm first**).
- List: `GET /admin/users`.
+19 -25
View File
@@ -1,19 +1,13 @@
#!/usr/bin/env bash
# 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
# ~/.config/opencode/skills/ so any dev agent discovers the capability via OpenCode's
# skill registry. The credential is the shared AGENT_TOKEN (a PAT whose scopes the
# maintainer set at creation time — issue/repository/organization/misc read+write, cross-repo).
# 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.
# Emits an opencode Skill file under ~/.config/opencode/skills/. The credential is SELF_TOKEN — the
# RUNNING agent's OWN token (e.g. TOKEN_PM for @pm), present in the Run-agent step's env. So each
# agent talks to Gitea as itself, with its own scopes. This step only writes the doc, so it always
# emits; permission.skill decides which agents may actually load it.
#
# 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
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
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
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
`${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`.
## 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
token's account can see). That covers:
- issues, PRs, comments, labels, milestones, reviewers (read + write)
@@ -64,9 +58,9 @@ in `agent.yml` exists to enforce.
## 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,
redact the header as `Authorization: token $AGENT_TOKEN`.
redact the header as `Authorization: token $SELF_TOKEN`.
## Examples
@@ -77,9 +71,9 @@ All examples assume `API="${GITHUB_SERVER_URL}/api/v1"`.
```bash
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)
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
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"'
```
@@ -91,27 +85,27 @@ find the owner/repo for a `#N` in *this* repo, just use `${GITHUB_REPOSITORY}`.
```bash
API="${GITHUB_SERVER_URL}/api/v1"
# 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
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)
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
```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)
```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"}'
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"]}'
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"}'
```
@@ -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 |
|-------|-------|:------:|------|--------|------|
| `@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`. |
| `@senior` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api`, `node1-ssh` | Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). |
| `@lead` | `anthropic/claude-opus-4-8` | yes | pr | `gitea-api`, `node1-ssh` | Tech lead — the hardest problems, architecture, and final calls. |
| `@qa` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | QA — verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs. |
| `@senior` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api` | Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). |
| `@lead` | `anthropic/claude-opus-4-8` | yes | pr | `gitea-api` | Tech lead — the hardest problems, architecture, and final calls. |
| `@qa` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | QA / reviewer — reads the PR diff, drives a headless browser (Playwright) to verify behavior; recommendations on the PR, pass/fail verdict on the issue. Never edits code, never merges. |
| `@ops` | `anthropic/claude-opus-4-8` | no | comment | `gitea-admin` | Gitea operator — administers the instance itself (create orgs/users/repos, labels, secrets, scoped per-user tokens, bootstrap repos). Comments only; never edits code. Confirms before destructive actions. |
| `@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
or an agent's skills there, update this table too.
The registry `.gitea/workflows/scripts/agents.json` is the source of truth for this mapping — if you
change a model or an agent's skills there, update this table too. (Repo-specific skills, e.g. a
deploy-host SSH skill, live in the consuming repo under `.gitea/agent-skills/` — not in this table.)
## How a task flows
`@pm` orchestrates from the **issue thread**; the review happens on the **PR**; `@pm` never reads the PR
(keeps its context small) and `@qa` never merges.
1. **Issue opened**`@pm` plans and names a dev, then asks the creator *"ready? reply yes"*
(with the `autopilot` label it skips the question and delegates immediately).
2. **Dev builds** on `ai/issue-N`, a PR opens automatically, and the dev pings `@pm` on the issue.
3. `@pm` hands the PR to **`@qa`**.
4. `@qa` reviews **on the PR** — either recommendations + `BOUNCE: @dev` (dev fixes → `@qa`
re-verifies, direct loop, max 3 rounds) or `APPROVE`.
5. On approval `@qa` posts the verdict **on the issue**`@pm` tells the creator *"ready to merge"*
and a **human merges** — or, with the `autopilot` label, `@pm` merges and closes the issue itself.
`@pm` is the only agent that ever merges, and only under the `autopilot` label (its kill switch:
remove the label mid-flight and the next step reverts to human control).
### 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
@@ -37,7 +74,7 @@ it is the source of truth, and `agents` itself uses the same file:
```yaml
name: ai-agent
run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment
# Standard caller for the shared AI-agent workflow (ffaerber/agents). Copy this file VERBATIM into
# Standard caller for the shared AI-agent workflow (gitea/agents). Copy this file VERBATIM into
# any repo that should get the agents — it is identical in every repo. All logic + scripts live in
# agents/.gitea/workflows/; scripts are fetched from @main at run time. The `jobs.agent` wrapper is
# required: a reusable (workflow_call) workflow can only be invoked from a caller job, not top-level.
@@ -49,7 +86,7 @@ on:
types: [opened]
jobs:
agent:
uses: ffaerber/agents/.gitea/workflows/agent.yml@main
uses: gitea/agents/.gitea/workflows/agent.yml@main
secrets: inherit
```
@@ -76,11 +113,17 @@ points `$SCRIPTS` at it. Keep the workflow and its scripts moving together on `m
|--------|-----|
| `ANTHROPIC_API_KEY` | `@lead` (and `@pm`/`@senior`/`@qa` if on Claude) |
| `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, glm-5.2, minimax-m3) |
| `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` | optional — post/commit as each agent's own Gitea user (falls back to the bot) |
| `TOKEN_PM`,`TOKEN_SENIOR`,`TOKEN_JUNIOR`,`TOKEN_LEAD`,`TOKEN_QA` | **primary** — each agent's own Gitea-user PAT. The running agent gets *only its own* token (as `SELF_TOKEN`) so it posts, commits and comments as itself, and its `gitea-api` skill acts with its own scopes. Scopes: devs + `TOKEN_PM` carry `write:repository` (`@pm` is the only agent that merges, autopilot only); `TOKEN_QA` is `read:repository` + `write:issue` (reviews, never merges). |
| `TOKEN_OPS` | `@ops` only — the admin PAT behind the `gitea-admin` skill (create orgs/users/repos, manage labels & secrets, mint scoped tokens). Injected into the agent process only when the agent is `@ops`. |
`GITEA_TOKEN` is auto-provided. Tip: set these once at the **org** level so every repo inherits
them via `secrets: inherit`.
Each agent authenticates as **itself**: the Run-agent step selects that agent's `TOKEN_*` into
`SELF_TOKEN` (never another agent's), and `publish.sh` uses the same token for the 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