diff --git a/.gitea/workflows/agent.yml b/.gitea/workflows/agent.yml index c210a1a..a233df2 100644 --- a/.gitea/workflows/agent.yml +++ b/.gitea/workflows/agent.yml @@ -162,6 +162,7 @@ jobs: MODE: ${{ steps.prep.outputs.mode }} HAS_IMAGES: ${{ steps.imgs.outputs.has_images }} BRANCH: ${{ steps.prep.outputs.branch }} + AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the issue carries the `autopilot` label NUM: ${{ github.event.issue.number }} TITLE: ${{ github.event.issue.title }} IBODY: ${{ github.event.issue.body }} @@ -192,6 +193,9 @@ jobs: TITLE: ${{ github.event.issue.title }} BRANCH: ${{ steps.prep.outputs.branch }} NEW: ${{ steps.prep.outputs.new }} + IS_PR: ${{ github.event.issue.pull_request }} # set when this run is on a PR thread + AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the origin issue carries `autopilot` + ISSNUM: ${{ steps.prep.outputs.issnum }} # origin issue number (resolved from branch on PR threads) run: bash "$SCRIPTS/publish.sh" # Failure-safe: if any step above failed AFTER a dev agent already pushed commits, the normal diff --git a/.gitea/workflows/scripts/publish.sh b/.gitea/workflows/scripts/publish.sh index a0d93f5..2fc4db7 100755 --- a/.gitea/workflows/scripts/publish.sh +++ b/.gitea/workflows/scripts/publish.sh @@ -4,6 +4,7 @@ # Required env (provided by the workflow step): # GT AGENT_TOKEN TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA # NAME MODE NUM TITLE BRANCH NEW GITHUB_SERVER_URL GITHUB_REPOSITORY +# IS_PR AUTOPILOT ISSNUM (autopilot: @qa label-gated merge/halt + auto-trigger @qa on a fresh PR) set +e # publish is best-effort: a grep-no-match / curl non-zero must NOT kill the step # Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot. case "$NAME" in @@ -17,14 +18,30 @@ API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json") post() { curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \ "$API/issues/$NUM/comments" -d "$(jq -nc --arg b "$1" '{body:$b}')"; } +# Remove the 'autopilot' label from an issue by resolving its ID first (Gitea's DELETE label +# endpoint is by ID, not name). Arg $1 = issue number. Used as the autopilot kill switch. +del_autopilot_label() { + local iss="$1" lid + lid=$(curl -sS "${hdr[@]}" "$API/issues/$iss/labels" 2>/dev/null \ + | jq -r 'if type=="array" then ([.[]|select(.name=="autopilot")][0].id // empty) else empty end') + if [ -n "$lid" ]; then + curl -sS -X DELETE "${hdr[@]}" "$API/issues/$iss/labels/$lid" \ + -w '\nunlabel -> HTTP %{http_code}\n' || true + else + echo "no 'autopilot' label found on #$iss to remove" + fi +} -# drop machine-readable markers: DELEGATE / CLOSE_ISSUE, and the BEGIN_SUBTASKS..END_SUBTASKS and -# BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR description is published separately). +# drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / HALT_AUTOPILOT, and the +# BEGIN_SUBTASKS..END_SUBTASKS and BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR +# description is published separately). reply=$(awk ' /^[[:space:]]*BEGIN_SUBTASKS/{s=1} /^[[:space:]]*BEGIN_PR_DESCRIPTION/{p=1} /^[[:space:]]*DELEGATE:[[:space:]]*@/{next} /^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next} + /^[[:space:]]*MERGE_PR[[:space:]]*$/{next} + /^[[:space:]]*HALT_AUTOPILOT[[:space:]]*$/{next} s{ if(/^[[:space:]]*END_SUBTASKS/){s=0}; next } p{ if(/^[[:space:]]*END_PR_DESCRIPTION/){p=0}; next } {print} @@ -91,6 +108,43 @@ if [ "$MODE" != "pr" ]; then subtext=$(printf '\n\n---\nšŸ¤– **@%s** — created sub-issues%s (mention an agent on each when ready):%b' "$NAME" "${ms:+ under milestone **$ms**}" "$links") fi post "$(printf 'šŸ¤– **@%s**\n\n%s%s' "$NAME" "$msg" "$subtext")" + + # --- AUTOPILOT: @qa's narrow, label-gated merge / halt authority --- + # Only @qa, only when 'autopilot' is set, and only on a PR thread. The MERGE_PR / HALT_AUTOPILOT + # markers come from the QA prompt. Merge + label ops use TOKEN_QA (the QA user's PAT, which the + # maintainer must grant write+merge scope). ISSNUM is the origin issue (resolved from the branch). + if [ "$NAME" = "qa" ] && [ "$AUTOPILOT" = "true" ]; then + if grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then + if [ -z "$IS_PR" ]; then + echo "MERGE_PR marker but this run is not on a PR thread — skipping merge" + else + echo "@qa autopilot: merging PR #$NUM (origin issue #${ISSNUM:-$NUM})" + mc=$(curl -sS -o /tmp/merge_resp.txt -w '%{http_code}' -X POST "${hdr[@]}" \ + "$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:]]*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** — šŸ›‘ found a problem, so I did NOT merge. Removed the `autopilot` label (back to human control). @ffaerber please decide next steps (details above).')" + fi + exit 0 + fi + # Auto-delegate: if the plan names a teammate, trigger them via AGENT_TOKEN (a PAT, so it # fires a new workflow run — the built-in token cannot). Never targets @pm or self, so the # chain always terminates at a dev. The 'šŸ¤–' guard on the trigger stops status-comment loops. @@ -185,6 +239,17 @@ prpost() { 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 halt and remove the label if you find a problem." '{body:$b}')" \ + -w '\ntrigger-qa -> HTTP %{http_code}\n' || true + fi 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). diff --git a/.gitea/workflows/scripts/route.sh b/.gitea/workflows/scripts/route.sh index f4452f9..32328dd 100755 --- a/.gitea/workflows/scripts/route.sh +++ b/.gitea/workflows/scripts/route.sh @@ -51,8 +51,10 @@ git config user.name "$name" git config user.email "$name@ffaerber.duckdns.org" API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json") +branch_ref="" if [ -n "$IS_PR" ]; then # comment on a PR -> resume its branch ref=$(curl -s -H "Authorization: token $GT" "$API/pulls/$NUM" | jq -r .head.ref) + branch_ref="$ref" git fetch origin "$ref" && git checkout "$ref" { echo "branch=$ref"; echo "new=false"; } >> "$GITHUB_OUTPUT" elif git ls-remote --exit-code --heads origin "ai/issue-$NUM" >/dev/null 2>&1; then @@ -72,3 +74,18 @@ else # comment on an issue, no branch ye -d "$(jq -nc --arg b "šŸ”Ø **@$name** is on it — building on branch [\`ai/issue-$NUM\`]($url). I'll open a PR when it's ready." '{body:$b}')" >/dev/null || true fi fi + +# --- Autopilot gate: read the `autopilot` label FRESH every run. --- +# Presence of this label is the opt-in switch (and the kill switch: remove it mid-flight and the +# next run reverts to normal human-approval behavior). When @qa is triggered on a PR thread, the +# label lives on the ORIGIN issue (ai/issue-N), so resolve N from the branch name. +issnum="$NUM" +case "$IS_PR" in ?*) issnum=$(printf '%s' "$branch_ref" | sed -nE 's,^ai/issue-([0-9]+).*,\1,p');; esac +[ -z "$issnum" ] && issnum="$NUM" +autopilot=false +if curl -sS -H "Authorization: token $GT" "$API/issues/$issnum/labels" 2>/dev/null \ + | jq -e 'any(.[]?; .name=="autopilot")' >/dev/null 2>&1; then + autopilot=true +fi +echo "autopilot (autopilot label on #$issnum)=$autopilot" +{ echo "autopilot=$autopilot"; echo "issnum=$issnum"; } >> "$GITHUB_OUTPUT" diff --git a/.gitea/workflows/scripts/run-agent.sh b/.gitea/workflows/scripts/run-agent.sh index d913160..3903df4 100755 --- a/.gitea/workflows/scripts/run-agent.sh +++ b/.gitea/workflows/scripts/run-agent.sh @@ -3,8 +3,10 @@ # plain-text reply (/tmp/agent_out.md) plus the raw event stream (/tmp/events.jsonl). # # Required env (provided by the workflow step): -# ANTHROPIC_API_KEY AGENT_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH NUM TITLE IBODY CMT +# ANTHROPIC_API_KEY AGENT_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE +# IBODY CMT # FILES (the opencode -f image flags, from the imgs step output) +# AUTOPILOT is 'true' when the issue carries the `autopilot` label (label-gated autopilot mode). set -u [ -z "$CMT" ] && CMT="(a new issue was just opened — assess it)" @@ -51,6 +53,36 @@ if [ "$MODE" = "comment" ]; then END_SUBTASKS The automation creates the milestone + one sub-issue per line (each linked to this issue). It does NOT auto-start any dev — the maintainer @mentions an agent on each sub-issue when ready." + if [ "$AUTOPILOT" = "true" ]; then + ACTION="$ACTION + AUTOPILOT MODE IS ACTIVE (this issue carries the 'autopilot' label). This OVERRIDES the + two-phase approval gate above: do NOT ask '@ffaerber ready to start building?' and do NOT wait + for a 'yes'. When the task is clear, present your SHORT plan naming the best teammate to build it + AND end your reply with a 'DELEGATE: @' line in the SAME turn to hand off immediately. + Prefer @junior for small/low-risk (mostly YAML/compose/config), @senior/@lead for complex or + multi-file work. Only skip delegating (and instead ask @ffaerber) if the task is genuinely + ambiguous or unsafe — otherwise plan-and-delegate now." + fi + fi + if [ "$NAME" = "qa" ]; then + ACTION="$ACTION + As QA you verify a change works: read the PR/issue, drive the web app with your headless + browser if there is a URL, and report bugs or confirm behavior. You normally do NOT merge — + a human does that." + if [ "$AUTOPILOT" = "true" ]; then + ACTION="$ACTION + AUTOPILOT MODE IS ACTIVE (this issue/PR carries the 'autopilot' label). This grants you a + NARROW, one-time merge authority for THIS PR only: + - If, after verifying, the PR is correct and any CI checks are green, end your reply with EXACTLY + one line: 'MERGE_PR'. The automation will then merge the PR and close the linked issue for you. + Do NOT merge via any other means; only the MERGE_PR marker triggers the merge. + - If you find ANY bug, doubt, or the change is not clearly correct, do NOT merge. Instead describe + the problem clearly and end your reply with EXACTLY one line: 'HALT_AUTOPILOT'. The automation + removes the 'autopilot' label (returning this issue to normal human control) and leaves + it for @ffaerber to decide next steps. Never auto-bounce back to a dev. + Emit AT MOST one of MERGE_PR or HALT_AUTOPILOT, and only after you have actually verified. When in + doubt, prefer HALT_AUTOPILOT." + fi fi else ACTION="You start on git branch '${BRANCH}', with git and push credentials already configured. diff --git a/AGENTS.md b/AGENTS.md index d6c8856..cab0f69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,10 +11,26 @@ the loop guards. ## Golden rules - You may edit **any file**. **NEVER push to `main`, NEVER merge a PR** — all work goes on a branch and becomes a PR a human reviews and merges. + - **Narrow exception — `@qa` autopilot merge:** `@qa` (and only `@qa`) MAY merge a single PR **only** + when the linked issue carries the `autopilot` label, the PR is clearly correct, and any CI + checks are green. `@qa` triggers the merge by ending its reply with the `MERGE_PR` marker (the + workflow performs the merge + closes the issue). On **any** doubt or bug, `@qa` must NOT merge: + it ends with `HALT_AUTOPILOT` instead, which removes the `autopilot` label and returns the + issue to human control. No other agent may merge, and `@qa` may not merge without the label. - **Never print, exfiltrate, or invent secret values.** - Keep changes **minimal** and match the conventions already in the file you're editing. - Do the work on a **branch** — never paste code or diffs into the issue thread. +## Autopilot (`autopilot` label) +An issue labeled **`autopilot`** runs without the usual human checkpoints: +- `@pm` plans **and** delegates in the same turn (skips the "ready to build? reply yes" gate). +- After the dev's PR is opened, `@qa` is auto-triggered to verify it, and merges + closes on success + (see the QA merge exception above). +- **Kill switch:** remove the `autopilot` label at any time. The label is re-read fresh at the + start of every run, so the next agent turn reverts to normal human-approval behavior. `@qa` also + removes the label itself whenever it halts on a bug or a failed merge. +No label (the default) = today's behavior, unchanged. + ## Branches & pull requests Start on `ai/issue-`. Split independent changes into separate branches (one PR each). Commit and push incrementally. Do NOT open PRs yourself (automated). End your reply with the PR description