agents: PM-orchestrated flow — @pm hub, @qa reviews, human/@pm merges

Rework the delegation/trigger choreography so @pm orchestrates end-to-end from
the issue thread while @qa reviews on the PR and never merges.

Flow: issue → @pm plans (asks creator; autopilot skips) → dev builds PR → dev
pings @pm on the issue → @pm hands to @qa → @qa reviews ON THE PR (recommends /
BOUNCE:@dev, direct qa↔dev loop, 3 max) → @qa APPROVEs → verdict on the ISSUE →
@pm → normal: tells creator "ready to merge" (human merges); autopilot: @pm
MERGE_PR. @pm never reads or comments on the PR (small context); @qa never merges.

- run-agent.sh: @pm prompt = 3 phases (plan / hand PR to @qa / finalize),
  autopilot only drops the approval gate + adds @pm MERGE_PR. @qa prompt =
  reviewer, ends with APPROVE / BOUNCE:@dev / HALT; identical in both flows.
- publish.sh: shared post_to()/trig()/resolve_pr() helpers + ISSN; @qa routes
  reply to PR (bounce) vs issue (approve) and fires @pm/@dev; @pm handles
  DELEGATE:@dev, DELEGATE:@qa (resolves the PR), and autopilot MERGE_PR; dev
  NEW=true fires @pm on the issue, NEW=false fires @qa on the PR. Strip
  APPROVE/HALT markers.

Tokens re-minted to match: TOKEN_PM gains write:repository (it merges now),
TOKEN_QA drops to read:repository (never merges).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Felix Faerber
2026-07-05 21:06:39 +03:00
co-authored by Claude Opus 4.8
parent 01497e9ebe
commit 84cec444ef
2 changed files with 141 additions and 148 deletions
+96 -108
View File
@@ -32,6 +32,20 @@ hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
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$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() {
@@ -46,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 '
@@ -55,7 +69,8 @@ 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 }
@@ -81,6 +96,39 @@ prdesc=$(awk '/BEGIN_PR_DESCRIPTION/{f=1;next} /END_PR_DESCRIPTION/{f=0} f' /tmp
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' "${PRN:-?}" "$reply")"
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" # 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' "$reply")"
else
post_to "${PRN:-$NUM}" "$reply" # 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"
@@ -127,91 +175,48 @@ if [ "$MODE" != "pr" ]; then
fi
post "$(printf '%s%s' "$msg" "$subtext")"
# --- AUTOPILOT: @qa's narrow, label-gated merge / halt authority ---
# Only @qa, only when 'autopilot' is set, and only on a PR thread. The MERGE_PR / HALT_AUTOPILOT
# markers come from the QA prompt. Merge + label ops use TOKEN_QA (the QA user's PAT, which the
# maintainer must grant write+merge scope). ISSNUM is the origin issue (resolved from the branch).
if [ "$NAME" = "qa" ] && [ "$AUTOPILOT" = "true" ]; then
if grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then
if [ -z "$IS_PR" ]; then
echo "MERGE_PR marker but this run is not on a PR thread — skipping merge"
else
echo "@qa autopilot: merging PR #$NUM (origin issue #${ISSNUM:-$NUM})"
# Merge with TOK (a PAT) — NOT the built-in Actions token — so the resulting push to
# main TRIGGERS downstream workflows (e.g. deploy). A merge made with the built-in GITEA_TOKEN
# does not fire new runs (loop-prevention), which silently skips the deploy. Fall back to the
# agent's own token only if TOK isn't set (then the deploy would need a manual run).
mtok="$TTOK"
mc=$(curl -sS -o /tmp/merge_resp.txt -w '%{http_code}' -X POST \
-H "Authorization: token $mtok" -H "Content-Type: application/json" \
"$API/pulls/$NUM/merge" -d '{"Do":"merge"}')
echo "merge -> HTTP $mc"; cat /tmp/merge_resp.txt 2>/dev/null || true
case "$mc" in
200|201|204)
echo "closing origin issue #${ISSNUM:-$NUM}"
curl -sS -X PATCH "${hdr[@]}" "$API/issues/${ISSNUM:-$NUM}" \
-d '{"state":"closed"}' -w '\nclose -> HTTP %{http_code}\n' || true
post "$(printf '✅ Verified & merged PR #%s (autopilot). Closed issue #%s.' "$NUM" "${ISSNUM:-$NUM}")"
;;
*)
# Merge failed (checks not green, conflicts, or TOKEN_QA lacks merge scope) — do NOT
# silently proceed: drop the label so it reverts to human control and report.
del_autopilot_label "${ISSNUM:-$NUM}"
post "$(printf '⚠️ Tried to merge PR #%s but the API returned HTTP %s (checks not green, a conflict, or missing merge permission on TOKEN_QA). Removed the `autopilot` label — @ffaerber please take a look.' "$NUM" "$mc")"
;;
esac
fi
elif grep -qiE '^[[:space:]]*BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md; then
# @qa wants the dev to fix something. Send it back — never fix it ourselves. After 3 bounces,
# stop and hand to the human. QA's feedback is already posted (the reply comment above).
if [ -z "$IS_PR" ]; then
echo "BOUNCE marker but this run is not on a PR thread — skipping"
else
target=$(grep -oiE 'BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md | head -1 \
| grep -oiE '(junior|senior|lead)' | tr '[:upper:]' '[:lower:]')
[ -z "$target" ] && target=$(curl -sS "${hdr[@]}" "$API/pulls/$NUM" | jq -r '.user.login // "junior"')
# Count how many times this PR has already been bounced (marker in the trigger comment).
prior=$(curl -sS "${hdr[@]}" "$API/issues/$NUM/comments?limit=100" \
| jq -r 'if type=="array" then [.[]|select(.body|test("autopilot fix attempt"))]|length else 0 end' 2>/dev/null)
prior=${prior:-0}
if [ "$prior" -ge 3 ]; then
echo "@qa autopilot: 3 bounces already — halting"
del_autopilot_label "${ISSNUM:-$NUM}"
post "$(printf '🛑 Still not right after 3 fix attempts. Stopping autopilot (removed the `autopilot` label). @ffaerber please take over — details in the comments above.')"
else
n=$((prior + 1))
echo "@qa autopilot: bounce $n/3 -> @$target"
curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$NUM/comments" \
-d "$(jq -nc --arg b "@$target please address @qa's feedback above and update this PR (autopilot fix attempt $n/3)." '{body:$b}')" \
-w '\nbounce -> HTTP %{http_code}\n' || true
fi
fi
elif grep -qiE '^[[:space:]]*HALT_AUTOPILOT[[:space:]]*$' /tmp/agent_out.md; then
echo "@qa autopilot: HALT — removing 'autopilot' label from #${ISSNUM:-$NUM}"
del_autopilot_label "${ISSNUM:-$NUM}"
post "$(printf '🛑 This needs a human decision (not a dev fix). Removed the `autopilot` label (back to human control). @ffaerber please decide next steps (details above).')"
# --- @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 TOK (a PAT, so it
# fires a new workflow run — the built-in token cannot). Never targets @pm or self, so the
# chain always terminates at a dev. The '🤖' guard on the trigger stops status-comment loops.
if [ -n "$TTOK" ]; then
# Only delegate on an explicit "DELEGATE: @<agent>" line — never on a prose mention,
# so an agent that is asking the maintainer a question does not hand off prematurely.
target=$(grep -oiE 'DELEGATE:[[:space:]]*@(junior|senior|lead|qa)' /tmp/agent_out.md 2>/dev/null \
| head -1 | grep -oiE '(junior|senior|lead|qa)' | tr '[:upper:]' '[:lower:]')
if [ -n "$target" ] && [ "$target" != "$NAME" ]; then
echo "auto-delegating to @$target"
curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$NUM/comments" \
-d "$(jq -nc --arg b "@$target please proceed with issue #$NUM per the plan above (delegated by $NAME)." '{body:$b}')" \
-w '\ndelegate -> HTTP %{http_code}\n' || true
# --- @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
@@ -288,32 +293,15 @@ prpost() {
}
if [ "$NEW" = "true" ]; then
prpost "$prnum" "$(printf '✅ PR ready for review — @ffaerber please review & merge:\n- %s%s' "$url" "$activity")"
# AUTOPILOT: hand the fresh PR to @qa automatically (via TOK, so it fires a new run).
# @qa then verifies and — if green — merges + closes via its MERGE_PR marker. The comment lands
# on the PR thread ($prnum) so the next run resolves the origin issue's label from the branch
# name. The '🤖' guard on the trigger gate stops status-comment loops.
if [ "$AUTOPILOT" = "true" ] && [ -n "$TTOK" ] && [ -n "$prnum" ]; then
echo "autopilot: auto-triggering @qa to review PR #$prnum"
curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$prnum/comments" \
-d "$(jq -nc --arg b "@qa please verify this PR (autopilot: issue #$NUM is labeled autopilot). Merge it if correct, or bounce it back to the dev with exactly what needs fixing." '{body:$b}')" \
-w '\ntrigger-qa -> HTTP %{http_code}\n' || true
fi
# 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 'Pushed an update to the PR:\n- %s%s' "$url" "$activity")"
# AUTOPILOT: after a dev pushes a fix (e.g. following a @qa bounce), hand back to @qa to re-verify.
if [ "$AUTOPILOT" = "true" ] && [ -n "$TTOK" ] && [ -n "$prnum" ]; then
case "$NAME" in
junior|senior|lead)
echo "autopilot: dev pushed a fix — re-triggering @qa to re-verify PR #$prnum"
curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$prnum/comments" \
-d "$(jq -nc --arg b "@qa please re-verify this PR (autopilot). Merge it if now correct, or bounce it back with exactly what still needs fixing." '{body:$b}')" \
-w '\ntrigger-qa -> HTTP %{http_code}\n' || true
;;
esac
fi
# 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