Compare commits

...
42 Commits
Author SHA1 Message Date
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
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
13 changed files with 580 additions and 190 deletions
+49 -29
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:
@@ -12,13 +12,21 @@ jobs:
# 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') &&
!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')))
runs-on: ci-runner
steps:
- name: Acknowledge with 👀
@@ -54,7 +62,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 +100,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)
@@ -103,34 +112,38 @@ 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/)
# 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.
# 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)
# Instance administration (orgs/users/repos/labels/secrets/scoped tokens). The SKILL.md is
# written ONLY for @ops (skill-gitea-admin.sh gates on NAME) and permission.skill also denies
# 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 }}
run: bash "$SCRIPTS/skill-gitea-admin.sh"
- name: Inspect / fetch image attachments (download only for vision agents)
id: imgs
env:
@@ -152,16 +165,19 @@ jobs:
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 || '' }}
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 }}
@@ -180,18 +196,21 @@ jobs:
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 }}
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 +227,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 }}
+3 -3
View File
@@ -1,6 +1,6 @@
name: ai-agent
run-name: ai-agent · #${{ github.event.issue.number }}
# Standard caller for the shared AI-agent workflow (ffaerber/agents). Copy this file VERBATIM into
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.
@@ -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
+53 -6
View File
@@ -1,7 +1,54 @@
{
"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."}
}
"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"
],
"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 — 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
@@ -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
+156 -17
View File
@@ -2,30 +2,76 @@
# 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";; *) TOK="";;
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) 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}')"; }
# 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-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 && /^[[: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)
@@ -77,20 +123,89 @@ 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")"
# Auto-delegate: if the plan names a teammate, trigger them via AGENT_TOKEN (a PAT, so it
post "$(printf '%s%s' "$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 TOK (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 TOK isn't set (then the deploy would need a manual run).
mtok="$TTOK"
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 '✅ 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 '⚠️ 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 '🛑 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 $TTOK" -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 '🛑 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 TOK (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
if [ -n "$TTOK" ]; 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:]')
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" \
curl -sS -X POST -H "Authorization: token $TTOK" -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
@@ -123,7 +238,7 @@ fi
git push origin "HEAD:$BRANCH" || true
git fetch -q origin 2>/dev/null || true
prbody=$(printf '%s\n\n---\nResolves #%s · 🤖 @%s' "$prdesc" "$NUM" "$NAME")
prbody=$(printf '%s\n\n---\nResolves #%s' "$prdesc" "$NUM")
owner=${GITHUB_REPOSITORY%%/*}
# Post the agent's activity trail (tool calls + reasoning) inline in the same comment so
@@ -133,7 +248,7 @@ 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---\n🤖 **@%s** — activity log (%s entries):\n<details>\n<summary>tool calls & reasoning</summary>\n\n%s\n\n</details>' "$NAME" "$entries" "$log")
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
@@ -143,7 +258,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
@@ -161,7 +276,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%s' "$NAME" "$reply" "$activity")"; exit 0; }
[ -z "$url" ] && { echo "PR open/lookup failed for $br — posting reply on issue instead"; post "$(printf '%s%s' "$reply" "$activity")"; exit 0; }
# Posts to the PR thread when we have a PR number, else to the origin issue ($NUM).
prpost() {
@@ -169,12 +284,36 @@ 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")"
prpost "$prnum" "$(printf '✅ PR ready for review — @ffaerber please review & merge:\n- %s%s' "$url" "$activity")"
# AUTOPILOT: hand the fresh PR to @qa automatically (via TOK, 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 "$TTOK" ] && [ -n "$prnum" ]; then
echo "autopilot: auto-triggering @qa to review PR #$prnum"
curl -sS -X POST -H "Authorization: token $TTOK" -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%s' "$NAME" "$url" "$prdesc" "$activity")"
# 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 'Pushed an update to the PR:\n- %s%s' "$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 "$TTOK" ] && [ -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 $TTOK" -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
+29 -4
View File
@@ -27,7 +27,7 @@ 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; do
for a in pm junior senior lead qa ops; do
case "$scan" in *"@$a"*) name=$a; break;; esac
done
if [ -z "$name" ]; then
@@ -44,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.
@@ -63,6 +71,23 @@ else # comment on an issue -> new branch
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
# --- 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"
+44 -4
View File
@@ -3,16 +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 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).
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=""
@@ -51,6 +54,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.
@@ -66,6 +102,10 @@ PROMPT="You are @${NAME}, a member of an AI dev team working on this Gitea repos
YOUR CAPABILITIES: model ${MODEL}. ${CAP}
${NOTE}
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.
TEAM ROSTER (who does what — hand off if a task isn't yours):
${ROSTER}
@@ -0,0 +1,118 @@
#!/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 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
# 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 SELF_TOKEN
set -eu
[ "${NAME:-}" = "ops" ] || { echo "not @ops — skipping gitea-admin skill"; exit 0; }
# 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'
---
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 ${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
- **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 $SELF_TOKEN" -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 $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 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`.
## 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 $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 $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 $SELF_TOKEN" "$API/repos/{owner}/{repo}/labels" \
-d '{"name":"status/review","color":"1d76db","description":"…","exclusive":true}'
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)
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, SELF_TOKEN,
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)"
+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)"
+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
+13 -7
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.
@@ -35,8 +36,8 @@ 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 }}
# Standard caller for the shared AI-agent workflow (ffaerber/agents). Copy this file VERBATIM into
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.
@@ -48,7 +49,7 @@ on:
types: [opened]
jobs:
agent:
uses: ffaerber/agents/.gitea/workflows/agent.yml@main
uses: gitea/agents/.gitea/workflows/agent.yml@main
secrets: inherit
```
@@ -75,11 +76,16 @@ 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, comments, and (for `@qa` autopilot) merges as itself, and its `gitea-api` skill acts with its own scopes. `TOKEN_QA` needs `write:repository` to merge. |
| `TOKEN_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 delegation/autopilot
trigger comment and `@qa` merges — 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