Compare commits

..
Author SHA1 Message Date
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
Felix FaerberandClaude Opus 4.8 2f1ae61b06 feat(ops): make @ops routable + post under its own TOKEN_OPS identity
PR #55 added @ops to the registry but route.sh only looped pm/junior/senior/lead/qa, so @ops could
never be triggered, and there was no token case for it. Add 'ops' to the routing loop and the
TOKEN_OPS case in route.sh + publish.sh, and pass TOKEN_OPS in the workflow env next to TOKEN_QA
(route/publish/rescue steps) so route.sh (set -u) doesn't error on an @ops run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:53:09 +03:00
ffaerber 03c2bef880 Merge pull request 'feat(ops): add @ops Gitea-administrator agent' (#55) from feat/ops-agent into main
Reviewed-on: ffaerber/agents#55
2026-07-05 13:19:29 +02:00
Felix Faerber 6832d7ad6c ops: record minted tokens in the private gitea/secrets inventory
@ops now treats gitea/secrets/tokens.md as the source-of-truth inventory (readable by @ffaerber and
@ops only) and records every token it mints/rotates there, alongside storing the live value in the
matching Actions secret.
2026-07-05 14:09:13 +03:00
Felix FaerberandClaude Opus 4.8 74d3e1d229 feat(ops): add @ops agent — Gitea instance administrator
A comment-mode operator agent (opus) for administering Gitea itself from issues in gitea/ops:
create orgs/users/repos, manage labels & Actions secrets, and mint least-privilege per-user tokens.

- agents.json: new @ops role (comment-mode, skill gitea-admin, confirms before destructive ops).
- skill-gitea-admin.sh: SKILL.md documenting org/user/repo/label/secret ops + the create-user →
  mint-scoped-token → store-as-secret flow (never printing tokens). Gated on NAME=ops so the admin
  how-to is written ONLY for @ops; permission.skill also denies it to other agents.
- agent.yml: wire the skill step (uses AGENT_TOKEN — an admin PAT during bootstrap).

Bootstrap note: AGENT_TOKEN is admin for now, so every agent's process technically holds an admin
credential (skill-scoping hides the doc, not the env var). Once @ops is minting scoped per-user
tokens, narrow AGENT_TOKEN and inject a dedicated admin token only for @ops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:03:57 +03:00
ffaerber 4cbbc9b2d7 Merge pull request 'fix(autopilot): merge via AGENT_TOKEN so it triggers deploy' (#52) from fix/autopilot-merge-triggers-deploy into main
Reviewed-on: ffaerber/agents#52
2026-07-05 08:05:42 +02:00
Felix FaerberandClaude Opus 4.8 cf1e7178b5 fix(autopilot): merge with AGENT_TOKEN so the merge triggers the deploy
@qa's autopilot merge used the built-in Actions token (TOKEN_QA unset -> fell back to GITEA_TOKEN).
Gitea does not fire new workflow runs for pushes made by the built-in Actions token, so the merge
landed on main but deploy.yml never ran — the change was merged-but-not-deployed. Merge with
AGENT_TOKEN (a PAT, already used for delegation and proven to trigger runs) so the push to main
triggers the deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:32:12 +03:00
ffaerber 5a9dba64fb Merge pull request 'autopilot(qa): bounce to dev instead of halt; stop after 3' (#51) from fix/autopilot-bounce into main 2026-07-04 22:21:51 +02:00
Felix FaerberandClaude Opus 4.8 9840c2a860 autopilot(qa): bounce back to the dev instead of halting; stop after 3 bounces
Per maintainer: @qa must never change code — it only accepts a PR or sends it back to the dev with
precise instructions. Replaces the halt-on-any-problem behavior with a bounce loop:

- QA prompt: emit MERGE_PR (approve), or `BOUNCE: @<dev>` after spelling out exactly what to change.
  HALT_AUTOPILOT is now only for problems a dev can't fix (needs a human decision).
- publish.sh: on BOUNCE, re-trigger the named dev (via AGENT_TOKEN) with "autopilot fix attempt N/3".
  Count prior attempts from the PR thread; at 3 it stops (removes the label) and hands to @ffaerber.
- publish.sh: after a dev pushes a fix (resume), auto-re-trigger @qa to re-verify — closing the loop
  (dev fix -> qa re-check -> merge or bounce again).
- Updated the initial @qa trigger wording (bounce, not halt).

Verified: BOUNCE target parsing and the 3-strike counter (attempts 1/3,2/3,3/3 then HALT).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:18:00 +03:00
ffaerber bb07558d70 Merge pull request '@lead: run without asking user' (#20) from ai/issue-16 into main
Reviewed-on: ffaerber/agents#20
2026-07-04 21:39:01 +02:00
lead ef06da3ffe Rename autopilot label 'auto' to 'autopilot' 2026-07-04 19:23:56 +00:00
lead 7278e06dff Rename autopilot label 'fully-automatic' to 'auto' 2026-07-04 19:23:44 +00:00
lead df0f6d6543 @lead: issue #16 — fully-automatic autopilot (label-gated @pm auto-delegate + @qa merge) 2026-07-04 19:23:20 +00:00
ffaerber 23cc3e192e Merge pull request 'fix(activity-log): tool calls only (drop prose summary)' (#50) from fix/activity-tool-calls-only into main 2026-07-04 13:41:02 +02:00
Felix FaerberandClaude Opus 4.8 0a89309ff1 fix(activity-log): tool calls only — drop the agent's prose summary
The activity <details> was labelled 'tool calls & reasoning' but included the agent's final prose
summary ('Added the whoami service... Exposed via Traefik... Pushed to branch...'), which is just a
restatement of the PR description and not a tool call. Emit only tool_use events; relabel to
'N tool calls'. The what-changed narrative lives in the PR description; this section is the record of
actions taken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:40:59 +03:00
ffaerber cb11a6b2d1 Merge pull request 'fix(activity-log): strip PR-description block from reasoning trail' (#49) from fix/activity-log-strip-prdesc into main 2026-07-04 13:25:30 +02:00
Felix FaerberandClaude Opus 4.8 81882ee3ec fix(publish): de-dup agent comments (no repeated PR desc, no doubled header)
Batches the remaining comment-noise cleanups on top of the activity-log fix:
- Resume comment no longer re-posts the full PR description (## Summary/## Changes) — the PR body and
  diff already carry it; the comment just links the PR (the reasoning trail shows what the run did).
- Strip a leading self-header the model sometimes emits ("🤖 **@pm**" on its own line, sometimes
  twice) before we prepend our own, so headers aren't doubled.
- Also drop the BEGIN_PR_DESCRIPTION block from the plain reply text, and simplify the activity
  block's summary (it repeated "🤖 **@name** — activity log").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:24:37 +03:00
Felix FaerberandClaude Opus 4.8 1987ba792d fix(activity-log): strip the PR-description block from the reasoning trail
The 'tool calls & reasoning' activity log dumped each text part verbatim, including the agent's
BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION block — which is already published as the PR description, so
it appeared twice (e.g. homelab PR #117). Strip that block from text parts, and drop a text entry
that becomes empty after stripping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:12:32 +03:00
ffaerber a1ff1b9881 Merge pull request 'fix(route): resume existing ai/issue-N branch on re-trigger' (#47) from fix/route-resume-existing-branch into main 2026-07-04 12:21:07 +02:00
Felix FaerberandClaude Opus 4.8 d803ee7cf5 fix(route): resume an existing ai/issue-N branch instead of losing re-triggered work
When an agent was re-triggered on an issue that already had a branch/open PR, route.sh created a
fresh branch from main (git checkout -b), so the push back was rejected non-fast-forward and the new
commits were silently dropped (|| true) -- the run reported success and re-announced the stale PR,
but the work was gone (observed on issue #17 run #88). Resume the existing branch when it exists, so
new commits fast-forward onto it and update its PR; only branch from main when none exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:21:04 +03:00
ffaerber 1d248bc675 Merge pull request 'fix: quote run-name (bare # was a YAML comment)' (#45) from fix/run-name-quoting into main 2026-07-04 11:33:29 +02:00
Felix FaerberandClaude Opus 4.8 1fd32f0ff6 fix: quote run-name so the issue number is not eaten as a YAML comment
Bare '#' starts a YAML comment, so run-name resolved to 'ai-agent ·' with the number dropped.
Quote the value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:33:27 +03:00
ffaerber e9fad6bfad Merge pull request 'chore: title agent runs by issue number (run-name)' (#43) from chore/run-name into main 2026-07-04 11:29:54 +02:00
Felix FaerberandClaude Opus 4.8 729a14f8bd chore: title agent runs by issue number (run-name)
Without run-name, Gitea titles each ai-agent run by main's head commit, so runs aren't identifiable
in the Actions list. Add `run-name: ai-agent · #${{ github.event.issue.number }}` to the standard
caller (both the file and the README stub) so each run shows its issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:29:09 +03:00
ffaerber d3c8f116f1 Merge pull request 'chore: standardize the ai-agent caller stub' (#41) from chore/standard-caller into main
Reviewed-on: ffaerber/agents#41
2026-07-04 11:24:49 +02:00
Felix FaerberandClaude Opus 4.8 658c10fc35 chore: standardize the ai-agent caller stub (identical in every repo)
The caller workflow had drifted between repos (run-name, stale SHA comment, trigger ordering,
different comment text) though all were functionally the same `uses: agent.yml@main`. Define one
canonical stub: make this repo's own ai-agent.yml the reference and update the README "Use it in a
repo" block to match it verbatim, noting that the on: triggers and the jobs.agent wrapper are the
irreducible minimum for a reusable (workflow_call) workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:17:13 +03:00
ffaerber 649cd4ea9b Merge pull request 'fix(routing): expose agent skills in the roster' (#40) from fix/roster-skills into main 2026-07-04 11:00:39 +02:00
Felix FaerberandClaude Opus 4.8 509b10ac57 fix(routing): expose agent skills in the roster so pm routes by capability
The homelab health-check test mis-routed a node1 inspection to @junior (skills: []), which cannot
reach node1 — only @senior/@lead hold node1-ssh. Root cause: the roster shown to agents listed
desc + vision but not skills, so @pm had no way to route by capability (despite a comment claiming
it could). Add each teammate's skill names to the roster line. Names only; the scoped how-to detail
stays hidden via permission.skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:56:27 +03:00
ffaerber 4ed96dcdf2 Merge pull request '@senior: One comment per agent run' (#39) from ai/issue-38 into main
Reviewed-on: ffaerber/agents#39
2026-07-04 10:55:39 +02:00
senior 767a3341fd feat(publish): one comment per agent run (issue #38)
Merge the activity-log comment into the main reply comment and merge the
sub-issues-created comment into the pm reply, so each agent run posts
exactly one comment (reply + tool calls & reasoning inline).
2026-07-04 08:42:56 +00:00
ffaerber 9a3ca95f9e Merge pull request '@junior: Add runtime .agents-workflow/ checkout to .gitignore' (#36) from ai/issue-35 into main 2026-07-04 09:47:41 +02:00
junior f0f1152545 Add runtime .agents-workflow/ checkout to .gitignore 2026-07-04 07:45:27 +00:00
ffaerber 13148ea68c Merge pull request 'Single source of truth for agent model IDs (fixes #31)' (#32) from ai/issue-31 into main 2026-07-04 09:43:28 +02:00
ffaerber d0a4de9682 Merge pull request '@lead: Dev-agent runs can break later steps by touching the runtime .agents-workflow scripts checkout' (#34) from ai/issue-33 into main 2026-07-04 09:43:13 +02:00
senior cafd36eae1 fix(agent): single source of truth for model IDs (route.sh + install-opencode.sh)
Extract the agent registry to a shared agents.json and have both
route.sh (agent → model) and install-opencode.sh (ollama-cloud provider
models map) derive from it, so the two lists can no longer drift.

route.sh now copies agents.json to /tmp/agents.json instead of an inline
heredoc. install-opencode.sh builds the ollama-cloud  map by
collecting every registry model with the  prefix and
stripping it — non-ollama-cloud models (e.g. @lead's anthropic/claude-opus,
the local ornith:35b) are excluded by construction.

Resolves #31.
2026-07-04 07:20:25 +00:00
13 changed files with 423 additions and 55 deletions
Submodule .agents-workflow deleted from b3deee7412
+24 -2
View File
@@ -1,6 +1,6 @@
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:
@@ -54,7 +54,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 }}
@@ -92,6 +92,7 @@ jobs:
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
TOKEN_QA: ${{ secrets.TOKEN_QA }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
run: bash "$SCRIPTS/route.sh"
- name: Install opencode + provider config (+ Playwright MCP for browser agents)
@@ -131,6 +132,18 @@ jobs:
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
run: bash "$SCRIPTS/skill-gitea-api.sh"
- name: Set up `gitea-admin` skill (@ops only — administer the Gitea instance)
# 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 the
# dedicated admin token TOKEN_OPS (the `ops` user), NOT AGENT_TOKEN — this step only writes
# the doc; the token itself is injected into the Run-agent step only when NAME==ops.
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
NAME: ${{ steps.prep.outputs.name }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
run: bash "$SCRIPTS/skill-gitea-admin.sh"
- name: Inspect / fetch image attachments (download only for vision agents)
id: imgs
env:
@@ -156,12 +169,16 @@ jobs:
# 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 }}
# The dedicated admin token for the `gitea-admin` skill — injected into the agent's process
# ONLY when it is @ops, so no other agent ever holds an admin credential in its env.
TOKEN_OPS: ${{ steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || '' }}
NAME: ${{ steps.prep.outputs.name }}
MODEL: ${{ steps.prep.outputs.model }}
VISION: ${{ steps.prep.outputs.vision }}
MODE: ${{ steps.prep.outputs.mode }}
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
NUM: ${{ github.event.issue.number }}
TITLE: ${{ github.event.issue.title }}
IBODY: ${{ github.event.issue.body }}
@@ -186,12 +203,16 @@ jobs:
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
TOKEN_QA: ${{ secrets.TOKEN_QA }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
NAME: ${{ steps.prep.outputs.name }}
MODE: ${{ steps.prep.outputs.mode }}
NUM: ${{ github.event.issue.number }}
TITLE: ${{ github.event.issue.title }}
BRANCH: ${{ steps.prep.outputs.branch }}
NEW: ${{ steps.prep.outputs.new }}
IS_PR: ${{ github.event.issue.pull_request }} # set when this run is on a PR thread
AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the origin issue carries `autopilot`
ISSNUM: ${{ steps.prep.outputs.issnum }} # origin issue number (resolved from branch on PR threads)
run: bash "$SCRIPTS/publish.sh"
# Failure-safe: if any step above failed AFTER a dev agent already pushed commits, the normal
@@ -208,6 +229,7 @@ jobs:
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
TOKEN_QA: ${{ secrets.TOKEN_QA }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
NAME: ${{ steps.prep.outputs.name }}
MODE: ${{ steps.prep.outputs.mode }}
NUM: ${{ github.event.issue.number }}
+6 -3
View File
@@ -1,6 +1,9 @@
name: ai-agent
# Thin caller so the agents work on THIS repo too (their own workflow). Same shared logic.
# New issues opened by ffaerber auto-start @pm; @mention an agent in a comment to route on replies.
run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment
# 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.
on:
issue_comment:
types: [created]
@@ -8,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
+8
View File
@@ -0,0 +1,8 @@
{
"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)."}
}
+10 -12
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash
# Build activity log (tool calls + reasoning) from the event stream into /tmp/activity_log.md.
# Only dev agents (mode=pr) get an activity-log comment — comment-only roles (pm/qa)
# do no tool calls, so a trail would be empty/noise.
# 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.
#
# Required env (provided by the workflow step): MODE
set -u
@@ -11,16 +13,12 @@ if [ "$MODE" != "pr" ]; then
fi
jq -r '
def trunc(n): if length > n then (.[0:n] + "…") else . end;
select(.type=="tool_use" or .type=="text") |
if .type=="text" then
"💬 " + ((.part.text // "") | trunc(4000))
else
(.part.tool // "?") as $t |
((.part.state.title // (.part.state.input | tojson | trunc(160)) // "")) as $title |
"🔧 **" + $t + "**: `" + ($title | trunc(240)) + "`"
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 entries"
echo "activity log: $n tool calls"
[ "$n" -eq 0 ] && : > /tmp/activity_log.md
head -3 /tmp/activity_log.md
+8 -2
View File
@@ -28,10 +28,16 @@ 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).
jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" '{
# 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.
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" '{
provider: {
ollama: {npm:"@ai-sdk/openai-compatible", options:{baseURL:($url+"/v1")}, models:{"ornith:35b":{}}},
"ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:{"glm-5.2:cloud":{},"gemma4:cloud":{},"kimi-k2.7-code:cloud":{},"minimax-m3:cloud":{}}}
"ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:$cloud}
},
permission: $perm,
mcp: $mcp
+144 -18
View File
@@ -4,11 +4,12 @@
# Required env (provided by the workflow step):
# GT AGENT_TOKEN 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";; *) TOK="";;
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";;
esac
[ -z "$TOK" ] && TOK="$GT"
git config user.name "$NAME"
@@ -17,15 +18,43 @@ API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
post() { curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \
"$API/issues/$NUM/comments" -d "$(jq -nc --arg b "$1" '{body:$b}')"; }
# 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() {
local iss="$1" lid
lid=$(curl -sS "${hdr[@]}" "$API/issues/$iss/labels" 2>/dev/null \
| jq -r 'if type=="array" then ([.[]|select(.name=="autopilot")][0].id // empty) else empty end')
if [ -n "$lid" ]; then
curl -sS -X DELETE "${hdr[@]}" "$API/issues/$iss/labels/$lid" \
-w '\nunlabel -> HTTP %{http_code}\n' || true
else
echo "no 'autopilot' label found on #$iss to remove"
fi
}
# drop machine-readable markers (DELEGATE / CLOSE_ISSUE / the BEGIN_SUBTASKS..END_SUBTASKS block)
# 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
# description is published separately).
reply=$(awk '
/^[[:space:]]*BEGIN_SUBTASKS/{s=1}
/^[[:space:]]*BEGIN_PR_DESCRIPTION/{p=1}
/^[[:space:]]*DELEGATE:[[:space:]]*@/{next}
/^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next}
/^[[:space:]]*MERGE_PR[[: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 '
BEGIN{s=1}
s && /^[^A-Za-z0-9]*\*\*@[A-Za-z]+\*\*[[:space:]]*$/ {next}
s && /^[[: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)
@@ -39,7 +68,6 @@ if [ "$MODE" != "pr" ]; then
# 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
post "$(printf '🤖 **@%s**\n\n%s' "$NAME" "$msg")"
# Close the issue if the agent flagged it (maintainer said it's not needed / duplicate).
if grep -qiE '^[[:space:]]*CLOSE_ISSUE[[:space:]]*$' /tmp/agent_out.md; then
echo "closing issue #$NUM"
@@ -48,6 +76,9 @@ if [ "$MODE" != "pr" ]; then
fi
# BREAKDOWN: from a BEGIN_SUBTASKS block, create a milestone + one sub-issue per line
# (linked to this issue). Sub-issues are NOT auto-started — maintainer mentions agents later.
# Process subtasks first so we can append the created-issues list to the SAME comment as
# the reply (issue #38 — one comment per run).
subtext=""
if grep -qiE '^[[:space:]]*BEGIN_SUBTASKS' /tmp/agent_out.md; then
block=$(awk '/^[[:space:]]*BEGIN_SUBTASKS/{f=1;next} /^[[:space:]]*END_SUBTASKS/{f=0} f' /tmp/agent_out.md)
ms=$(printf '%s\n' "$block" | sed -nE 's/^[[:space:]]*milestone:[[:space:]]*//Ip' | head -1)
@@ -75,8 +106,78 @@ if [ "$MODE" != "pr" ]; then
echo "created sub-issue #${n:-?}: $title"
[ -n "$n" ] && links="$links\n- #$n$title"
done < /tmp/subtasks.txt
post "$(printf '🤖 **@%s** — created sub-issues%s (mention an agent on each when ready):%b' "$NAME" "${ms:+ under milestone **$ms**}" "$links")"
subtext=$(printf '\n\n---\n🤖 **@%s** — created sub-issues%s (mention an agent on each when ready):%b' "$NAME" "${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"
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}"
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"}')
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}")"
;;
*)
# 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")"
;;
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.
@@ -123,6 +224,16 @@ git fetch -q origin 2>/dev/null || true
prbody=$(printf '%s\n\n---\nResolves #%s · 🤖 @%s' "$prdesc" "$NUM" "$NAME")
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
# 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.
@@ -130,7 +241,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' "$NAME" "$reply")"
post "$(printf '🤖 **@%s**\n\n%s%s' "$NAME" "$reply" "$activity")"
exit 0
fi
@@ -148,7 +259,7 @@ if [ -z "$url" ]; then
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' "$NAME" "$reply")"; exit 0; }
[ -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; }
# Posts to the PR thread when we have a PR number, else to the origin issue ($NUM).
prpost() {
@@ -160,17 +271,32 @@ prpost() {
}
if [ "$NEW" = "true" ]; then
prpost "$prnum" "$(printf '🤖 **@%s** — ✅ PR ready for review — @ffaerber please review & merge:\n- %s' "$NAME" "$url")"
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
else
# Resume (comment is on a PR thread): include the write-up here too.
prpost "$prnum" "$(printf '🤖 **@%s** — updated branch/PR:\n- %s\n\n%s' "$NAME" "$url" "$prdesc")"
fi
# Post the agent's activity trail (tool calls + reasoning) as a separate comment so
# it is visible on the PR thread. Additive — kept here even when nothing changed, so a
# follow-up run (re-trigger) can see what this run did via the fetched issue thread.
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)
prpost "$prnum" "$(printf '🤖 **@%s** — activity log (%s entries):\n<details>\n<summary>tool calls & reasoning</summary>\n\n%s\n\n</details>' "$NAME" "$entries" "$log")"
# 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
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
;;
esac
fi
fi
+32 -12
View File
@@ -10,27 +10,24 @@
set -eu
# --- agent registry: model + capabilities + mode + role + skills ---
# The registry is the SINGLE SOURCE OF TRUTH, kept in agents.json next to this
# script. install-opencode.sh derives its ollama-cloud provider `models:` map
# from the same file, so an agent's model can never be missing from the provider
# config — drift is impossible by construction. See issue #31.
# `skills` is the allow-list of opencode Skills each agent may load. It scopes the
# `permission.skill` block written into opencode.json (see install-opencode.sh) so an agent only
# ever sees (and can load) the skills relevant to its role. Skills NOT listed here are hidden from
# that agent entirely — not even the one-line summary appears in its <available_skills>, so the
# full API/how-to detail never reaches an agent that shouldn't act on it. A teammate can still learn
# *that* another agent has a capability from the roster and ask them to use it.
cat > /tmp/agents.json <<'JSON'
{
"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."}
}
JSON
AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json"
cp "$AGENTS_JSON" /tmp/agents.json
# On a new issue, @pm auto-assesses. On a comment, route by the @mention.
# A comment event has a comment id (CID); an issue-opened event does not. (event_name is unreliable
# 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; do
for a in pm junior senior lead qa ops; do
case "$scan" in *"@$a"*) name=$a; break;; esac
done
if [ -z "$name" ]; then
@@ -47,18 +44,26 @@ echo "Routing to @$name (model=$model vision=$vision mode=$mode skills=$skills)"
# 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";; *) TOK="";;
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";;
esac
[ -z "$TOK" ] && TOK="$GT"
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")
branch_ref=""
if [ -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"
{ echo "branch=$ref"; echo "new=false"; } >> "$GITHUB_OUTPUT"
else # comment on an issue -> new branch
elif git ls-remote --exit-code --heads origin "ai/issue-$NUM" >/dev/null 2>&1; then
# comment on an issue whose branch ALREADY exists (a prior run / open PR) -> RESUME it, so new
# commits fast-forward onto the same branch and update its PR. Branching fresh from main here would
# be rejected on push as non-fast-forward and the new work would be silently lost (see issue #17).
git fetch origin "ai/issue-$NUM" && git checkout "ai/issue-$NUM"
{ echo "branch=ai/issue-$NUM"; echo "new=false"; } >> "$GITHUB_OUTPUT"
else # comment on an issue, no branch yet -> new branch
git checkout -b "ai/issue-$NUM"
{ echo "branch=ai/issue-$NUM"; echo "new=true"; } >> "$GITHUB_OUTPUT"
# For dev agents, publish the branch immediately and tell the maintainer where to watch.
@@ -69,3 +74,18 @@ else # comment on an issue -> new branch
-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
fi
fi
# --- Autopilot gate: read the `autopilot` label FRESH every run. ---
# Presence of this label is the opt-in switch (and the kill switch: remove it mid-flight and the
# next run reverts to normal human-approval behavior). When @qa is triggered on a PR thread, the
# label lives on the ORIGIN issue (ai/issue-N), so resolve N from the branch name.
issnum="$NUM"
case "$IS_PR" in ?*) issnum=$(printf '%s' "$branch_ref" | sed -nE 's,^ai/issue-([0-9]+).*,\1,p');; esac
[ -z "$issnum" ] && issnum="$NUM"
autopilot=false
if curl -sS -H "Authorization: token $GT" "$API/issues/$issnum/labels" 2>/dev/null \
| jq -e 'any(.[]?; .name=="autopilot")' >/dev/null 2>&1; then
autopilot=true
fi
echo "autopilot (autopilot label on #$issnum)=$autopilot"
{ echo "autopilot=$autopilot"; echo "issnum=$issnum"; } >> "$GITHUB_OUTPUT"
+40 -2
View File
@@ -3,14 +3,19 @@
# 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 NUM TITLE IBODY CMT
# ANTHROPIC_API_KEY AGENT_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).
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)
ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision))") | join("\n")' /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.
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=""
if [ "$VISION" != "true" ] && [ "${HAS_IMAGES:-0}" -gt 0 ]; then
@@ -48,6 +53,39 @@ if [ "$MODE" = "comment" ]; then
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."
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."
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.
- '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
fi
else
ACTION="You start on git branch '${BRANCH}', with git and push credentials already configured.
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Set up the `gitea-admin` skill — instance administration for the @ops agent ONLY.
# 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 TOKEN_OPS (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
# TOKEN_OPS is admin, every agent's process technically holds an admin credential in its env —
# that is the bootstrap trade-off. Once @ops is minting scoped per-user tokens, TOKEN_OPS should be
# narrowed and a dedicated admin token injected only for @ops.
#
# Required env (provided by the workflow step): NAME TOKEN_OPS
set -eu
[ "${NAME:-}" = "ops" ] || { echo "not @ops — skipping gitea-admin skill"; exit 0; }
if [ -z "${TOKEN_OPS:-}" ]; then
echo "TOKEN_OPS not set — skipping gitea-admin skill"
exit 0
fi
mkdir -p ~/.config/opencode/skills/gitea-admin && chmod 700 ~/.config/opencode/skills/gitea-admin
cat > ~/.config/opencode/skills/gitea-admin/SKILL.md <<'SKILLET'
---
name: gitea-admin
description: Administer this Gitea instance — create orgs, users, repos; manage labels & Actions secrets; mint scoped per-user access tokens; bootstrap a new repo with the agent caller workflow. Use for "create org X", "create repo Y", "add user Z", "give user W a token scoped to …", "set label set on …".
domains: [gitea, admin, orgs, users, repos, secrets, tokens]
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 ${TOKEN_OPS}` (a site-admin token during bootstrap). Both env vars are
already set. Work from the issue instructions; report what you did.
## Golden rules
- **NEVER print, echo, or paste a token, password, or secret value** — not in comments, not in logs.
Capture into a shell variable and immediately store it as a secret; report only that it was stored.
- **ALWAYS confirm before anything destructive** (delete user/repo/org, remove a member). Post a
clear "reply `yes` to confirm deleting X" and stop; only act after the maintainer confirms.
- Prefer the **least privilege** that satisfies the request when minting tokens.
- Be idempotent where you can (check if the org/repo/label already exists before creating).
## Create an organisation
```
curl -sS -X POST -H "Authorization: token $TOKEN_OPS" -H "Content-Type: application/json" \
"$API/orgs" -d '{"username":"acme","visibility":"private"}'
```
## Create a user, then mint a TAILORED token for them (least privilege)
Admin creates the user with a password you generate; you then basic-auth AS that user (with the
password you just set) to mint a scoped token, and store the token straight into a secret.
```
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 $TOKEN_OPS" -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 the value in BOTH places (see "Secret storage" below) — never print $tok
curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" -H "Content-Type: application/json" \
"$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`.
## 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,
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 $TOKEN_OPS" "$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 $TOKEN_OPS" "$API/orgs/{org}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$API/repos/{owner}/{repo}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$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 $TOKEN_OPS" "$API/repos/{owner}/{repo}/labels" \
-d '{"name":"status/review","color":"1d76db","description":"…","exclusive":true}'
curl -sS -X POST -H "Authorization: token $TOKEN_OPS" "$API/orgs/{org}/labels" -d '{…}'
```
## Bootstrap a new repo (create + wire it up for the agents)
1. Create: `POST /orgs/{org}/repos` or `POST /admin/users/{user}/repos` (e.g. `{"name":"homepage","auto_init":true,"private":true}`).
2. Add the standard label set (loop the labels above).
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, TOKEN_OPS,
TOKEN_* , OLLAMA_URL, OLLAMA_CLOUD_API_KEY); set any missing via the secrets calls above.
## Admin user management
- Create: `POST /admin/users`. Edit: `PATCH /admin/users/{username}`. Delete: `DELETE /admin/users/{username}` (**confirm first**).
- List: `GET /admin/users`.
SKILLET
chmod -R o=rX ~/.config/opencode/skills/gitea-admin
echo "gitea-admin skill installed for @ops ($(wc -l < ~/.config/opencode/skills/gitea-admin/SKILL.md) lines)"
+1
View File
@@ -1 +1,2 @@
.env
.agents-workflow/
+16
View File
@@ -11,10 +11,26 @@ the loop guards.
## Golden rules
- You may edit **any file**. **NEVER push to `main`, NEVER merge a PR** — all work goes on a branch
and becomes a PR a human reviews and merges.
- **Narrow exception — `@qa` autopilot merge:** `@qa` (and only `@qa`) MAY merge a single PR **only**
when the linked issue carries the `autopilot` label, the PR is clearly correct, and any CI
checks are green. `@qa` triggers the merge by ending its reply with the `MERGE_PR` marker (the
workflow performs the merge + closes the issue). On **any** doubt or bug, `@qa` must NOT merge:
it ends with `HALT_AUTOPILOT` instead, which removes the `autopilot` label and returns the
issue to human control. No other agent may merge, and `@qa` may not merge without the label.
- **Never print, exfiltrate, or invent secret values.**
- Keep changes **minimal** and match the conventions already in the file you're editing.
- Do the work on a **branch** — never paste code or diffs into the issue thread.
## Autopilot (`autopilot` label)
An issue labeled **`autopilot`** runs without the usual human checkpoints:
- `@pm` plans **and** delegates in the same turn (skips the "ready to build? reply yes" gate).
- After the dev's PR is opened, `@qa` is auto-triggered to verify it, and merges + closes on success
(see the QA merge exception above).
- **Kill switch:** remove the `autopilot` label at any time. The label is re-read fresh at the
start of every run, so the next agent turn reverts to normal human-approval behavior. `@qa` also
removes the label itself whenever it halts on a bug or a failed merge.
No label (the default) = today's behavior, unchanged.
## Branches & pull requests
Start on `ai/issue-<N>`. Split independent changes into separate branches (one PR each). Commit and
push incrementally. Do NOT open PRs yourself (automated). End your reply with the PR description
+14 -3
View File
@@ -12,6 +12,7 @@ Shared **AI dev-team** workflow for Gitea Actions, reusable across repos. It giv
| `@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. |
| `@ops` | `anthropic/claude-opus-4-8` | no | comment | `gitea-admin` | Gitea operator — administers the instance itself (create orgs/users/repos, labels, secrets, scoped per-user tokens, bootstrap repos). Comments only; never edits code. Confirms before destructive actions. |
`agent.yml`'s agent registry is the source of truth for this mapping — if you change a model
or an agent's skills there, update this table too.
@@ -29,10 +30,18 @@ detail out of agents that shouldn't act on it while still letting them know the
## Use it in a repo
Add `.gitea/workflows/ai-agent.yml` to the consuming repo:
**The standard caller is one file, identical in every repo.** Copy this repo's own
[`.gitea/workflows/ai-agent.yml`](.gitea/workflows/ai-agent.yml) verbatim into the consuming repo —
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 (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.
# `run-name` titles each run by the triggering issue (e.g. "ai-agent · #42") in the Actions list.
on:
issue_comment:
types: [created]
@@ -40,11 +49,13 @@ on:
types: [opened]
jobs:
agent:
uses: ffaerber/agents/.gitea/workflows/agent.yml@main
uses: gitea/agents/.gitea/workflows/agent.yml@main
secrets: inherit
```
That's the whole per-repo footprint. All the logic (agent registry, routing, delegation,
That's the whole per-repo footprint, and it's the minimum a caller can be: the `on:` triggers must
live in each repo (a reusable workflow can't declare its callers' triggers) and the `jobs.agent`
wrapper is mandatory for `workflow_call`. Everything else (agent registry, routing, delegation,
reactions, PR/issue plumbing) lives here in `agent.yml`.
## Repo layout