Imported from dustinkeeton/wafflestack (
stacks/orchestration/skills/autopilot/SKILL.md). Install upstream withnpx skills add dustinkeeton/wafflestack --skill autopilot. Copyright stays with the author.
Autopilot — plan → implement → PR over the backlog
The primitives for an autonomous agency loop already exist and this skill is the only thing that composes them: /issue writes specs, /delegate turns issues into PRs, the required doctor check gates merges, and delegate.autoMerge proves post-green merge works. Autopilot runs the whole cycle over a supplied list of issues so a human no longer has to drive plan → accept → delegate → accept → merge → repeat by hand.
Autopilot is a prose playbook that composes other skills — it adds an orchestration loop and a plan→implement handoff on top of machinery that already exists. It does not re-implement classification, worktree isolation, PR creation, or board sync: those are the delegate skill's job, invoked here in batch mode. It does not re-implement branch/worktree teardown: that is the clean-up skill's job. Read those skills; this one only wires them together and adds the per-issue plan artifact.
Cross-referenced, never duplicated:
delegate— the per-issue/group engine. Autopilot runs delegate's Phases 1–5 withdelegate.batchModeengaged (the supplied scope stands in for the human's plan approval) anddelegate.autoMergeset to the run's consent (or forced off when the QA gate, the review loop, or the audit step is on — see Step 3). Everything about classification, parallel-vs-serial grouping, checkpoints, per-issue PRs, and board updates is delegate's, unchanged.qa+pr-response— the opt-in QA gate (Step 5).qa <PR#>checks a green PR against the linked issue's intent and acceptance criteria — best-effort running the tests, exercising the changed behavior, and assessing the diff's test coverage — posts its findings as one PR review (marker<!-- waffle-qa -->, distinct from the adversarial-review skill's), and returns a per-severity count;pr-response <PR#> --yesdisposes of the findings exactly as in the review loop, and its implemented count is the convergence signal. Autopilot only wires these in when QA-gate consent is on; it never re-implements the QA or fix application.adversarial-review+pr-response— the opt-in review loop (Step 6).adversarial-review <PR#>posts hostile-reviewer findings on a green PR (marker<!-- waffle-adversarial-review -->) and returns a per-severity count;pr-response <PR#> --yesscores each finding on its rubric, implements the accepted fixes on the PR branch and pushes, defers/declines the rest, and returns per-verdict counts — the implemented count is the convergence signal the loop reads. Autopilot only wires these in when review-loop consent is on; it never re-implements review, scoring, or fix application.audit— the opt-in final gate (Step 7)./audit <focus>runs the passes as theauditskill documents them, including itsdocsinvocation (architecture → security pass 1 → {{audit.complianceLabel}} → thedocsskill, invoked → security pass 2) — named agents that apply fixes where their role grants edit tools, and it gates after security pass 1 on Critical/High findings. Autopilot composes this playbook itself — the skill isdisable-model-invocation: true, so it is never auto-invoked — scopes it to the PR's diff, owns the audit agents' lifecycle (spawn → run → tear down withshutdown_requestthenTaskStop, even on failure), and hard-gates the merge on unresolved Critical/High findings. Autopilot only wires it in when audit-step consent is on; it never re-implements the audit passes.issue— files the{{autopilot.holdLabel}}-labeled follow-up issue when the QA gate or the review loop can't converge within its run-effective round cap (defaults{{autopilot.maxQaRounds}}/{{autopilot.maxReviewRounds}}rounds, per-run override via{{autopilot.qaLoop.flag.on}}:N/{{autopilot.reviewLoop.flag.on}}:N), so the unresolved findings are tracked rather than lost. The brief is sourced from a fresh post-cap review pass (see Steps 5–6), a clean fresh pass skips the filing — it is the convergence evidence — and when a later fix loop is enabled the filing itself defers to the last one (Step 5's hatch hands its fresh-pass findings to Step 6's triage instead of filing).hygiene— the reference for the auto-merge guardrails autopilot inherits verbatim (gh pr merge --auto --merge, arm-only-on-a-required-check, never fall back to an immediate or--adminmerge).clean-up— post-merge git/harness teardown (clean-up git --yesright after a merge; the confirm-first full sweep at the end of the run).git-workflow— branch/commit/PR conventions and the After a PR merges close-out (verify the issue closed, reconcile the board, clean the debris) that autopilot performs on every merge.
Instantiation contract
Capture all five of these before any work starts — they are the run's entire mandate. If any is missing from the invocation arguments, ask for it with AskUserQuestion; never guess.
1. Issue scope — REQUIRED
An explicit set of issues. This is not optional and has a second job: it is what activates delegate.batchMode. Batch mode only skips delegate's interactive plan-approval gate when the invoker supplied explicit scope — the explicit scope is the standing-in-for-a-human approval. An autopilot run therefore cannot be unscoped. Accepted forms (they map one-to-one onto delegate's Phase 1 argument handling):
| Scope form | Example | Passed to delegate as |
|---|---|---|
| Explicit issue list | #12 #14 #17 |
the issue numbers |
| Milestone | milestone:v1.1.0 |
milestone:<name> |
| Label | bug, enhancement |
the label |
| All open issues | all-open |
the all-open default scope |
If the invocation carries no scope, stop and ask for one with AskUserQuestion (offer "the current milestone", "all open issues", or "a specific list"). Never default to all-open silently, and never start a run whose scope is empty — an unscoped run cannot activate batch mode and would fall back to interactive confirmation, which defeats the purpose.
Hold-labeled issues are out of automatic scope. An issue carrying the hold label {{autopilot.holdLabel}} — the label autopilot applies to its own can't-converge QA/review follow-ups and audit-blocked follow-ups (see Steps 5–7) — is excluded from every automatic scope form: all-open, a label, and a milestone. It is held for human triage and re-enters a run only when the invoker names it explicitly by #N. So when you resolve a label / milestone / all-open scope, filter out any issue that also carries {{autopilot.holdLabel}}; an explicit issue list is taken as-is, because naming #N is the human action that releases a held issue.
Reassess-held issues are out of automatic scope too. The same filter applies to {{issue.reassessLabel}} — the hold a human applies when an issue needs re-evaluation/reconfirmation before further action (nothing in the toolkit applies or removes it; the human removing it is the reconfirmed signal). Drop any issue carrying it from an all-open / label / milestone scope alongside the {{autopilot.holdLabel}} exclusion, count what you held out for the run report, and treat an explicit #N that carries it as a request for reassessment, not implementation: delegate's reassessment gate re-verifies the issue's premises against main, posts the result as an issue comment, and holds the issue — batch mode never implements it. The human removes the label to release it.
2. Auto-merge consent — per-run, explicit, locked to prompt
Whether this run may arm gh pr merge --auto --merge on the PRs it opens. On only when the invoker opts in for this run — {{autopilot.autoMerge.flag.on}} in the arguments, or an affirmative answer to the AskUserQuestion you raise when it is unspecified.
Rendered gate for this repo: {{autopilot.autoMerge}} — the value after .waffle/waffle.local.yaml → .waffle/waffle.yaml → the stack default (prompt). The key is lockMode: prompt, so that chain can only ever yield prompt: a true or false in either config file fails render and bare doctor. That declaration — not a remembered preference, a checkpoint, or the memory doc — is what makes consent per-run; never read a prior run's answer back from any of those. With the rendered prompt:
| Invocation | Human-attended run | Non-interactive caller (a CI job, a subagent) |
|---|---|---|
{{autopilot.autoMerge.flag.on}} in the arguments |
On for this run. | On for this run. |
| No token | Ask with AskUserQuestion — never assume. |
Off — the key's nonInteractive: false fallback: every PR is left for a human. |
- Consent off → every PR is opened and left for human review; autopilot never merges it. This is still a complete run: the final outcome of each issue is a PR.
- Consent on → autopilot sets
delegate.autoMergefor the run so each PR arms itself on green, under the exact guardrails below — unless the QA gate (§5), the review loop (§3), or the audit step (§4) is also on, in which case arming is deferred out of the delegate run and autopilot arms the PRs itself after the last gate that is on (the QA gate, then the review loop, then — when it is on — the audit gate, which is always last). See Steps 3, 5, 6, and 7. - What arming needs —
--autoonly arms when all three hold: (1) the repo has "Allow auto-merge" enabled; (2) a required status check is configured on the base branch; (3) that check needs branch protection or a ruleset, which on GitHub Free exists only for public repos — a private repo needs GitHub Pro / Team / Enterprise. Otherwise--autohas nothing to wait on and the PR is left open-but-not-armed.
3. Review-loop consent — per-run, separate from auto-merge, locked to prompt
Whether this run runs the adversarial-review → pr-response loop (Step 6) on each PR after it goes green — the automated review gate between "green" and "merged". On only when the invoker opts in for this run ({{autopilot.reviewLoop.flag.on}} in the arguments, or an affirmative answer to the AskUserQuestion you raise when it is unspecified, exactly as for auto-merge; when both consents are unspecified you may cover them in one AskUserQuestion). Rendered gate for this repo: {{autopilot.reviewLoop}} — same lockMode: prompt declaration, precedence, and resolution table as auto-merge (§2): {{autopilot.reviewLoop.flag.on}} is the token, and a non-interactive caller without it resolves to off (nonInteractive: false).
{{autopilot.reviewLoop.flag.on}}:Nsets this run's round cap. The consent flag may carry an optional per-run round count via the same colon syntax asmilestone:<name>:{{autopilot.reviewLoop.flag.on}}:3consents to the loop AND caps it at 3 rounds for this run. Bare{{autopilot.reviewLoop.flag.on}}keeps the rendered default —autopilot.maxReviewRounds, currently{{autopilot.maxReviewRounds}}.Nmust be a positive integer (N >= 1); a zero, negative, or non-numeric count ({{autopilot.reviewLoop.flag.on}}:0,{{autopilot.qaLoop.flag.on}}:deep) is malformed — treat that flag as unspecified (consent and cap both) and ask withAskUserQuestion, per this contract's ask-for-it-never-guess rule: never start a zero-round loop and never guess a cap. When consent is captured viaAskUserQuestion, capture the round count in the same exchange — offer the rendered default plus sensible alternatives (1 for a cheap sanity pass on a trivial batch, 3–4 for a deeper pass on a risky batch) or accept a custom value. The effective cap is as per-run as the consent itself — it applies to this invocation only; the rendered default governs every future run. The:Nsuffix is parsed here, by this skill: it overrides the value keyautopilot.maxReviewRoundsfor the run and is no part of the consent key'sflag:declaration, which names only the bare token.- Independent of auto-merge consent. The two opt-ins are captured separately and neither implies the other. The loop can run on a PR that will then be armed (auto-merge also on) OR on one that will be left for a human (auto-merge off) — in the latter case it cleans the PR up before human review.
{{autopilot.reviewLoop.flag.on}}with no{{autopilot.autoMerge.flag.on}}is a valid, useful mandate, and so is{{autopilot.autoMerge.flag.on}}with no{{autopilot.reviewLoop.flag.on}}(today's behavior). - Consent off → no review loop runs, and arming happens wherever the other gates put it: with the QA gate (§5) and audit step (§4) also off, Step 3 arms auto-merge inline when that is consented; when either of those gates is on, arming defers to the last of them (the QA gate, Step 5, or — when it is on — the audit gate, Step 7). This change is invisible unless you opt in.
- Consent on → autopilot defers auto-merge arming out of Step 3 and runs the loop (Step 6) on each green PR, arming afterward only if auto-merge was also consented and the audit step (§4) is off; when the audit step is on, arming defers further still, to after the audit gate (Step 7), since the audit gate is always the last gate.
4. Audit-step consent — per-run, separate from auto-merge and the review loop, locked to prompt
Whether this run runs the /audit gate (Step 7) on each PR as the final pre-merge quality gate — a whole-codebase security sweep and a machine/human docs refresh, scoped to the PR's diff, after the QA gate and the review loop and before the merge-wait. On only when the invoker opts in for this run ({{autopilot.auditStep.flag.on}} in the arguments, or an affirmative answer to the AskUserQuestion you raise when it is unspecified; when the consents are unspecified you may cover all of them in one AskUserQuestion). Rendered gate for this repo: {{autopilot.auditStep}} — same lockMode: prompt declaration, precedence, and resolution table as auto-merge (§2): {{autopilot.auditStep.flag.on}} is the token, and a non-interactive caller without it resolves to off (nonInteractive: false).
- Independent of auto-merge consent and of review-loop consent. The opt-ins are captured separately and none implies another — any combination may be on. The audit gate can run on a PR that will then be armed (auto-merge also on) or one left for a human (auto-merge off), and with or without the QA gate (§5) or the review loop (§3) ahead of it.
{{autopilot.auditStep.flag.on}}alone is a valid, useful mandate. - Consent off → no audit gate runs; the per-issue flow is exactly as #220 left it, and arming happens wherever Step 3 / Step 5 / Step 6 put it. This change is invisible unless you opt in.
- Consent on → autopilot defers auto-merge arming past the QA gate and the review loop too, out to the audit gate (Step 7): it waits for the PR to go green, composes
/auditscoped to the diff, lands the fixes and re-waits for green, then hard-gates on unresolved Critical/High findings — blocking the merge (and filing a{{autopilot.holdLabel}}follow-up) even if auto-merge was consented — and only arms auto-merge, if consented, once the gate passes green. The audit gate is always the last gate.
5. QA-gate consent — per-run, separate from the other consents, locked to prompt
Whether this run runs the qa → pr-response loop (Step 5) on each PR after it goes green — the functional-QA gate that checks each change against its linked issue's intent and test coverage, the way /qa and /pr-response do by hand. It is the fifth contract entry, but its step runs first among the gates — the pipeline order is fixed: QA gate (Step 5) → review loop (Step 6) → audit gate (Step 7). On only when the invoker opts in for this run ({{autopilot.qaLoop.flag.on}} in the arguments, or an affirmative answer to the AskUserQuestion you raise when it is unspecified; when the consents are unspecified you may cover all of them in one AskUserQuestion). Rendered gate for this repo: {{autopilot.qaLoop}} — same lockMode: prompt declaration, precedence, and resolution table as auto-merge (§2): {{autopilot.qaLoop.flag.on}} is the token, and a non-interactive caller without it resolves to off (nonInteractive: false).
{{autopilot.qaLoop.flag.on}}:Nsets this run's QA round cap — same colon syntax,N-validation, same-exchangeAskUserQuestioncapture, and per-run rule as{{autopilot.reviewLoop.flag.on}}:N(§3); the suffix is likewise skill-parsed, overriding the value keyautopilot.maxQaRoundsfor the run. Bare{{autopilot.qaLoop.flag.on}}keeps the rendered default —autopilot.maxQaRounds, currently{{autopilot.maxQaRounds}}.- Independent of the other three consents. All four opt-ins — auto-merge, the QA gate, the review loop, the audit step — are captured separately and none implies another; any combination may be on. The QA gate can run on a PR that will then be armed (auto-merge also on) or one left for a human (auto-merge off), and with or without the review loop (§3) or the audit gate (§4) after it.
{{autopilot.qaLoop.flag.on}}alone is a valid, useful mandate. - Consent off → no QA gate runs; the per-issue flow is exactly as #221 left it, and arming happens wherever Step 3 / Step 6 / Step 7 put it. This change is invisible unless you opt in.
- Consent on → autopilot defers auto-merge arming out of Step 3 (it withholds arming from the delegate run) and runs the QA loop (Step 5) on each green PR, arming afterward only if auto-merge was also consented and both the review loop (§3) and the audit step (§4) are off; when either later gate is on, arming defers further still, to the last gate that is on.
Record the captured values (scope + auto-merge consent + review-loop consent with its effective round cap + audit-step consent + QA-gate consent with its effective round cap) at the top of your run so the report can restate the mandate the run actually operated under.
Per-issue loop
Process the scoped issues serially by default, parallel only for a group delegate's own area rules deem safe (disjoint areas, no root/shared files, no dependency language — see delegate's parallelization rules; autopilot does not second-guess them). For each issue (or delegate-computed group), run these steps in order.
Step 1 — Plan context (one context, one written plan)
Spawn one dedicated planning context for the issue whose sole deliverable is a written implementation plan file — the same shape plan mode produces. It reads the issue and the relevant source, then writes the plan to:
{{autopilot.planDir}}/issue-<N>.md
Create the directory first (mkdir -p {{autopilot.planDir}}); it sits under {{git.worktreesDir}} so it inherits that gitignore — the plan files are throwaway run artifacts, not committed. Use a read-only planner (e.g. the Plan agent or a general-purpose agent told not to edit source): its job is to think, not to implement. The plan should name the files to touch, the approach, the risks, and the test surface — enough for a fresh context to act on without re-deriving the whole issue.
For a parallel group, plan each issue in the group first (these planning contexts can run concurrently — they only read and write their own plan file).
Plan-ahead overlap (optional). The planning context is read-only and writes only its own throwaway plan file under {{autopilot.planDir}} (gitignored), so it is conflict-free by construction against any in-flight PR's gates. While PR N is in its gate loops (Steps 5–7) or merge-wait (Step 8), the orchestrator MAY spawn issue N+1's Step 1 planning context so N+1's plan file is ready the moment N merges. This overlap is a scheduling optimization only and changes none of the serial semantics:
- Read-only. The plan-ahead planner is exactly the Step 1 read-only planner run early — it never edits source and never provisions a worktree, so it touches nothing the in-flight PR's gates or
pr-response//auditfixes can collide with. - Serial handoff is unchanged. N+1's plan is still handed to a fresh implementer only after Step 8 confirms
MERGEDfor a dependent chain (Step 2's handoff and Step 8's merge-wait are untouched) — planning ahead never starts implementing ahead. - The plan stays a brief, not a contract. If PR N's gates change the ground the plan stood on (e.g. a semantic fix push), Step 2's authority language already covers the staleness — the fresh implementer owns the outcome, not fidelity to a plan drafted against pre-merge state.
Step 2 — Handoff to a fresh implementer (the plan is a brief, not a contract)
The implementer is a fresh context — the delegate-spawned agent — that receives the plan file's content in its prompt. Autopilot is the orchestrating context that runs the delegate playbook, so it controls the prompt delegate hands each agent: inject the plan as an extra section of delegate's agent-prompt template, immediately above its Instructions:
## Implementation plan (a brief, not a contract)
{full contents of {{autopilot.planDir}}/issue-<N>.md}
This plan is a **brief, not a contract.** You have full authority to adjust,
extend, or depart from it as the code demands — reality wins over the plan.
If you diverge substantially, say so in your PR body and commit messages.
The authority language is load-bearing: a plan written by one context against a codebase it only read can be wrong once the implementer is in the files. The implementer owns the outcome, not fidelity to the plan.
Step 3 — Implement → PR (delegate batch mode)
Run the delegate playbook for this issue/group with two behaviors engaged — autopilot is the context executing delegate, so it applies these regardless of the rendered defaults baked into the delegate skill:
delegate.batchModeengaged. The supplied scope is explicit, so delegate's Phase 3 confirmation gate is skipped — the plan table is logged, not paused on, and an ambiguous classification falls back to the safest choice (serial execution in the main checkout) rather than pausing. This is exactly delegate's documented batch-mode behavior; autopilot supplies the explicit scope that authorizes it.delegate.autoMerge= this run's consent — unless the QA gate (§5), the review loop (§3), or the audit step (§4) is on. With all three off this is unchanged: consent on → each agent arms its PR right aftergh pr create; off → each PR is left for a human. With any of the QA gate, the review loop, or the audit step on, autopilot withholds arming from delegate — it setsdelegate.autoMergeoff for the delegate run even when auto-merge was consented, because a PR armed here would merge on green before those gates run, leavingpr-response(or/audit) a merged PR it cannot fix. Autopilot arms those PRs itself after the last gate that is on — the QA gate (Step 5) when only it is on, the review loop (Step 6) when it is on and the audit step is off, or the audit gate (Step 7) whenever the audit step is on — if auto-merge was consented.
Delegate does the rest unchanged: classify, group, provision worktrees for parallel groups, spawn the implementer(s) with the plan-injected prompt, create one PR per issue, sync the board. The final outcome of every issue is always a PR — that is the invariant autopilot preserves.
delegate.approveBeforePush is orthogonal and not weakened: if a repo runs with that pre-push gate on, batch mode still stops each agent before git push exactly as in interactive mode. Autopilot silences only the plan pause, never the push gate.
Implement-ahead overlap (optional). When the next scoped issue is independent of the in-flight one under delegate's own parallelization rules (disjoint areas, no root/shared files, no dependency language — the same rules that let delegate parallelize a group; autopilot does not second-guess them), the orchestrator MAY start issue N+1's implementer — Steps 2–3, a fresh context with N+1's plan injected — in its own delegate-provisioned worktree while PR N is still in its gate loops (Steps 5–7) or merge-wait (Step 8), so N+1's PR is already open the moment N merges. Like the Step 1 plan-ahead overlap this is a scheduling optimization only: it permits early implementation (worktree + PR creation) for an independent next issue and changes none of the gate or merge semantics:
- Independence is the precondition. This path is available only when delegate's area rules would treat N+1 as parallel-safe against N. A serial-dependent N+1 is excluded — it still waits for Step 8's
MERGEDexactly as today; autopilot never starts a dependent issue's implementation against unmerged upstream state. - Worktree-per-issue, even on the "serial" path. The default serial path implements in the main checkout; implement-ahead instead requires delegate's worktree provisioning for N+1 (the same isolation delegate gives a parallel group), because N's checkout is busy with its gate fixes. Autopilot runs delegate's Steps 2–3 for N+1 exactly as for any worktree-isolated issue.
- Gates stay serial — the merge boundary is the gate. N+1's PR is created early, but its gate loops (Steps 5–7) do NOT start until PR N reads
MERGEDand Step 9 housekeeping has run. Merge-order serialization at the gate boundary is what keeps this repo's known root-docs/lock conflict mode contained; implement-ahead never overlaps two PRs' gates. - Rebase onto merged main before N+1's gates start. N+1 implemented against pre-merge main while N's gate fixes were still landing — an accepted, bounded base staleness — so once PR N is
MERGEDand Step 9 has run, rebase N+1's branch onto the freshly merged main before its first enabled gate's green-wait begins (or, if no gate is on for this run, before N+1's own merge). Nearly every PR touchesCHANGELOG.mdand the.wafflelock, so this pre-gate rebase is the reconciliation point; only after it (and its re-push) does N+1 enter its gates. - Step 8 and serial-dependent semantics are untouched. The merge-wait (Step 8) and the "a dependent serial issue waits for
MERGED" rule are exactly as today — implement-ahead only adds an earlier start for an independent N+1's implementation, nothing more.
Step 4 — Verify the PR (never assume)
After the implementer(s) for the issue/group finish, verify each PR directly — silent specialist agents finish without reporting, so confirm from GitHub, not from a report:
# A PR exists for the branch delegate assigned this issue:
gh pr list --head <branch-name> --json number,url,state
-
No PR → the implementer did not land the work. Treat it as a failed attempt for this issue (see Failure handling); do not proceed to merge-wait or housekeeping for a PR that does not exist.
-
Auto-merge consented AND the QA gate, the review loop, and the audit step are all off → arming happened inline in Step 3, so also verify the PR is actually armed; don't assume
gh pr merge --autosucceeded:gh pr view <pr> --json autoMergeRequest -q '.autoMergeRequest != null' # expect truefalsemeans the PR is open but not armed (auto-merge disabled on the repo, no required check on the base branch, or no branch protection / ruleset to hold one — on GitHub Free, private repos have none). Record it as open-but-not-armed and surface it in the report — it will not merge itself, so a human still has to. Never react to a failed arm by merging immediately or with--admin. -
QA gate, review loop, or the audit step on → the PR is intentionally not armed yet at this point (Step 3 deferred arming to the last gate that is on), so skip the arm check here even when auto-merge was consented — a
falsenow is expected, not a failure. The last gate arms and verifies the PR: the QA gate (Step 5) when only it is on, the review loop (Step 6) when it is on and the audit step is off, or the audit gate (Step 7) whenever the audit step is on.
Step 5 — QA → respond loop (opt-in)
Runs only when QA-gate consent is on (§5); when it is off, skip straight to Step 6 (the review loop, itself opt-in) with auto-merge arming left wherever Step 3 put it. This is the functional-QA gate between "green" and "reviewed" — it checks each PR the way /qa and /pr-response do by hand, verifying the change against the linked issue's intent and acceptance criteria, running the tests, and assessing whether the diff carries real test coverage — the questions that neither the required doctor check (render-matches-lock only) nor the adversarial review (a hostile correctness/design review of the diff, Step 6) asks.
Auto-merge is not yet armed on this PR — Step 3 deferred arming precisely so a green PR can't merge before it is QA'd.
Persistent gate agents — spawn once, resume across rounds. The two halves of this loop are named agents, not a fresh invocation per round:
-
Round 1 spawns them.
Agent(name: "qa-pr<N>", …)runsqa <pr>;Agent(name: "respond-qa-pr<N>", …)runspr-response <pr> --yes. Each agent's prompt is exactly the skill invocation the round below specifies — nothing about what the skills do changes. A barename:is all they need: no team, and noisolation(pr-responsefixes belong in the PR's own checkout). Name them per PR and per loop (<N>in these names is the PR number — the same number the commands write as<pr>, not an issue number) so the execution log shows which loop an agent belongs to and shows the same agent recurring across rounds. Spawn the reviewer at the round's start; spawn the responder lazily, at the first round whose head carries findings to triage — any untriaged review with findings: its own review's, another gate's review already sitting on that head (a hook-armed repo's — see below), or a human's. The trigger is untriaged findings on the PR, never merely this round's reviewer surfaced some: a QA pass that finds nothing of its own does not license merging someone else's untriaged findings. A review is a trigger only when it carries findings — a bare approval, or a comment raising none, is nothing to triage. And a human's review is unmarked, so no marker test could find it anyway: read the PR's reviews, not just the marked ones (pr-responsetriages human findings when it runs and records them in the same verdict table). Only a round whose head has nothing left to triage converges with the responder never spawned.Read triage state from the
waffle/pr-responseCOMMIT STATUS — never from a comment body — and compare it against the responder's READ CUTOFF, never against mere existence and never against the status's own clock. A reviewR(withcommit_idandsubmitted_at) is triaged if and only if awaffle/pr-responsesuccess status onR.commit_idcarries atriaged-throughcutoff at or afterR.submitted_at:# R.commit_id → $SHA ; R.submitted_at → $SINCE (both straight off the reviews API) gh api "repos/$OWNER/$REPO/commits/$SHA/statuses" --paginate \ | jq -s --arg since "$SINCE" 'add // [] | [ .[] | select(.context=="waffle/pr-response" and .state=="success") | (.description // "") | select(startswith("triaged-through=")) | ltrimstr("triaged-through=") | select(test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) | select(. >= $since) ] | length'Expect ≥ 1 for triaged.
pr-responsewritesdescription=triaged-through=<ISO-8601 UTC>, where the cutoff is the newestsubmitted_atit saw when it read the findings — so the status asserts "I read every review submitted up to this moment", which is exactly the question this gate asks. Both values are ISO-8601 UTC (2026-07-12T22:02:22Z), so a plain string>=orders them. A status description takes repo push access to write, exactly like the status itself: no body can forge it.Fail closed: no status, no parseable cutoff, or a cutoff older than the review ⇒ UNTRIAGED ⇒ spawn the responder. A redundant triage round costs one cheap round; a skipped one merges live findings.
THE
test(…)VALIDATION IS THE FAIL-CLOSED PROMISE — WITHOUT IT THE GATE FAILS OPEN.startswith("triaged-through=")proves only that a prefix is present; whatever follows is compared as a raw string, and ISO dates begin with a digit. So any token sorting above ASCII digits certifies everything on that head:triaged-through=now,=null,=unknowneach read as TRIAGED — against a review from 2099, even. That is not fail-closed, it is merge-over-everything, and it is reachable precisely because the writer is a model being asked to emit a formatted string: hand it an empty or unclear cutoff andnowis the natural improvisation.The reader must not trust the writer's format. This whole design rests on decide on an artifact that takes push access to forge, never on prose a model was told to emit — and the description is the prose half of that artifact. The status is unforgeable; the string inside it is not disciplined by anything except this validation. Validate it here, or the thesis has a hole exactly where it is load-bearing.
Why the cutoff, and not the status's
created_at. Two ways a weaker test merges over live findings — the gate must defeat both:- Existence alone (
is there a status on H?). A status certifies a SHA; findings belong to a review. They diverge on the most ordinary outcome there is — the last responder deferred everything: it implements 0, so it pushes nothing, the head staysH, and the status it left pre-triages the next review to land there. Step 5 converges; Step 6'sadversarial-reviewposts real holes onH; an existence test says "already triaged"; the responder never spawns; auto-merge is armed over them. - The status's own
created_at.pr-responsereads the findings at the start of its run and stamps the status at the end — a 15–20 minute window covering score, fix, pre-flight, push, reply. A reviewBlanding inside that window is never seen by the responder, yetcreated_at(status) > submitted_at(B), so a clock-based test callsBtriaged by a run that never read it. And this is not exotic: the gate exists precisely to catch another gate's review and a human's, and a hook-armed repo'spr-greenposts adversarial reviews asynchronously on green — a designed-for concurrent producer landing straight into that window. A clock reading taken after a finding cannot certify that finding.
The cutoff defeats both, because it certifies what was read rather than when the writing stopped: in (1) the old status's cutoff predates the new review; in (2)
B.submitted_at > $CUTOFF. Either wayBreads untriaged, earns its own round, and that round writes a new status whose later cutoff does cover it. Self-healing, and no new signal is needed.This gate must never key on a marker in a body, and that is not a style preference — it is the merge path. The rule this replaces was "no marked
<!-- waffle-pr-response -->reply has disposed of it", which asked a free-text body anyone can write. Any comment that merely quoted the marker — a human discussing the hooks, a verdict table about markers — read as already triaged, so the responder was never spawned, the findings were never disposed of, and this skill went on to arm auto-merge over them. Silent, and on the one path that ships code. That is #338's thesis applied where it actually bites: decide on an artifact that takes write access to forge, never on prose. (PR #207 and PR #296 are that bug, observed, on the CI half.)Head-scoping is what makes it converge:
pr-responseposts the status against the head it responded to, so once it pushes fixes the new head carries no status, and the next round's review over that new head reads as untriaged and earns its own triage. Verdict history — what was already decided, and at what F-number — still lives in the reply bodies, andpr-responserecovers it from its own marked replies; that read is best-effort by design, because getting it wrong costs at most a redundant round, while getting this gate wrong merges untriaged code. - Existence alone (
-
Every later round resumes the same agent —
SendMessage(to: "qa-pr<N>", message: "the PR head moved to <sha> — re-run your QA pass on the new diff", summary: "re-run QA on the new head"), and likewise to the responder ("new review(s) posted on head<sha>— triage them"). The agent keeps its context: the diff it already read, the issue's acceptance criteria, the findings it filed, and why it settled each verdict. Round 2 therefore neither re-derives the PR from scratch nor re-litigates a finding round 1 already declined. That is the whole benefit — tokens and judgment continuity, nothing else. -
The return contract is identical. A resumed agent's reply carries the same structured summary a fresh invocation's final message would — per-severity counts from
qa, per-verdict and implemented counts frompr-response. Convergence (0 implemented — or the reviewer's clean summary when the responder never spawned), the cap, the markers, and the posted-review format are all untouched, and the marked reviews on the current head remain the verifiable ground truth: never take an agent's word over the PR's own state. -
A vanished agent degrades to a fresh spawn. If the
SendMessagefails because the agent no longer exists (it crashed, it was torn down, the session recovered), spawn a fresh agent under the same name with the full round-1 prompt and continue the loop from it. Persistence is an optimization; correctness never depends on it. The re-spawn prompt must say so explicitly — append "you are replacing a vanished loop agent — seed your history from the PR before reviewing" to the round-1 prompt, for reviewer and responder alike. That sentence is the only signal a cold-spawned agent has: the gate skills seed only when their invocation tells them they are a replacement, because an empty context by itself is ambiguous — the cap hatch below spawns its evidence pass deliberately cold, and it must not seed. With the sentence, a fresh-spawned reviewer seeds its finding history from the PR's marked reviews and verdict table, and a fresh-spawned responder seeds its verdict history and F-numbering from the PR's marked<!-- waffle-pr-response -->reply (pr-response's cold-start rule), so the fallback never re-litigates or renumbers. -
This loop owns their teardown. Shutdown-then-stop —
SendMessage(to: …, message: {type: "shutdown_request", reason: …})thenTaskStop(task_id: …)— for each agent this loop actually spawned at every exit — converged, cap-reached, a red round, an errored round — per theauditskill's Spawn-and-collect contract (its teardown clause is why the request alone is not enough). A round 1 with nothing to triage leaves only the reviewer to shut down; there is no responder to tear down when none was ever spawned. No gate agent outlives its loop.
Run this per PR (each PR in a parallel group independently):
-
Wait for green.
qais a post-green gate, so first poll until the PR's required checks pass:gh pr view <pr> --json statusCheckRollup -q '([.statusCheckRollup[].conclusion] | length > 0) and ([.statusCheckRollup[].conclusion] | all(. == "SUCCESS" or . == "SKIPPED" or . == "NEUTRAL"))'If a required check goes red (never green), the PR is blocked — stop its loop and report per Failure handling; never run
qaon a red PR and never arm one. Once the wait reads green, read the head it just certified —gh pr view <pr> --json headRefOid -q .headRefOid— that fresh value is the<sha>every resume message in this loop carries; never reuse a SHA cached from before the wait. -
Loop up to the run's effective QA cap (
{{autopilot.qaLoop.flag.on}}:Nwhen given, else the rendered default of{{autopilot.maxQaRounds}}rounds). Each round:- QA — run
qa <pr>, as theqa-pr<N>agent: spawned in round 1, resumed on the new head in every round after. It reads the diff plus the linked issue's intent, best-effort runs the tests and exercises the changed behavior, posts its findings as one PR review (or a "no QA concerns" summary — marker<!-- waffle-qa -->), and returns a per-severity count. A resumed round re-reads the diff fresh from the new head — the branch moved — while keeping the verdict history that tells it what it already verified. - Respond — run
pr-response <pr> --yes, as therespond-qa-pr<N>agent (spawned in round 1, resumed after). It scores each finding on its rubric, implements the accepted fixes on the PR branch and pushes, defers/declines the rest, posts one reply, and returns per-verdict counts — including the implemented count. Because it is the same agent each round, it remembers why it deferred or declined earlier findings and continues its F-numbering instead of re-litigating settled verdicts.--yesis the agent-mode flag that skips its confirmation gate. - Converged? A round that implements 0 findings is the terminal signal — there was nothing left to fix (qa found no concerns, or pr-response found no findings, or every finding was deferred/declined). Break the loop; this PR is done being QA'd. The implemented-count of 0 is the stop signal, read from pr-response's structured return when the responder ran; when the head had nothing left to triage and the responder was never spawned, qa's "no QA concerns" summary is the stop signal — there is no pr-response return to read. That second signal is scoped to nothing left to triage, not merely qa was clean: if the head still carries any untriaged review with findings — another gate's review, or a human's — the round has not converged — spawn the responder and triage it. Untriaged means no
waffle/pr-responsecommit status on that review's head SHA (the test in the Round-1 bullet above) — never "no marked reply on the PR". Breaking here on qa's clean summary alone would let item 3 arm auto-merge over findings no one ever disposed of. - Otherwise, re-wait for green. The round pushed fixes, so the PR is rebuilding — return to step 1's green wait before the next round's
qapass (it must always run on a green PR).
Repos with an armed
waffle-pr-green-hook: the hook fires on every green transition per head — including the PR's initial green, the very green step 1 waits on — so the PR may already carry an untriaged adversarial review when round 1 opens, and every round that pushes mints a new head whose green transition dispatches another (paid) review. Either way the next round'spr-response --yes— this loop's responder, resumed when a round with findings to triage already spawned it and spawned now when no round had — triages those findings alongside the QA ones. This is the case the spawn trigger above is scoped for: the hook's findings are this loop's business to triage, so a clean QA round over a head that carries them is not convergence. Expect the implemented count (this loop's convergence signal) to include non-QA findings and rounds to be spent on the other gate's findings. The cap escape hatch is where the sharing stops. Its fresh pass is a coldqapass, so the brief it sources carries only that pass's QA findings, and nopr-responsefollows it — so a hook review sitting on the final head is orphaned: nothing later in this run triages it, and it never reaches the hatch's brief. With the review loop (§3) on that costs nothing, because the hatch files nothing either — filing defers to Step 6, whose rounds triage every review on the PR, that one included. With the review loop off the QA hatch is the last fix loop, so say so where a human will see it: note in the follow-up — and in the run report when a clean pass files nothing — that the final head may still carry an untriaged hook review, so whoever picks the PR up reads its reviews too. That is the two gates sharing one PR, not a malfunction — but it spends rounds and review budget on the other gate's findings, so on hook-armed repos budget the run's effective QA cap (default{{autopilot.maxQaRounds}}rounds) with it in mind — a deeper{{autopilot.qaLoop.flag.on}}:Nis the tool for hook-armed repos. - QA — run
-
After the loop — tear down the gate agents, then arm, defer, or leave, and continue to Step 6. Teardown is unconditional:
shutdown_requestthenTaskStopeach agent this loop actually spawned —qa-pr<N>on every path that reached round 1, andrespond-qa-pr<N>when a round with findings to triage ever spawned it (a round 1 with nothing to triage never did — there is no responder to shut down) — on whichever exit path this loop took: on the cap-reached path after the hatch's fresh evidence pass (item 1 below), immediately on every other path, including the stop paths in Failure handling. A never-green PR is the empty case: item 1's green wait stops the loop before round 1 ever runs, and the reviewer is spawned in round 1 — so that path spawned no gate agents at all and has nothing to shut down. Teardown covers the spawned set, which may be both agents, the reviewer alone, or neither; it is never a fixed pair. Arming is owned by the last gate that is on: when the review loop (§3) or the audit step (§4) is on, the QA gate is not the last gate — do not arm here; leave the PR unarmed and let the later gate arm it after its own green wait. Only when both are off is the QA gate the last gate and does the arming below.-
Converged (a round implemented 0): the PR is QA'd and clean. With the review loop and audit step both off: if auto-merge was consented, arm it now under the standard guardrails —
gh pr merge --auto --merge <pr>, then on a successful arm label itgh pr edit <pr> --add-label "{{autoMerge.label}}", and verify it took:gh pr view <pr> --json autoMergeRequest -q '.autoMergeRequest != null' # expect trueA
falsehere is the same open but not armed outcome as Step 4 — record it and surface it in the report; never fall back to an immediate or--adminmerge. If auto-merge was not consented, leave the PR for a human. Either way, proceed to Step 6. -
Cap reached without converging (ran every round of the run's effective QA cap and the last still implemented fixes) → handle it per QA cap reached below, then proceed to Step 6.
-
QA cap reached without converging
The run's effective QA cap (default {{autopilot.maxQaRounds}}) is a safety cap, not a merge blocker. When the loop exhausts it and the final round still implemented fixes (never reached 0):
- Re-wait for green, then run one fresh evidence pass. The final round pushed fixes, so first return to step 1's green wait (if that push left CI red, this is the red-round failure case — stop and report per Failure handling; no fresh pass, and never arm a red PR). Then run
qa <pr>once more, outside the loop — it posts its review to the PR as usual (same<!-- waffle-qa -->marker) — purely as the source of the escape-hatch brief below. Spawn this pass fresh — never the standingqa-pr<N>agent. Spawn it unnamed — a bareAgent(…)with noname:— it runs once and is never resumed, and the standing agent still holdsqa-pr<N>(its teardown waits until after this hatch). Its whole value is a clean look at the final state, and the persistent agent spent every round alongside the fixes: it carries that anchoring and may under-report what it already declined. A cold context is what makes a clean pass credible as convergence evidence — so say so in the prompt: the invocation must tell the pass this pass is deliberately cold — do not seed history from the PR. Without that sentence the spawned agent cannot tell itself apart from a vanished-agent re-spawn, andqa's seeding rule would have it read the PR's marked reviews and verdict table — re-anchoring it on exactly the findings earlier rounds declined, which is the anchoring this pass is spawned cold to escape. Nopr-responsefollows it: this pass is evidence, not another fix round, and it does not count toward the cap — the gate spends at most cap+1qapasses (each subject to the one-retry error bound — see Failure handling), still strictly bounded. Without it the brief would capture the last round's findings, which that same round'spr-responsealready fixed — a stale hand-off (#234). - File the follow-up from the fresh pass — or skip it when the pass is clean. Filing, like arming, belongs to the last enabled fix loop: when the review loop (§3) is on, do not file here. Step 6's
pr-responserounds triage every review on the PR (the same cross-marker mechanics as the hook-armed-repos note above), so they will implement or dispose of these fresh-pass findings before merge — a follow-up filed now would hand a human findings the same run already fixed, the #234 staleness resurrected one gate later. The fresh pass still runs and its review rides into Step 6's triage; any follow-up is then Step 6's to make (its convergence needs none,pr-responsefiles its own follow-ups for deferrals, and its cap hatch briefs from its own fresh pass). Record the QA outcome as cap-reached with the filing handed to the review loop, and continue with item 3. With the review loop off:-
The fresh pass surfaced findings → invoke the
issueskill with a brief capturing those fresh-pass findings — e.g. "determine next steps for these unresolved QA findings on PR #N" plus the finding list — so it drafts, prioritizes, and board-places the issue. Then ensure the hold label lands (theissueskill applies type/priority labels, not this one):gh issue edit <new-issue-#> --add-label "{{autopilot.holdLabel}}"For a multi-line finding list, write it to a scratch file and hand it to
issue(orgh issue create --body-file <file>) — never a heredoc. -
The fresh pass came back clean ("no QA concerns") → file nothing. A clean pass over the fixed code is the convergence evidence the cap denied the loop — record this PR's QA outcome as converged-on-the-evidence-pass for the run report.
-
- Report the shed count — a quiet follow-up is invisible. A hold-labeled follow-up says nothing about the cap that produced it, so an under-provisioned cap surfaces only weeks later, as a pile of issues nobody connects to a loop that ran out of rounds (#348). So this PR's line in the run report states the shed count explicitly: rounds spent out of the run's effective cap, the fresh pass's finding counts by severity, how many of those findings the follow-up carries, and the follow-up's issue number — e.g. "PR #N — QA cap-reached (2/2 rounds), fresh pass 0 blockers / 1 should-fix / 3 nits, 4 shed into #M." Report it on every path through this hatch, including the ones that file nothing: a clean pass sheds 0 and says so, a filing handed to the review loop reports the same counts with the shed marked as Step 6's to own, and the errored-twice fallback reports the last round's counts and flags the brief as possibly stale. A gate that keeps shedding should-fix findings is under-provisioned —
{{autopilot.qaLoop.flag.on}}:Nis the dial, and this line is what tells a human to turn it. - Proceed normally. Arm, defer, or leave exactly as the converged path above: with the review loop (§3) and the audit step (§4) both off, arm auto-merge if consented (identical guardrails) or leave the PR for a human if not; with either later gate on, do not arm here — leave the PR unarmed for the last enabled gate to arm after it passes green. The rounds already run improved the PR; the cap only bounds the spend.
- Why the label matters. Same as the review loop's cap (see Step 6):
{{autopilot.holdLabel}}keeps the follow-up out of automatic scope (§1) until a human triages it by#N. The label must pre-exist (it is in the stack'ssetup:/prerequisites:).
Step 6 — Review → respond loop (opt-in)
Runs only when review-loop consent is on (§3); when it is off, skip straight to Step 7 (the audit gate, itself opt-in) with auto-merge arming left wherever Step 3 or Step 5 put it. This is the automated review gate between "green" and "merged" — it exercises each PR the way /adversarial-review and /pr-response do by hand, catching the correctness edge cases, thin tests, error handling, and API/naming choices that the required doctor check (render-matches-lock only) never looks at.
Auto-merge is not yet armed on this PR — Step 3 (and Step 5, when the QA gate ran) deferred arming precisely so a green PR can't merge before it is reviewed.
Persistent gate agents — spawn once, resume across rounds. Exactly as in Step 5, this loop's two halves are named agents, not a fresh invocation per round:
-
Round 1 spawns them.
Agent(name: "review-pr<N>", …)runsadversarial-review <pr>;Agent(name: "respond-rev-pr<N>", …)runspr-response <pr> --yes. Barename:is enough — no team, noisolation. These are this loop's own agents: any Step 5 agents (qa-pr<N>/respond-qa-pr<N>) were already shut down at its exit when that gate ran, and the distinct names keep the execution log unambiguous about which loop each agent served. The responder spawns lazily here too, on the same trigger — any untriaged review with findings on the head: this round's review's, another gate's undisposed review, or a human's, never merely this round's reviewer surfaced some — so only a round 1 whose head has nothing left to triage converges with it never spawned. Because it is a new agent, it cold-starts from any existing marked reply (pr-response's cold-start rule), so verdicts the QA gate already settled are not re-litigated here.Triage state is the
waffle/pr-responsecommit status on the review's head SHA, created AFTER that review was submitted — never a comment body, and never mere existence — the same test Step 5 spells out, and it bites hardest right here. Two things reach this gate looking triaged when they are not:- A marked reply from the QA gate. Its rounds have usually already left one on this PR, so the existence of a marked reply proves nothing about this head — and neither does a bare status.
- A status left by a responder that pushed nothing. Step 5 converges when
pr-responseimplements 0 — the normal all-deferred outcome — and that leaves the head unmoved with awaffle/pr-responsestatus already on it. Every review posted onto that head afterwards is newer than the status, and the timestamp comparison is the only thing that sees it.
Step 5's cap hatch routes exactly such a review into this loop: its fresh QA evidence pass posts findings with no
pr-responseafter it (Step 5, item 2 — "its review rides into Step 6's triage"). Those findings are untriaged whether or not that head carries a status, because the status (if any) predates the review. A round-1 review that finds no holes of its own over such a head has not converged — spawn the responder and triage them, rather than arming a merge over the findings Step 5 handed this gate to dispose of. When in doubt, spawn the responder. -
Every later round resumes the same agent —
SendMessage(to: "review-pr<N>", message: "the PR head moved to <sha> — re-run your review on the new diff", summary: "re-run review on the new head"), and likewise to the responder. The reviewer keeps its finding history, so it does not re-post holes the last round fixed or re-argue a findingpr-responsedeclined with a reason it accepted — while new blood in the diff gets the same hostility as round 1. The responder keeps its verdict history, so a settled Defer/Decline is not re-litigated. -
The return contract is identical. A resumed agent's reply carries the same structured summary as a fresh invocation — per-severity counts from
adversarial-review, per-verdict and implemented counts frompr-response. Convergence (0 implemented — or the reviewer's clean summary when the responder never spawned), the cap, the<!-- waffle-adversarial-review -->marker, and the posted-review format are untouched, and the PR's own marked reviews remain the ground truth. -
A vanished agent degrades to a fresh spawn — same rule as Step 5: re-spawn under the same name with the full round-1 prompt, plus the same explicit re-spawn sentence — "you are replacing a vanished loop agent — seed your history from the PR before reviewing" — for reviewer and responder alike, and continue (that sentence is what triggers the cold-start recovery: a fresh responder seeds itself from the PR's marked reply, a fresh reviewer from the marked reviews and that reply's verdict table). Correctness never depends on persistence.
-
This loop owns their teardown —
shutdown_requestthenTaskStopeach agent this loop actually spawned at every exit (converged, cap-reached, red, errored), per theauditskill's Spawn-and-collect contract. A round 1 with nothing to triage leaves only the reviewer to shut down; there is no responder to tear down when none was ever spawned. No gate agent outlives its loop.
Run this per PR (each PR in a parallel group independently):
-
Wait for green.
adversarial-reviewis a post-green gate, so first poll until the PR's required checks pass:gh pr view <pr> --json statusCheckRollup -q '([.statusCheckRollup[].conclusion] | length > 0) and ([.statusCheckRollup[].conclusion] | all(. == "SUCCESS" or . == "SKIPPED" or . == "NEUTRAL"))'If a required check goes red (never green), the PR is blocked — stop its loop and report per Failure handling; never run
adversarial-reviewon a red PR and never arm one. Once the wait reads green, read the head it just certified —gh pr view <pr> --json headRefOid -q .headRefOid— that fresh value is the<sha>every resume message in this loop carries; never reuse a SHA cached from before the wait. -
Loop up to the run's effective review cap (
{{autopilot.reviewLoop.flag.on}}:Nwhen given, else the rendered default of{{autopilot.maxReviewRounds}}rounds). Each round:- Review — run
adversarial-review <pr>, as thereview-pr<N>agent: spawned in round 1, resumed on the new head in every round after. It posts its findings as one PR review (or a "no holes found" summary) and returns a per-severity count. A resumed round re-reads the diff fresh from the new h
- Review — run
Truncated - read the full file at https://github.com/dustinkeeton/wafflestack/blob/acec92a747f58e87e418dda719884d773db1898a/stacks/orchestration/skills/autopilot/SKILL.md.