Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b9e1e945d | ||
|
|
88237d3f4d | ||
|
|
6334ebe8c6 | ||
|
|
af38a44d86 | ||
|
|
a1f7fa584b | ||
|
|
c339400503 | ||
|
|
479679ef4c | ||
|
|
5ddad786f1 | ||
|
|
84cec444ef | ||
|
|
01497e9ebe | ||
|
|
e0ef954454 | ||
|
|
baccb09df1 | ||
|
|
ed17613d0d | ||
|
|
62c86e77b0 | ||
|
|
4c05abac63 | ||
|
|
269e932b19 | ||
|
|
0f8893330f | ||
|
|
63dbd2727f | ||
|
|
3c66220f6d | ||
|
|
c792228e2c | ||
|
|
1c4e4ce950 | ||
|
|
738848304e | ||
|
|
06f1924441 | ||
|
|
e53e5caf8c | ||
|
|
79ea9f68c9 | ||
|
|
6c753dc0a0 | ||
|
|
23c48e66c4 | ||
|
|
6618de9c9f | ||
|
|
82c4b07fea | ||
|
|
2f1ae61b06 | ||
|
|
03c2bef880 |
+47
-35
@@ -1,24 +1,38 @@
|
||||
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:
|
||||
|
||||
jobs:
|
||||
|
||||
|
||||
agent:
|
||||
# One run at a time PER ISSUE: two quick comments on the same issue would otherwise race —
|
||||
# both checking out ai/issue-N, pushing (non-fast-forward loss) and double-posting. Queued
|
||||
# runs wait (no cancel) so every trigger is still processed, just serially.
|
||||
concurrency:
|
||||
group: ai-agent-${{ github.repository }}-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Trusted author only, and only when a known agent is mentioned. This gate is the main
|
||||
# defense against malicious-issue prompt injection — do not loosen it.
|
||||
if: >
|
||||
(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 +68,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 +106,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,43 +118,36 @@ 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), so the admin how-to never
|
||||
# reaches other agents; permission.skill also denies it to everyone but @ops. Uses
|
||||
# AGENT_TOKEN (an admin PAT during bootstrap) — see the script header for the token plan.
|
||||
# written ONLY for @ops (skill-gitea-admin.sh gates on NAME) and permission.skill also denies
|
||||
# it to every other agent. It uses SELF_TOKEN (which for @ops is TOKEN_OPS), injected into the
|
||||
# Run-agent step. This step only writes the doc.
|
||||
env:
|
||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||
NAME: ${{ steps.prep.outputs.name }}
|
||||
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
|
||||
run: bash "$SCRIPTS/skill-gitea-admin.sh"
|
||||
|
||||
- name: Inspect / fetch image attachments (download only for vision agents)
|
||||
@@ -163,10 +171,12 @@ 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 }}
|
||||
@@ -181,23 +191,24 @@ jobs:
|
||||
FILES: ${{ steps.imgs.outputs.files }} # opencode -f image flags (vision agents only)
|
||||
run: bash "$SCRIPTS/run-agent.sh"
|
||||
|
||||
- name: Build activity log (tool calls + reasoning) from the event stream
|
||||
- name: Build run report (tool calls + input/output tokens + $ cost) from the event stream
|
||||
id: log
|
||||
env:
|
||||
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||
MODE: ${{ steps.prep.outputs.mode }}
|
||||
MODEL: ${{ steps.prep.outputs.model }} # ollama-cloud models are subscription-billed (no $/token)
|
||||
run: bash "$SCRIPTS/build-activity-log.sh"
|
||||
|
||||
- name: Publish — PR (dev agents) or comment (pm), always reply in the issue
|
||||
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 }}
|
||||
@@ -223,6 +234,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 }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: ai-agent
|
||||
run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment
|
||||
# Standard caller for the shared AI-agent workflow (ffaerber/agents). Copy this file VERBATIM into
|
||||
# Standard caller for the shared AI-agent workflow (gitea/agents). Copy this file VERBATIM into
|
||||
# any repo that should get the agents — it is identical in every repo. All logic + scripts live in
|
||||
# agents/.gitea/workflows/; scripts are fetched from @main at run time. The `jobs.agent` wrapper is
|
||||
# required: a reusable (workflow_call) workflow can only be invoked from a caller job, not top-level.
|
||||
@@ -11,5 +11,5 @@ on:
|
||||
types: [opened]
|
||||
jobs:
|
||||
agent:
|
||||
uses: ffaerber/agents/.gitea/workflows/agent.yml@main
|
||||
uses: gitea/agents/.gitea/workflows/agent.yml@main
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: ci
|
||||
run-name: "ci · ${{ github.event.pull_request.title || github.sha }}"
|
||||
# Lint the very scripts every agent run executes. A single unchecked shell bug here breaks ALL
|
||||
# agents in ALL repos at once (e.g. the ${VAR:-{}} brace bug shellcheck flags as SC1083/SC2321),
|
||||
# so PRs must pass: bash -n + shellcheck on every script, YAML-parse on every workflow, and a
|
||||
# schema check on agents.json (the routing registry).
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ci-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install linters
|
||||
run: |
|
||||
command -v shellcheck >/dev/null || (apt-get update -qq && apt-get install -y -qq shellcheck) || \
|
||||
sudo sh -c 'apt-get update -qq && apt-get install -y -qq shellcheck' || true
|
||||
python3 -c 'import yaml' 2>/dev/null || pip3 install --quiet pyyaml || \
|
||||
(apt-get install -y -qq python3-yaml || sudo apt-get install -y -qq python3-yaml) || true
|
||||
|
||||
- name: bash -n (syntax) — every script
|
||||
run: |
|
||||
set -e
|
||||
for f in .gitea/workflows/scripts/*.sh; do bash -n "$f" && echo "OK $f"; done
|
||||
|
||||
- name: shellcheck — every script
|
||||
run: |
|
||||
set -e
|
||||
if command -v shellcheck >/dev/null; then
|
||||
# error-severity only: the scripts intentionally use unquoted word-splitting in places;
|
||||
# errors (real breakage like the ${x:-{}} brace bug) must fail the build.
|
||||
shellcheck -S error .gitea/workflows/scripts/*.sh && echo "shellcheck clean (severity=error)"
|
||||
else
|
||||
echo "shellcheck unavailable on runner — skipped"
|
||||
fi
|
||||
|
||||
- name: YAML-parse every workflow
|
||||
run: |
|
||||
set -e
|
||||
python3 - <<'EOF'
|
||||
import glob, sys, yaml
|
||||
for f in sorted(glob.glob('.gitea/workflows/*.yml')):
|
||||
yaml.safe_load(open(f))
|
||||
print('OK', f)
|
||||
EOF
|
||||
|
||||
- name: Validate agents.json (registry schema)
|
||||
run: |
|
||||
set -e
|
||||
jq -e 'to_entries | all(.value | (.model|type=="string") and (.mode=="pr" or .mode=="comment")
|
||||
and (.vision|type=="boolean") and (.skills|type=="array") and (.desc|type=="string"))' \
|
||||
.gitea/workflows/scripts/agents.json >/dev/null && echo "agents.json OK"
|
||||
@@ -1,8 +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."},
|
||||
"ops": {"model":"anthropic/claude-opus-4-8","vision":false,"mode":"comment","skills":["gitea-admin"],"desc":"Gitea operator — administers the Gitea instance itself: create orgs/users/repos, manage labels and secrets, mint scoped per-user tokens, bootstrap new repos with the agent caller. Comments only; never edits code. ALWAYS confirms before any destructive action (delete user/repo/org)."}
|
||||
}
|
||||
"pm": {
|
||||
"model": "ollama-cloud/gemma4:cloud",
|
||||
"vision": true,
|
||||
"mode": "comment",
|
||||
"skills": [
|
||||
"gitea-api"
|
||||
],
|
||||
"desc": "Product manager & orchestrator — plans and picks the dev, hands each finished PR to @qa for review, and reports back to the issue creator (in autopilot it merges approved PRs itself). Works from the issue thread only — comments only, never edits files, never reads the PR diff."
|
||||
},
|
||||
"junior": {
|
||||
"model": "ollama-cloud/kimi-k2.7-code:cloud",
|
||||
"vision": false,
|
||||
"mode": "pr",
|
||||
"skills": [],
|
||||
"desc": "Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to @senior or @lead."
|
||||
},
|
||||
"senior": {
|
||||
"model": "ollama-cloud/glm-5.2:cloud",
|
||||
"vision": false,
|
||||
"mode": "pr",
|
||||
"skills": [
|
||||
"gitea-api"
|
||||
],
|
||||
"desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."
|
||||
},
|
||||
"lead": {
|
||||
"model": "anthropic/claude-opus-4-8",
|
||||
"vision": true,
|
||||
"mode": "pr",
|
||||
"skills": [
|
||||
"gitea-api"
|
||||
],
|
||||
"desc": "Tech lead — the hardest problems, architecture, and final calls."
|
||||
},
|
||||
"qa": {
|
||||
"model": "ollama-cloud/minimax-m3:cloud",
|
||||
"vision": true,
|
||||
"mode": "comment",
|
||||
"skills": [
|
||||
"gitea-api"
|
||||
],
|
||||
"desc": "QA / reviewer — reviews PRs: reads the diff, drives a headless browser (Playwright) to verify behavior, posts specific recommendations on the PR and the pass/fail verdict on the issue. Never edits code, never merges."
|
||||
},
|
||||
"ops": {
|
||||
"model": "anthropic/claude-opus-4-8",
|
||||
"vision": false,
|
||||
"mode": "comment",
|
||||
"skills": [
|
||||
"gitea-admin"
|
||||
],
|
||||
"desc": "Gitea operator — administers the Gitea instance itself: create orgs/users/repos, manage labels and secrets, mint scoped per-user tokens, bootstrap new repos with the agent caller. Comments only; never edits code. ALWAYS confirms before any destructive action (delete user/repo/org)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the activity log — the list of TOOL CALLS the agent made — into /tmp/activity_log.md.
|
||||
# Only dev agents (mode=pr) get an activity-log comment — comment-only roles (pm/qa) do no tool calls.
|
||||
# NOTE: we deliberately DO NOT include the agent's prose text parts. That final "here's what I did"
|
||||
# text is just a restatement of the PR description (already published as the PR body), not a tool
|
||||
# call — so it was noise in a section titled "tool calls". The log is the record of ACTIONS taken.
|
||||
# Build the RUN REPORT appended to the agent's reply comment: the TOOL CALLS the agent made plus a
|
||||
# usage line (input / output tokens + $ cost). Written to /tmp/activity_log.md; the Publish step
|
||||
# appends it to the agent's reply. Applies to EVERY agent — @pm/@qa also call tools and cost money.
|
||||
#
|
||||
# Required env (provided by the workflow step): MODE
|
||||
# opencode --format json emits one JSON event per line. `step_finish` events carry, per LLM step,
|
||||
# .part.tokens {input, output, reasoning, cache:{read, write}} and .part.cost (USD, already computed
|
||||
# by opencode from the model's pricing). We sum them across all steps of the run. Models without
|
||||
# pricing (e.g. self-hosted ollama) report cost 0 — shown as $0.0000.
|
||||
#
|
||||
# Required env (provided by the workflow step): (none needed; reads /tmp/events.jsonl)
|
||||
set -u
|
||||
E=/tmp/events.jsonl
|
||||
: > /tmp/activity_log.md
|
||||
[ -s "$E" ] || { echo "no events — empty report"; exit 0; }
|
||||
|
||||
if [ "$MODE" != "pr" ]; then
|
||||
echo "skipping activity log for comment-mode agent"; : > /tmp/activity_log.md; exit 0
|
||||
fi
|
||||
# Tool calls = the ACTIONS taken (not the agent's prose text parts).
|
||||
jq -r '
|
||||
def trunc(n): if length > n then (.[0:n] + "…") else . end;
|
||||
select(.type=="tool_use") |
|
||||
(.part.tool // "?") as $t |
|
||||
((.part.state.title // (.part.state.input | tojson | trunc(160)) // "")) as $title |
|
||||
"🔧 **" + $t + "**: `" + ($title | trunc(240)) + "`"
|
||||
' /tmp/events.jsonl > /tmp/activity_log.md 2>/dev/null || true
|
||||
n=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0)
|
||||
echo "activity log: $n tool calls"
|
||||
[ "$n" -eq 0 ] && : > /tmp/activity_log.md
|
||||
head -3 /tmp/activity_log.md
|
||||
' "$E" > /tmp/tools.md 2>/dev/null || true
|
||||
n=$(wc -l < /tmp/tools.md 2>/dev/null || echo 0); n=${n:-0}
|
||||
|
||||
# Usage: sum per-step tokens + cost across every step_finish event (tab-separated for `read`).
|
||||
read -r COST INP OUT CR CW RE < <(jq -rs '
|
||||
[ .[] | select(.type=="step_finish") | .part ] as $s
|
||||
| [ ([$s[].cost // 0]|add // 0),
|
||||
([$s[].tokens.input // 0]|add // 0),
|
||||
([$s[].tokens.output // 0]|add // 0),
|
||||
([$s[].tokens.cache.read // 0]|add // 0),
|
||||
([$s[].tokens.cache.write // 0]|add // 0),
|
||||
([$s[].tokens.reasoning // 0]|add // 0) ]
|
||||
| @tsv' "$E" 2>/dev/null)
|
||||
COST=${COST:-0}; INP=${INP:-0}; OUT=${OUT:-0}; CR=${CR:-0}; CW=${CW:-0}; RE=${RE:-0}
|
||||
IN_TOTAL=$(( INP + CR + CW )) # total input context processed
|
||||
# Cost label: ollama / ollama-cloud models are SUBSCRIPTION-billed (GPU-time against the plan, no
|
||||
# $/token price exists), so a "$0.0000" there would be misleading — label it a subscription instead.
|
||||
# Metered providers (anthropic/…) get the real dollar cost opencode computed.
|
||||
case "${MODEL:-}" in
|
||||
ollama*|*"/ollama"*) COSTF="subscription" ;;
|
||||
*) COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}') ;;
|
||||
esac
|
||||
echo "usage: in=$IN_TOTAL out=$OUT cost=$COSTF (fresh=$INP cache_r=$CR cache_w=$CW reasoning=$RE); tools=$n"
|
||||
|
||||
{
|
||||
if [ "$n" -gt 0 ]; then
|
||||
printf '\n\n<details>\n<summary>🔧 %s tool calls · in %s · out %s · %s</summary>\n\n' "$n" "$IN_TOTAL" "$OUT" "$COSTF"
|
||||
cat /tmp/tools.md
|
||||
printf '\n\n<sub>tokens — input %s (fresh %s · cache %sw / %sr) · output %s · reasoning %s · **%s**</sub>\n</details>' \
|
||||
"$IN_TOTAL" "$INP" "$CW" "$CR" "$OUT" "$RE" "$COSTF"
|
||||
else
|
||||
printf '\n\n<sub>💰 **%s** · in %s · out %s tokens (cache %sw / %sr)</sub>' "$COSTF" "$IN_TOTAL" "$OUT" "$CW" "$CR"
|
||||
fi
|
||||
} > /tmp/activity_log.md
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fetch the full issue thread (shared memory) into /tmp/thread.md.
|
||||
# Agents post as their OWN Gitea users, so .user.login IS the agent name — attribute each comment
|
||||
# to its real author (@pm/@qa/@junior/…). Strip the hidden `<!-- 🤖 … -->` loop-prevention marker
|
||||
# from bodies — it's plumbing, not conversation, and would just waste prompt tokens.
|
||||
#
|
||||
# Required env (provided by the workflow step): GT NUM GITHUB_SERVER_URL GITHUB_REPOSITORY
|
||||
set -eu
|
||||
@@ -7,9 +10,8 @@ set -eu
|
||||
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=100" 2>/dev/null \
|
||||
| jq -r '.[] |
|
||||
( if (.body | test("delegated by")) then "an automated delegation"
|
||||
elif (.user.login == "ffaerber") then "ffaerber (the maintainer / you)"
|
||||
else "an AI teammate — the specific one is named in the 🤖 @name line at the top of the comment"
|
||||
end ) as $who |
|
||||
"### comment by \($who):\n\(.body)\n"' > /tmp/thread.md 2>/dev/null || true
|
||||
( if (.user.login == "ffaerber") then "@ffaerber (the maintainer)"
|
||||
else "@" + .user.login end ) as $who |
|
||||
"### comment by \($who):\n\(.body | gsub("\\s*<!-- 🤖 agent reply — do not trigger -->"; ""))\n"' \
|
||||
> /tmp/thread.md 2>/dev/null || true
|
||||
echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0)"
|
||||
|
||||
@@ -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
|
||||
@@ -5,7 +5,11 @@
|
||||
# GITHUB_PATH HOME
|
||||
set -eu
|
||||
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
# PIN the opencode version: an unpinned `latest` means a breaking release (CLI flags, or the
|
||||
# --format json event schema that build-activity-log.sh parses) breaks every agent in every repo
|
||||
# at once. Bump deliberately by changing this default (or set OPENCODE_VERSION in the step env).
|
||||
OPENCODE_VERSION="${OPENCODE_VERSION:-1.17.13}"
|
||||
curl -fsSL https://opencode.ai/install | bash -s -- --version "$OPENCODE_VERSION"
|
||||
echo "$HOME/.opencode/bin" >> "$GITHUB_PATH"
|
||||
mkdir -p ~/.config/opencode
|
||||
# Playwright browser MCP only for agents that need to drive a web app
|
||||
|
||||
+131
-131
@@ -2,22 +2,50 @@
|
||||
# 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}')"; }
|
||||
# Post a MARKED status/reply comment to an ARBITRARY thread (issue or PR) — never fires a run.
|
||||
post_to() { curl -sS -w "comment(#$1) -> HTTP %{http_code}\n" -X POST "${hdr[@]}" \
|
||||
"$API/issues/$1/comments" -d "$(jq -nc --arg b "$2$MARK" '{body:$b}')"; }
|
||||
# Post an UNMARKED TRIGGER comment on a thread — fires the mentioned agent's next run. Must use a PAT
|
||||
# (TTOK); the built-in GITEA_TOKEN cannot start new runs. No-op (logged) if this agent has no PAT.
|
||||
trig() { if [ -z "$TTOK" ]; then echo "no trigger token — cannot fire on #$1"; return; fi
|
||||
curl -sS -w "trigger(#$1) -> HTTP %{http_code}\n" -X POST \
|
||||
-H "Authorization: token $TTOK" -H "Content-Type: application/json" \
|
||||
"$API/issues/$1/comments" -d "$(jq -nc --arg b "$2" '{body:$b}')"; }
|
||||
# Origin issue for this run (route.sh resolves it from the branch on PR threads), and a resolver for
|
||||
# the open PR built from its branch (ai/issue-<issue>). Lets @pm/@qa cross between the issue and PR.
|
||||
ISSN="${ISSNUM:-$NUM}"
|
||||
resolve_pr() { curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \
|
||||
| jq -r --arg br "ai/issue-$ISSN" 'if type=="array" then (map(select(.head.ref==$br))|.[0].number // empty) else empty end' 2>/dev/null; }
|
||||
# Remove the 'autopilot' label from an issue by resolving its ID first (Gitea's DELETE label
|
||||
# endpoint is by ID, not name). Arg $1 = issue number. Used as the autopilot kill switch.
|
||||
del_autopilot_label() {
|
||||
@@ -32,7 +60,7 @@ del_autopilot_label() {
|
||||
fi
|
||||
}
|
||||
|
||||
# drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / HALT_AUTOPILOT, and the
|
||||
# drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / APPROVE / HALT / BOUNCE, and the
|
||||
# BEGIN_SUBTASKS..END_SUBTASKS and BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR
|
||||
# description is published separately).
|
||||
reply=$(awk '
|
||||
@@ -41,29 +69,69 @@ reply=$(awk '
|
||||
/^[[:space:]]*DELEGATE:[[:space:]]*@/{next}
|
||||
/^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next}
|
||||
/^[[:space:]]*MERGE_PR[[:space:]]*$/{next}
|
||||
/^[[:space:]]*HALT_AUTOPILOT[[:space:]]*$/{next}
|
||||
/^[[:space:]]*APPROVE[[:space:]]*$/{next}
|
||||
/^[[:space:]]*HALT([_ ]AUTOPILOT)?[[:space:]]*$/{next}
|
||||
/^[[:space:]]*BOUNCE:[[:space:]]*@/{next}
|
||||
s{ if(/^[[:space:]]*END_SUBTASKS/){s=0}; next }
|
||||
p{ if(/^[[:space:]]*END_PR_DESCRIPTION/){p=0}; next }
|
||||
{print}
|
||||
' /tmp/agent_out.md 2>/dev/null)
|
||||
# Strip a leading self-header the model sometimes emits ("🤖 **@pm**" on its own line) so we don't
|
||||
# double it when we prepend our own. Removes a leading run of such header lines and blank lines.
|
||||
reply=$(printf '%s' "$reply" | awk '
|
||||
# Strip a leading self-identification header the model sometimes emits, e.g. "🤖 **@pm**",
|
||||
# "🔨 **@senior**", or a heading like "## 🔨 @senior — <title>". Gitea already attributes the comment
|
||||
# to its author, so we drop any leading line that references the agent's OWN @handle — or a bare
|
||||
# "**@name**" line — together with surrounding blank lines, up to the first real content line.
|
||||
reply=$(printf '%s' "$reply" | awk -v me="@$NAME" '
|
||||
BEGIN{s=1}
|
||||
s && /^[^A-Za-z0-9]*\*\*@[A-Za-z]+\*\*[[:space:]]*$/ {next}
|
||||
s && /^[[:space:]]*$/ {next}
|
||||
s && index($0, me) {next}
|
||||
s && /^[^A-Za-z0-9]*\*\*@[A-Za-z]+\*\*[[:space:]]*$/ {next}
|
||||
{s=0; print}
|
||||
')
|
||||
[ -z "$reply" ] && reply="_(Made changes without a text summary — see the diff below.)_"
|
||||
# Prefer the agent's clean delimited PR description; fall back to the whole reply.
|
||||
prdesc=$(awk '/BEGIN_PR_DESCRIPTION/{f=1;next} /END_PR_DESCRIPTION/{f=0} f' /tmp/agent_out.md)
|
||||
[ -z "$prdesc" ] && prdesc="$reply"
|
||||
# Run report (tool calls + input/output tokens + $ cost) built by build-activity-log.sh. Appended to
|
||||
# every agent's reply comment so each run reports what it did and what it cost.
|
||||
activity="$(cat /tmp/activity_log.md 2>/dev/null || true)"
|
||||
|
||||
# comment-only roles (pm/qa): never change files
|
||||
if [ "$MODE" != "pr" ]; then
|
||||
git checkout -- . 2>/dev/null || true
|
||||
git clean -fd 2>/dev/null || true
|
||||
|
||||
# ---------- @qa: reviewer only — never edits, never merges ----------
|
||||
# Recommendations land ON THE PR (onsite the diff); the pass/fail verdict lands ON THE ISSUE so
|
||||
# @pm (who never reads the PR) can act on it. Ends its reply with APPROVE / BOUNCE: @dev / HALT.
|
||||
if [ "$NAME" = "qa" ]; then
|
||||
PRN=$(resolve_pr)
|
||||
if grep -qiE '^[[:space:]]*APPROVE[[:space:]]*$' /tmp/agent_out.md; then
|
||||
post_to "$ISSN" "$(printf '✅ Reviewed PR #%s — looks good.\n\n%s%s' "${PRN:-?}" "$reply" "$activity")"
|
||||
trig "$ISSN" "@pm — @qa approved PR #${PRN:-?} (issue #$ISSN). Over to you."
|
||||
elif grep -qiE '^[[:space:]]*BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md; then
|
||||
dev=$(grep -oiE 'BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md | head -1 | grep -oiE '(junior|senior|lead)' | tr '[:upper:]' '[:lower:]')
|
||||
[ -z "$dev" ] && [ -n "$PRN" ] && dev=$(curl -sS "${hdr[@]}" "$API/pulls/$PRN" | jq -r '.user.login // "junior"')
|
||||
dest="${PRN:-$NUM}"
|
||||
post_to "$dest" "$reply$activity" # recommendations, on the PR
|
||||
# Bounce budget: count prior "fix attempt" markers on the PR thread; stop after 3.
|
||||
prior=$(curl -sS "${hdr[@]}" "$API/issues/$dest/comments?limit=100" | jq -r 'if type=="array" then [.[]|select(.body|test("fix attempt"))]|length else 0 end' 2>/dev/null); prior=${prior:-0}
|
||||
if [ "$prior" -ge 3 ]; then
|
||||
[ "$AUTOPILOT" = "true" ] && del_autopilot_label "$ISSN"
|
||||
post_to "$ISSN" "🛑 Still not right after 3 fix attempts on PR #${PRN:-?} — handing to @ffaerber (details on the PR)."
|
||||
else
|
||||
n=$((prior + 1))
|
||||
trig "$dest" "@${dev:-junior} please address @qa's review above and update PR #${PRN:-?} (fix attempt $n/3)."
|
||||
fi
|
||||
elif grep -qiE '^[[:space:]]*HALT([_ ]AUTOPILOT)?[[:space:]]*$' /tmp/agent_out.md; then
|
||||
[ "$AUTOPILOT" = "true" ] && del_autopilot_label "$ISSN"
|
||||
post_to "$ISSN" "$(printf '🛑 This needs a human decision (not a dev fix) — @ffaerber please take a look.\n\n%s%s' "$reply" "$activity")"
|
||||
else
|
||||
post_to "${PRN:-$NUM}" "$reply$activity" # no verdict yet (a question) — post where qa works
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- @pm / @ops: issue-thread orchestration ----------
|
||||
target=$(grep -oiE 'DELEGATE:[[:space:]]*@(junior|senior|lead|qa)' /tmp/agent_out.md 2>/dev/null | head -1 | grep -oiE '(junior|senior|lead|qa)' | tr '[:upper:]' '[:lower:]')
|
||||
# Visible comment: the reply text, or a sensible line if the agent only emitted a marker.
|
||||
msg="$reply"
|
||||
@@ -106,95 +174,52 @@ 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")"
|
||||
post "$(printf '%s%s%s' "$msg" "$subtext" "$activity")"
|
||||
|
||||
# --- 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).')"
|
||||
# --- @pm autopilot merge: @pm is the ONLY agent that merges, and ONLY under the autopilot label ---
|
||||
# (@qa never merges — it approves and hands back here.) Merge with the PAT (TTOK), not the built-in
|
||||
# token, so the push to main fires the deploy. TOKEN_PM must carry write:repository.
|
||||
if [ "$NAME" = "pm" ] && [ "$AUTOPILOT" = "true" ] && grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then
|
||||
PRN=$(resolve_pr)
|
||||
if [ -z "$PRN" ]; then
|
||||
echo "MERGE_PR but no open PR found for issue #$ISSN"
|
||||
else
|
||||
echo "@pm autopilot: merging PR #$PRN (issue #$ISSN)"
|
||||
mc=$(curl -sS -o /tmp/merge_resp.txt -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token $TTOK" -H "Content-Type: application/json" \
|
||||
"$API/pulls/$PRN/merge" -d '{"Do":"merge"}')
|
||||
echo "merge -> HTTP $mc"; cat /tmp/merge_resp.txt 2>/dev/null || true
|
||||
case "$mc" in
|
||||
200|201|204)
|
||||
curl -sS -X PATCH "${hdr[@]}" "$API/issues/$ISSN" -d '{"state":"closed"}' -w '\nclose -> HTTP %{http_code}\n' || true
|
||||
post_to "$ISSN" "✅ Merged PR #$PRN (autopilot) and closed this issue." ;;
|
||||
*)
|
||||
del_autopilot_label "$ISSN"
|
||||
post_to "$ISSN" "⚠️ Tried to merge PR #$PRN but the API returned HTTP $mc (checks not green, a conflict, or TOKEN_PM lacks merge scope). Removed the autopilot label — @ffaerber please take a look." ;;
|
||||
esac
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Auto-delegate: if the plan names a teammate, trigger them via AGENT_TOKEN (a PAT, so it
|
||||
# fires a new workflow run — the built-in token cannot). Never targets @pm or self, so the
|
||||
# chain always terminates at a dev. The '🤖' guard on the trigger stops status-comment loops.
|
||||
if [ -n "$AGENT_TOKEN" ]; then
|
||||
# Only delegate on an explicit "DELEGATE: @<agent>" line — never on a prose mention,
|
||||
# so an agent that is asking the maintainer a question does not hand off prematurely.
|
||||
target=$(grep -oiE 'DELEGATE:[[:space:]]*@(junior|senior|lead|qa)' /tmp/agent_out.md 2>/dev/null \
|
||||
| head -1 | grep -oiE '(junior|senior|lead|qa)' | tr '[:upper:]' '[:lower:]')
|
||||
if [ -n "$target" ] && [ "$target" != "$NAME" ]; then
|
||||
echo "auto-delegating to @$target"
|
||||
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \
|
||||
"$API/issues/$NUM/comments" \
|
||||
-d "$(jq -nc --arg b "@$target please proceed with issue #$NUM per the plan above (delegated by $NAME)." '{body:$b}')" \
|
||||
-w '\ndelegate -> HTTP %{http_code}\n' || true
|
||||
# --- @pm delegation: hand the build to a dev, or hand the finished PR to @qa for review ---
|
||||
# Only an explicit 'DELEGATE: @<agent>' line acts (never a prose mention). Fires via the PAT (TTOK)
|
||||
# so a new run starts; the built-in token cannot. Everything posts on the ISSUE — @pm never touches
|
||||
# the PR. Chain terminates: normal → @pm tells the creator (no marker); autopilot → @pm merges above.
|
||||
if [ -n "$target" ] && [ "$target" != "$NAME" ]; then
|
||||
if [ "$target" = "qa" ]; then
|
||||
PRN=$(resolve_pr)
|
||||
if [ -n "$PRN" ]; then
|
||||
trig "$ISSN" "@qa please review PR #$PRN for issue #$ISSN — put your recommendations on the PR, or approve."
|
||||
else
|
||||
echo "DELEGATE:@qa but no open PR yet for issue #$ISSN — not firing"
|
||||
fi
|
||||
else
|
||||
echo "no DELEGATE marker — not delegating (agent is asking or finished)"
|
||||
trig "$ISSN" "@$target please proceed with issue #$ISSN per the plan above (delegated by $NAME)."
|
||||
fi
|
||||
else
|
||||
echo "no DELEGATE marker — not delegating (agent is asking or finished)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
@@ -221,19 +246,11 @@ 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
|
||||
# each run produces exactly ONE comment (issue #38). Computed once here so every dev-agent
|
||||
# exit path (no-changes, PR-open-failed, normal) appends it to the single reply comment.
|
||||
activity=""
|
||||
if [ -s /tmp/activity_log.md ]; then
|
||||
entries=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0)
|
||||
log=$(cat /tmp/activity_log.md)
|
||||
activity=$(printf '\n\n<details>\n<summary>🔧 activity — %s tool calls</summary>\n\n%s\n\n</details>' "$entries" "$log")
|
||||
fi
|
||||
|
||||
# $activity (the run report: tool calls + tokens + $ cost) was built once near the top, so every
|
||||
# dev-agent exit path (no-changes, PR-open-failed, normal) appends it to the single reply comment.
|
||||
# One PR per run: publish ONLY this run's own branch ($BRANCH), never sibling
|
||||
# ai/issue-N-* branches. This removes the multi-PR ambiguity that left the
|
||||
# activity log stranded on the triggering issue instead of the PR thread.
|
||||
@@ -241,7 +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
|
||||
|
||||
@@ -259,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() {
|
||||
@@ -267,36 +284,19 @@ prpost() {
|
||||
[ -n "$n" ] && [ "$n" != "$NUM" ] && t="$n"
|
||||
echo "posting to #$t"
|
||||
curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \
|
||||
"$API/issues/$t/comments" -d "$(jq -nc --arg b "$1" '{body:$b}')"
|
||||
"$API/issues/$t/comments" -d "$(jq -nc --arg b "$1$MARK" '{body:$b}')"
|
||||
}
|
||||
|
||||
if [ "$NEW" = "true" ]; then
|
||||
prpost "$prnum" "$(printf '🤖 **@%s** — ✅ PR ready for review — @ffaerber please review & merge:\n- %s%s' "$NAME" "$url" "$activity")"
|
||||
# AUTOPILOT: hand the fresh PR to @qa automatically (via AGENT_TOKEN, so it fires a new run).
|
||||
# @qa then verifies and — if green — merges + closes via its MERGE_PR marker. The comment lands
|
||||
# on the PR thread ($prnum) so the next run resolves the origin issue's label from the branch
|
||||
# name. The '🤖' guard on the trigger gate stops status-comment loops.
|
||||
if [ "$AUTOPILOT" = "true" ] && [ -n "$AGENT_TOKEN" ] && [ -n "$prnum" ]; then
|
||||
echo "autopilot: auto-triggering @qa to review PR #$prnum"
|
||||
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \
|
||||
"$API/issues/$prnum/comments" \
|
||||
-d "$(jq -nc --arg b "@qa please verify this PR (autopilot: issue #$NUM is labeled autopilot). Merge it if correct, or bounce it back to the dev with exactly what needs fixing." '{body:$b}')" \
|
||||
-w '\ntrigger-qa -> HTTP %{http_code}\n' || true
|
||||
fi
|
||||
# First PR for this issue: record it on the PR thread, then notify @pm on the ISSUE. @pm never
|
||||
# reads the PR, so the issue gets only this one-line ping — @pm then routes it to @qa for review.
|
||||
prpost "$prnum" "$(printf 'Opened PR #%s for review.%s' "$prnum" "$activity")"
|
||||
trig "$ISSN" "@pm — PR #$prnum is ready for review (issue #$ISSN)."
|
||||
else
|
||||
# Resume: just link the PR — its body and the diff already carry the description, so we don't
|
||||
# repeat the full write-up in the comment (the reasoning trail below shows what this run did).
|
||||
prpost "$prnum" "$(printf '🤖 **@%s** — pushed an update to the PR:\n- %s%s' "$NAME" "$url" "$activity")"
|
||||
# AUTOPILOT: after a dev pushes a fix (e.g. following a @qa bounce), hand back to @qa to re-verify.
|
||||
if [ "$AUTOPILOT" = "true" ] && [ -n "$AGENT_TOKEN" ] && [ -n "$prnum" ]; then
|
||||
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
|
||||
# A fix (usually after a @qa bounce): update the PR and hand straight back to @qa to re-verify,
|
||||
# on the PR thread. The qa↔dev loop is direct — it does NOT go back through @pm each round.
|
||||
prpost "$prnum" "$(printf 'Pushed an update to PR #%s.%s' "$prnum" "$activity")"
|
||||
case "$NAME" in
|
||||
junior|senior|lead) trig "$prnum" "@qa please re-verify PR #$prnum — the dev has pushed an update." ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# the run. Comment-only roles (pm/qa) push nothing, so they are skipped.
|
||||
#
|
||||
# Required env (provided by the workflow step):
|
||||
# GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA
|
||||
# GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA TOKEN_OPS
|
||||
# NAME MODE NUM TITLE BRANCH GITHUB_SERVER_URL GITHUB_REPOSITORY
|
||||
set +e
|
||||
|
||||
@@ -17,9 +17,11 @@ set +e
|
||||
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
|
||||
case "$NAME" in
|
||||
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
|
||||
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; *) TOK="";;
|
||||
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";;
|
||||
esac
|
||||
[ -z "$TOK" ] && TOK="$GT"
|
||||
# Trigger token: the @pm hand-back below must FIRE a new run, which the built-in token cannot.
|
||||
TTOK="$TOK"; [ "$TTOK" = "$GT" ] && TTOK=""
|
||||
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
|
||||
|
||||
@@ -38,18 +40,30 @@ resp=$(curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \
|
||||
| jq -r --arg br "$BRANCH" 'if type=="array" then (map(select(.head.ref==$br)) | .[0] // empty) else empty end' 2>/dev/null)
|
||||
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
|
||||
if [ -z "$url" ]; then
|
||||
body=$(printf 'The run failed before it could publish, but pushed work exists on this branch — opening a PR so it is not lost.\n\n---\nResolves #%s · 🤖 @%s (auto-rescued after a failed run)' "$NUM" "$NAME")
|
||||
body=$(printf 'The run failed before it could publish, but pushed work exists on this branch — opening a PR so it is not lost.\n\n---\nResolves #%s (auto-rescued after a failed run)' "$NUM")
|
||||
resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \
|
||||
-d "$(jq -nc --arg t "@$NAME: $TITLE" --arg h "$BRANCH" --arg b "$body" \
|
||||
'{title:$t, head:$h, base:"main", body:$b}')")
|
||||
echo "rescue PR create ($BRANCH): $resp"
|
||||
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
|
||||
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
|
||||
else
|
||||
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -n "$url" ]; then
|
||||
# Status note on the issue (marked — must not trigger)…
|
||||
curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \
|
||||
-d "$(jq -nc --arg b "$(printf '🤖 **@%s** — ⚠️ the run failed, but your pushed work was not lost — a PR was opened for branch \`%s\`:\n- %s' "$NAME" "$BRANCH" "$url")" '{body:$b}')" \
|
||||
-d "$(jq -nc --arg b "$(printf '⚠️ The run failed, but the pushed work was not lost — a PR was opened for branch \`%s\`:\n- %s\n\n<!-- 🤖 agent reply — do not trigger -->' "$BRANCH" "$url")" '{body:$b}')" \
|
||||
-w '\nrescue comment -> HTTP %{http_code}\n' || true
|
||||
# …then hand the rescued PR back into the flow: without this, the pm→qa choreography would stall
|
||||
# here (the normal "PR ready" trigger never fired). Unmarked + PAT so it starts @pm's run.
|
||||
if [ -n "$TTOK" ]; then
|
||||
curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
|
||||
"$API/issues/$NUM/comments" \
|
||||
-d "$(jq -nc --arg b "@pm — PR #${prnum:-?} was auto-rescued after a failed run (issue #$NUM). Please route it for review." '{body:$b}')" \
|
||||
-w '\nrescue trigger @pm -> HTTP %{http_code}\n' || true
|
||||
fi
|
||||
else
|
||||
echo "rescue: could not open/find a PR for $BRANCH"
|
||||
fi
|
||||
|
||||
@@ -27,7 +27,11 @@ 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
|
||||
# FIRST MATCH IN THIS LIST ORDER WINS when a comment mentions several agents. The order is
|
||||
# load-bearing for the flow's trigger comments: "@pm — @qa approved …" must route to @pm (pm is
|
||||
# checked first), while "@junior please address @qa's review …" must route to the dev (devs are
|
||||
# checked before qa). If you add an agent or reword a trigger in publish.sh, re-check this order.
|
||||
for a in pm junior senior lead qa ops; do
|
||||
case "$scan" in *"@$a"*) name=$a; break;; esac
|
||||
done
|
||||
if [ -z "$name" ]; then
|
||||
@@ -44,7 +48,7 @@ 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"
|
||||
@@ -71,7 +75,9 @@ else # comment on an issue, no branch ye
|
||||
git push -u origin "HEAD:ai/issue-$NUM" || true
|
||||
url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/src/branch/ai/issue-$NUM"
|
||||
curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \
|
||||
-d "$(jq -nc --arg b "🔨 **@$name** is on it — building on branch [\`ai/issue-$NUM\`]($url). I'll open a PR when it's ready." '{body:$b}')" >/dev/null || true
|
||||
-d "$(jq -nc --arg b "🔨 Building on branch [\`ai/issue-$NUM\`]($url) — I'll open a PR when it's ready.
|
||||
|
||||
<!-- 🤖 agent reply — do not trigger -->" '{body:$b}')" >/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# plain-text reply (/tmp/agent_out.md) plus the raw event stream (/tmp/events.jsonl).
|
||||
#
|
||||
# Required env (provided by the workflow step):
|
||||
# ANTHROPIC_API_KEY AGENT_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
|
||||
# ANTHROPIC_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
|
||||
# IBODY CMT
|
||||
# FILES (the opencode -f image flags, from the imgs step output)
|
||||
# AUTOPILOT is 'true' when the issue carries the `autopilot` label (label-gated autopilot mode).
|
||||
@@ -12,9 +12,10 @@ set -u
|
||||
[ -z "$CMT" ] && CMT="(a new issue was just opened — assess it)"
|
||||
THREAD=$(cat /tmp/thread.md 2>/dev/null); [ -z "$THREAD" ] && THREAD="(no prior comments)"
|
||||
DESC=$(jq -r --arg a "$NAME" '.[$a].desc' /tmp/agents.json)
|
||||
# Include each teammate's skills so an agent (esp. @pm) can route by capability — e.g. only
|
||||
# @senior/@lead hold node1-ssh, so a node1 task must not go to @junior. Skill *names* only; the
|
||||
# scoped how-to detail stays hidden per the permission.skill allow-list.
|
||||
# Include each teammate's registry skills so an agent (esp. @pm) can route by capability — e.g. only
|
||||
# skill-holders should get a task that needs that skill. Skill *names* only; the scoped how-to detail
|
||||
# stays hidden per the permission.skill allow-list. (Caller-provided skills from a repo's
|
||||
# .gitea/agent-skills/ are not in this roster — document that routing in the caller's AGENTS.md.)
|
||||
ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision); skills: \(.value.skills | if length>0 then join(", ") else "none" end))") | join("\n")' /tmp/agents.json)
|
||||
if [ "$VISION" = "true" ]; then CAP="You CAN read images attached to the issue."; else CAP="You CANNOT read images — you are a text-only model."; fi
|
||||
NOTE=""
|
||||
@@ -34,58 +35,63 @@ if [ "$MODE" = "comment" ]; then
|
||||
obviously not needed; when in doubt, ask instead."
|
||||
if [ "$NAME" = "pm" ]; then
|
||||
ACTION="$ACTION
|
||||
As PM you work in two phases and NEVER skip the approval gate:
|
||||
PLAN — when the task is clear, present a SHORT plan naming which teammate should build it
|
||||
(@junior for small/low-risk, @senior/@lead for complex, @qa to verify), then END by asking
|
||||
'@ffaerber ready to start building? reply yes to proceed.' Do NOT include a DELEGATE line yet.
|
||||
DELEGATE — ONLY after the maintainer has explicitly approved starting in the thread (a clear
|
||||
'yes' / 'go' / 'proceed' / 'start building' answering your ready-to-build question) do you end
|
||||
your reply with a 'DELEGATE: @<agent>' line to hand off.
|
||||
Never present a plan and delegate on the same turn. If anything is unclear or needs a decision,
|
||||
START your reply with '@ffaerber', ask specific questions, and do NOT delegate.
|
||||
BREAKDOWN (for a feature too big for one PR): first PLAN — propose a milestone name and the list
|
||||
of sub-tasks (title + one line each), then ask '@ffaerber create these N sub-issues? reply yes.'
|
||||
Do NOT emit the block yet. ONLY after the maintainer approves, end your reply with EXACTLY:
|
||||
As PM you ORCHESTRATE this issue from the ISSUE THREAD ONLY — you never read or comment on the PR
|
||||
(keep your context on the issue). Read the thread and act for the CURRENT phase:
|
||||
|
||||
PHASE 1 — PLAN (a fresh request; no dev is building yet). Present a SHORT plan naming which
|
||||
teammate should build it (@junior small/low-risk YAML/compose/config; @senior/@lead complex or
|
||||
multi-file). Then END by asking '@ffaerber ready to start building? reply yes to proceed.' — do
|
||||
NOT delegate yet. ONLY after an explicit 'yes'/'go'/'proceed' do you end a reply with a
|
||||
'DELEGATE: @<dev>' line to hand off. Never plan and delegate in the same reply.
|
||||
|
||||
PHASE 2 — REVIEW (a dev has reported 'PR #<n> is ready'). Do NOT re-plan. Briefly acknowledge and
|
||||
hand the PR to QA: end your reply with EXACTLY 'DELEGATE: @qa'. (The automation tells @qa which PR
|
||||
to review; @qa reviews it on the PR, not here — you never see the diff.)
|
||||
|
||||
PHASE 3 — FINALIZE (@qa has reported the PR is approved / 'code OK'). Tell the issue creator it is
|
||||
ready: e.g. 'PR #<n> is reviewed and ready to merge, @ffaerber.' Do NOT delegate and do NOT merge —
|
||||
the human merges.
|
||||
|
||||
If anything is unclear or needs a decision at any phase, START your reply with '@ffaerber', ask
|
||||
specific questions, and do NOT emit a marker. Mentioning a teammate in prose does NOT act — only a
|
||||
DELEGATE line does.
|
||||
BREAKDOWN (a feature too big for one PR): in PHASE 1, propose a milestone name and the sub-task
|
||||
list, then ask '@ffaerber create these N sub-issues? reply yes.' ONLY after approval, end with:
|
||||
BEGIN_SUBTASKS
|
||||
milestone: <feature name>
|
||||
- <task title> :: <one-line description>
|
||||
- <task title> :: <one-line description>
|
||||
END_SUBTASKS
|
||||
The automation creates the milestone + one sub-issue per line (each linked to this issue). It
|
||||
does NOT auto-start any dev — the maintainer @mentions an agent on each sub-issue when ready."
|
||||
The automation creates the milestone + one sub-issue per line (each linked here); it does NOT
|
||||
auto-start any dev — the maintainer @mentions an agent on each sub-issue when ready."
|
||||
if [ "$AUTOPILOT" = "true" ]; then
|
||||
ACTION="$ACTION
|
||||
AUTOPILOT MODE IS ACTIVE (this issue carries the 'autopilot' label). This OVERRIDES the
|
||||
two-phase approval gate above: do NOT ask '@ffaerber ready to start building?' and do NOT wait
|
||||
for a 'yes'. When the task is clear, present your SHORT plan naming the best teammate to build it
|
||||
AND end your reply with a 'DELEGATE: @<agent>' line in the SAME turn to hand off immediately.
|
||||
Prefer @junior for small/low-risk (mostly YAML/compose/config), @senior/@lead for complex or
|
||||
multi-file work. Only skip delegating (and instead ask @ffaerber) if the task is genuinely
|
||||
ambiguous or unsafe — otherwise plan-and-delegate now."
|
||||
AUTOPILOT MODE IS ACTIVE (this issue carries the 'autopilot' label) — it changes exactly TWO
|
||||
things for you; everything else above is unchanged:
|
||||
- PHASE 1: do NOT ask '@ffaerber ready to build?'. Present your SHORT plan AND end with a
|
||||
'DELEGATE: @<dev>' line in the SAME reply. Only skip delegating (and ask @ffaerber) if the task
|
||||
is genuinely ambiguous or unsafe.
|
||||
- PHASE 3: do NOT ask the human to merge. When @qa has approved, end your reply with EXACTLY
|
||||
'MERGE_PR' — the automation merges the PR and closes this issue. You are the ONLY agent that
|
||||
merges, and only here."
|
||||
fi
|
||||
fi
|
||||
if [ "$NAME" = "qa" ]; then
|
||||
ACTION="$ACTION
|
||||
As QA you verify a change works: read the PR/issue, drive the web app with your headless
|
||||
browser if there is a URL, and report bugs or confirm behavior. You normally do NOT merge —
|
||||
a human does that."
|
||||
if [ "$AUTOPILOT" = "true" ]; then
|
||||
ACTION="$ACTION
|
||||
AUTOPILOT MODE IS ACTIVE (this issue/PR carries the 'autopilot' label). You are the quality gate.
|
||||
You do NOT edit code or fix anything yourself — you either accept the PR or send it back to the dev
|
||||
with precise instructions. After actually verifying, end your reply with EXACTLY one of:
|
||||
- 'MERGE_PR' — the change is correct and any CI is green. The automation merges the PR and closes
|
||||
the linked issue. Do NOT merge by any other means; only this marker triggers the merge.
|
||||
As QA you are the REVIEWER — you NEVER edit code and NEVER merge. @pm points you at a PR; review
|
||||
it: read the diff, drive the web app with your headless browser if there is a URL, and put your
|
||||
detailed, specific recommendations ON THE PR (the automation posts your reply to the PR thread).
|
||||
After actually verifying, end your reply with EXACTLY one of:
|
||||
- 'APPROVE' — the change is correct and any CI is green. The automation records your verdict on the
|
||||
issue and hands back to @pm (who tells the creator, or in autopilot merges). You do NOT merge.
|
||||
- 'BOUNCE: @<dev>' — something needs changing. FIRST spell out, specifically and actionably, exactly
|
||||
what the dev must change (name the file, label, value, hostname, etc.), THEN end with the BOUNCE
|
||||
line naming who should fix it (@junior / @senior / @lead — usually whoever built it; @senior or
|
||||
@lead for something harder). The automation sends the PR back to that dev and then re-verifies
|
||||
with you. After 3 bounces it stops automatically and hands to @ffaerber — so make each round
|
||||
count and list ALL problems at once, not one at a time.
|
||||
Use BOUNCE for anything a dev can fix. Only use 'HALT_AUTOPILOT' when the problem is NOT fixable by
|
||||
a dev — the request itself is ambiguous or needs a human decision — to hand back to @ffaerber.
|
||||
Emit AT MOST one of MERGE_PR / BOUNCE / HALT_AUTOPILOT, and only after you have actually verified."
|
||||
fi
|
||||
what to change (file, label, value, hostname, …), THEN end with the BOUNCE line naming who fixes
|
||||
it (@junior / @senior / @lead — usually whoever built it). The automation sends the PR back and
|
||||
re-verifies with you. After 3 rounds it stops and hands to @ffaerber — so list ALL problems at
|
||||
once, not one at a time.
|
||||
- 'HALT' — the problem is NOT something a dev can fix (the request is ambiguous / needs a human
|
||||
decision). Hands back to @ffaerber.
|
||||
Emit AT MOST one marker, and only after you have actually verified."
|
||||
fi
|
||||
else
|
||||
ACTION="You start on git branch '${BRANCH}', with git and push credentials already configured.
|
||||
@@ -101,6 +107,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}
|
||||
|
||||
|
||||
@@ -3,20 +3,18 @@
|
||||
# Emits an opencode Skill file under ~/.config/opencode/skills/ documenting how to create
|
||||
# orgs/users/repos, manage labels & secrets, and mint scoped per-user tokens via the Gitea API.
|
||||
#
|
||||
# The credential is AGENT_TOKEN (BOOTSTRAP: currently an admin PAT — temporary). This skill doc is
|
||||
# The credential is SELF_TOKEN (BOOTSTRAP: currently an admin PAT — temporary). This skill doc is
|
||||
# written ONLY for @ops (gated on NAME) so the how-to never reaches other agents. NOTE: while
|
||||
# AGENT_TOKEN is admin, every agent's process technically holds an admin credential in its env —
|
||||
# that is the bootstrap trade-off. Once @ops is minting scoped per-user tokens, AGENT_TOKEN should be
|
||||
# SELF_TOKEN is admin, every agent's process technically holds an admin credential in its env —
|
||||
# that is the bootstrap trade-off. Once @ops is minting scoped per-user tokens, SELF_TOKEN should be
|
||||
# narrowed and a dedicated admin token injected only for @ops.
|
||||
#
|
||||
# Required env (provided by the workflow step): NAME AGENT_TOKEN
|
||||
# Required env (provided by the workflow step): NAME SELF_TOKEN
|
||||
set -eu
|
||||
|
||||
[ "${NAME:-}" = "ops" ] || { echo "not @ops — skipping gitea-admin skill"; exit 0; }
|
||||
if [ -z "${AGENT_TOKEN:-}" ]; then
|
||||
echo "AGENT_TOKEN not set — skipping gitea-admin skill"
|
||||
exit 0
|
||||
fi
|
||||
# The doc references $SELF_TOKEN (@ops's own admin token, present in the Run-agent step). This step
|
||||
# only writes the doc for @ops; permission.skill also denies the skill to every other agent.
|
||||
mkdir -p ~/.config/opencode/skills/gitea-admin && chmod 700 ~/.config/opencode/skills/gitea-admin
|
||||
cat > ~/.config/opencode/skills/gitea-admin/SKILL.md <<'SKILLET'
|
||||
---
|
||||
@@ -29,7 +27,7 @@ tags: [gitea, admin, api, curl, bootstrap]
|
||||
# `gitea-admin` Skill (operator / @ops only)
|
||||
|
||||
Administer the Gitea instance via its REST API at `${GITHUB_SERVER_URL}/api/v1`, authenticated with
|
||||
`Authorization: token ${AGENT_TOKEN}` (a site-admin token during bootstrap). Both env vars are
|
||||
`Authorization: token ${SELF_TOKEN}` (a site-admin token during bootstrap). Both env vars are
|
||||
already set. Work from the issue instructions; report what you did.
|
||||
|
||||
## Golden rules
|
||||
@@ -42,7 +40,7 @@ already set. Work from the issue instructions; report what you did.
|
||||
|
||||
## Create an organisation
|
||||
```
|
||||
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \
|
||||
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
|
||||
"$API/orgs" -d '{"username":"acme","visibility":"private"}'
|
||||
```
|
||||
|
||||
@@ -53,26 +51,30 @@ password you just set) to mint a scoped token, and store the token straight into
|
||||
API="${GITHUB_SERVER_URL}/api/v1"
|
||||
PW=$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24) # generated, never printed
|
||||
# 1) create the user
|
||||
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \
|
||||
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
|
||||
"$API/admin/users" -d "$(jq -nc --arg u inter --arg e inter@ffaerber.duckdns.org --arg p "$PW" \
|
||||
'{username:$u,email:$e,password:$p,must_change_password:false,source_id:0,visibility:"private"}')"
|
||||
# 2) mint a scoped token AS that user (pick the narrowest scopes needed)
|
||||
tok=$(curl -sS -u "inter:$PW" -H "Content-Type: application/json" -X POST "$API/users/inter/tokens" \
|
||||
-d '{"name":"inter","scopes":["read:repository","write:issue"]}' | jq -r '.sha1')
|
||||
# 3) store it as a secret (org / repo / user level) — never print $tok
|
||||
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \
|
||||
# 3) store the value in BOTH places (see "Secret storage" below) — never print $tok
|
||||
curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
|
||||
"$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')"
|
||||
```
|
||||
Token **scopes** are groups of `read:`/`write:` on: `repository`, `issue`, `organization`, `user`,
|
||||
`package`, `notification`, `misc`, and (only for a privileged token) `admin`.
|
||||
|
||||
## Token inventory — record everything in `gitea/secrets`
|
||||
The private repo **`gitea/secrets`** (readable only by @ffaerber and @ops) is the source of truth for
|
||||
tokens. Whenever you mint, rotate, or re-scope a token, append/update a row in its `tokens.md` via the
|
||||
contents API (`GET` the file for its `sha`, then `PUT` the updated base64 content with that `sha`):
|
||||
`| <token/secret name> | <owner user> | <scopes> | <Actions secret it is stored in> | <notes> |`.
|
||||
Storing the live value in the matching Actions secret is what workflows use; the `gitea/secrets` row
|
||||
is the human-readable inventory. Never paste a token value into any issue/PR/comment/log.
|
||||
## Secret storage — `gitea/secrets/.env` is the SOURCE OF TRUTH
|
||||
Every token/secret value MUST live in **`gitea/secrets/.env`** (private, readable only by @ffaerber and
|
||||
@ops) as a `KEY=value` line. That file is the master; the workflows only get a secret because `.env` is
|
||||
mirrored into the org Actions secrets. So whenever you mint, rotate, or re-scope a token you MUST do
|
||||
BOTH, in sync:
|
||||
1. **`.env`**: `GET /repos/gitea/secrets/contents/.env` for its `sha`, add or replace the `KEY=value`
|
||||
line, then `PUT` the updated base64 content with that `sha`.
|
||||
2. **Actions secret**: `PUT /orgs/gitea/actions/secrets/{KEY}` with the same value (what runs use).
|
||||
When you DELETE a token, remove it from BOTH. Keep `gitea/secrets/README.md` (the table describing what
|
||||
each KEY is) up to date. Do NOT use `tokens.md` — the values live in `.env`. NEVER paste a token value
|
||||
into any issue/PR/comment/log; it only ever goes into `.env` and the Actions secret.
|
||||
|
||||
## Change a user's token scope (the "update my token" flow)
|
||||
Tokens are immutable — you can't edit scopes. Re-mint: delete the old token and create a new one,
|
||||
@@ -80,23 +82,23 @@ then overwrite the stored secret.
|
||||
```
|
||||
curl -sS -u "inter:$PW" -X DELETE "$API/users/inter/tokens/<name-or-id>" # needs the password again
|
||||
tok=$(curl -sS -u "inter:$PW" -X POST "$API/users/inter/tokens" -d '{"name":"inter","scopes":[…new…]}' | jq -r '.sha1')
|
||||
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')"
|
||||
curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')"
|
||||
```
|
||||
(If you no longer hold the user's password, reset it first via `PATCH /admin/users/{username}` with a
|
||||
new generated password, then re-mint.)
|
||||
|
||||
## Actions secrets & variables
|
||||
```
|
||||
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/orgs/{org}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
|
||||
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/repos/{owner}/{repo}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
|
||||
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/user/actions/secrets/{NAME}" -d '{"data":"<value>"}' # user-level
|
||||
curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$API/orgs/{org}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
|
||||
curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$API/repos/{owner}/{repo}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
|
||||
curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$API/user/actions/secrets/{NAME}" -d '{"data":"<value>"}' # user-level
|
||||
```
|
||||
|
||||
## Labels (repo or org-wide). Scoped labels (name `scope/value`) are mutually exclusive if `exclusive:true`.
|
||||
```
|
||||
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" "$API/repos/{owner}/{repo}/labels" \
|
||||
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$API/repos/{owner}/{repo}/labels" \
|
||||
-d '{"name":"status/review","color":"1d76db","description":"…","exclusive":true}'
|
||||
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" "$API/orgs/{org}/labels" -d '{…}'
|
||||
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$API/orgs/{org}/labels" -d '{…}'
|
||||
```
|
||||
|
||||
## Bootstrap a new repo (create + wire it up for the agents)
|
||||
@@ -105,7 +107,7 @@ curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" "$API/orgs/{org}/labels"
|
||||
3. Commit the standard caller so it gets the agents — `PUT /repos/{owner}/{repo}/contents/.gitea/workflows/ai-agent.yml`
|
||||
with base64 `content`, `message`, `branch:"main"` (copy the exact caller from the `agents` repo README).
|
||||
4. Add the agent bot users as collaborators: `PUT /repos/{owner}/{repo}/collaborators/{username}` (`{"permission":"write"}`).
|
||||
5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_API_KEY, AGENT_TOKEN,
|
||||
5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_API_KEY, SELF_TOKEN,
|
||||
TOKEN_* , OLLAMA_URL, OLLAMA_CLOUD_API_KEY); set any missing via the secrets calls above.
|
||||
|
||||
## Admin user management
|
||||
|
||||
@@ -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)"
|
||||
@@ -7,15 +7,33 @@ Shared **AI dev-team** workflow for Gitea Actions, reusable across repos. It giv
|
||||
|
||||
| Agent | Model | Vision | Mode | Skills | Role |
|
||||
|-------|-------|:------:|------|--------|------|
|
||||
| `@pm` | `ollama-cloud/gemma4:cloud` | yes | comment | `gitea-api` | Product manager — research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files. |
|
||||
| `@pm` | `ollama-cloud/gemma4:cloud` | yes | comment | `gitea-api` | Product manager & orchestrator — plans, picks the dev, hands finished PRs to `@qa`, reports back to the issue creator (autopilot: merges approved PRs itself). Issue thread only; never edits files, never reads the PR diff. |
|
||||
| `@junior` | `ollama-cloud/kimi-k2.7-code:cloud` | no | pr | — | Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to `@senior` or `@lead`. |
|
||||
| `@senior` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api`, `node1-ssh` | Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). |
|
||||
| `@lead` | `anthropic/claude-opus-4-8` | yes | pr | `gitea-api`, `node1-ssh` | Tech lead — the hardest problems, architecture, and final calls. |
|
||||
| `@qa` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | QA — verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs. |
|
||||
| `@senior` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api` | Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). |
|
||||
| `@lead` | `anthropic/claude-opus-4-8` | yes | pr | `gitea-api` | Tech lead — the hardest problems, architecture, and final calls. |
|
||||
| `@qa` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | QA / reviewer — reads the PR diff, drives a headless browser (Playwright) to verify behavior; recommendations on the PR, pass/fail verdict on the issue. Never edits code, never merges. |
|
||||
| `@ops` | `anthropic/claude-opus-4-8` | no | comment | `gitea-admin` | Gitea operator — administers the instance itself (create orgs/users/repos, labels, secrets, scoped per-user tokens, bootstrap repos). Comments only; never edits code. Confirms before destructive actions. |
|
||||
|
||||
`agent.yml`'s agent registry is the source of truth for this mapping — if you change a model
|
||||
or an agent's skills there, update this table too.
|
||||
The registry `.gitea/workflows/scripts/agents.json` is the source of truth for this mapping — if you
|
||||
change a model or an agent's skills there, update this table too. (Repo-specific skills, e.g. a
|
||||
deploy-host SSH skill, live in the consuming repo under `.gitea/agent-skills/` — not in this table.)
|
||||
|
||||
## How a task flows
|
||||
|
||||
`@pm` orchestrates from the **issue thread**; the review happens on the **PR**; `@pm` never reads the PR
|
||||
(keeps its context small) and `@qa` never merges.
|
||||
|
||||
1. **Issue opened** → `@pm` plans and names a dev, then asks the creator *"ready? reply yes"*
|
||||
(with the `autopilot` label it skips the question and delegates immediately).
|
||||
2. **Dev builds** on `ai/issue-N`, a PR opens automatically, and the dev pings `@pm` on the issue.
|
||||
3. `@pm` hands the PR to **`@qa`**.
|
||||
4. `@qa` reviews **on the PR** — either recommendations + `BOUNCE: @dev` (dev fixes → `@qa`
|
||||
re-verifies, direct loop, max 3 rounds) or `APPROVE`.
|
||||
5. On approval `@qa` posts the verdict **on the issue** → `@pm` tells the creator *"ready to merge"*
|
||||
and a **human merges** — or, with the `autopilot` label, `@pm` merges and closes the issue itself.
|
||||
|
||||
`@pm` is the only agent that ever merges, and only under the `autopilot` label (its kill switch:
|
||||
remove the label mid-flight and the next step reverts to human control).
|
||||
|
||||
### Per-agent skill scoping
|
||||
|
||||
@@ -37,7 +55,7 @@ it is the source of truth, and `agents` itself uses the same file:
|
||||
```yaml
|
||||
name: ai-agent
|
||||
run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment
|
||||
# Standard caller for the shared AI-agent workflow (ffaerber/agents). Copy this file VERBATIM into
|
||||
# Standard caller for the shared AI-agent workflow (gitea/agents). Copy this file VERBATIM into
|
||||
# any repo that should get the agents — it is identical in every repo. All logic + scripts live in
|
||||
# agents/.gitea/workflows/; scripts are fetched from @main at run time. The `jobs.agent` wrapper is
|
||||
# required: a reusable (workflow_call) workflow can only be invoked from a caller job, not top-level.
|
||||
@@ -49,7 +67,7 @@ on:
|
||||
types: [opened]
|
||||
jobs:
|
||||
agent:
|
||||
uses: ffaerber/agents/.gitea/workflows/agent.yml@main
|
||||
uses: gitea/agents/.gitea/workflows/agent.yml@main
|
||||
secrets: inherit
|
||||
```
|
||||
|
||||
@@ -76,11 +94,17 @@ points `$SCRIPTS` at it. Keep the workflow and its scripts moving together on `m
|
||||
|--------|-----|
|
||||
| `ANTHROPIC_API_KEY` | `@lead` (and `@pm`/`@senior`/`@qa` if on Claude) |
|
||||
| `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, glm-5.2, minimax-m3) |
|
||||
| `AGENT_TOKEN` | PAT (issue/repository/organization/misc read+write, cross-repo) — posts the delegation comment that fires the next agent **and** powers the `gitea-api` skill (read/write issues, PRs, comments, labels, and Actions runs/logs across any repo). Do not re-narrow its scopes without also removing the `gitea-api` skill. |
|
||||
| `TOKEN_PM`,`TOKEN_SENIOR`,`TOKEN_JUNIOR`,`TOKEN_LEAD`,`TOKEN_QA` | optional — post/commit as each agent's own Gitea user (falls back to the bot) |
|
||||
| `TOKEN_PM`,`TOKEN_SENIOR`,`TOKEN_JUNIOR`,`TOKEN_LEAD`,`TOKEN_QA` | **primary** — each agent's own Gitea-user PAT. The running agent gets *only its own* token (as `SELF_TOKEN`) so it posts, commits and comments as itself, and its `gitea-api` skill acts with its own scopes. Scopes: devs + `TOKEN_PM` carry `write:repository` (`@pm` is the only agent that merges, autopilot only); `TOKEN_QA` is `read:repository` + `write:issue` (reviews, never merges). |
|
||||
| `TOKEN_OPS` | `@ops` only — the admin PAT behind the `gitea-admin` skill (create orgs/users/repos, manage labels & secrets, mint scoped tokens). Injected into the agent process only when the agent is `@ops`. |
|
||||
|
||||
`GITEA_TOKEN` is auto-provided. Tip: set these once at the **org** level so every repo inherits
|
||||
them via `secrets: inherit`.
|
||||
Each agent authenticates as **itself**: the Run-agent step selects that agent's `TOKEN_*` into
|
||||
`SELF_TOKEN` (never another agent's), and `publish.sh` uses the same token for the trigger comments
|
||||
that drive the flow (delegation, `@qa` hand-offs, bounces) and for `@pm`'s autopilot merge — the two
|
||||
things the built-in `GITEA_TOKEN` can't do (it won't start
|
||||
new runs, and a merge under it won't fire downstream deploys). So **every consuming repo must carry the
|
||||
per-agent `TOKEN_*` secrets** (org-level for `gitea/*`, user-level for `ffaerber/*`); there is no shared
|
||||
fallback token. `GITEA_TOKEN` is auto-provided (used for reads). Tip: set the `TOKEN_*` once at the
|
||||
**org / user** level so every repo inherits them via `secrets: inherit`.
|
||||
|
||||
## Also add to each consuming repo
|
||||
|
||||
|
||||
Reference in New Issue
Block a user