Imported from chris-yyau/busdriver (
skills/pr-grind/SKILL.md). Install upstream withnpx skills add chris-yyau/busdriver --skill pr-grind. Copyright stays with the author.
PR Grind — Iterative PR Feedback Resolution
When to Use
- After
gh pr createsucceeds and you want to stay on it until merge-ready - When CI is failing on an open PR
- When reviewer comments need addressing
- Manually:
/pr-grindor/pr-grind 123or/pr-grind https://github.com/owner/repo/pull/123
Announce at start: "Grinding PR #N — will iterate until CI is green and comments are resolved, then merge." (Drop "then merge" if --no-merge.)
Authority Hierarchy
Merge gate (authoritative — all must be satisfied):
- Required status checks: green — per
.github/required-checks.lockrequired[]when present (allowlist mode: only those names block); otherwise all checks exceptADVISORY_PATTERN/CodeScene (advisory-fallback mode). The lock is the single source of truth for both the pre-merge gate and pr-grind, computed byscripts/relevant-check-status.sh. In allowlist mode, "green" means every lock-required check REPORTED green — a required check with no run on this HEAD counts as pending, not as absent (#515). Non-reporting is the normal state of aCONFLICTINGPR (GitHub stops firingpull_requestworkflows), and counting only the checks that did report let one still-posting app check certify a PR whose CI had never run. Consequence to know about: a required check that is legitimately never posted (apaths-filtered workflow with no dummy job) now blocks pr-grind rather than being ignored — which is what branch protection does anyway. - Actionable findings on YOUR PR's changed lines: addressed (fix or justified reply)
- PR title/body: conventional commit + scope
Bounded-wait advisory (best-effort, capped by --max-wait):
- AI reviewer acks (CodeRabbit, Cubic, Greptile, etc.)
External policy gates (NOT something pr-grind can resolve — surfaces to the operator):
GitHub branch-protection settings encode org policy that pr-grind has no automated recourse for. Required required_approving_review_count is the canonical axis: the rules API can demand N >= 1 human APPROVED reviews on the PR before merge, and a solo author cannot self-approve their own PR. The fix-rounds budget (--max-fix) and wait-rounds budget (--max-wait) are both irrelevant — there is nothing to fix and nothing to wait for; the gap is structural. When this is the sole remaining blocker (CI green, bots ack HEAD, threads resolved), the dispatcher BAILs with RESULT_BAIL_CATEGORY=policy and surfaces operator-decision options (see "Approver-Gap Detection" later in this file). pr-grind NEVER auto-bypasses org policy; the --admin-on-approver-gap flag is the explicit opt-in for the narrow case where the operator has admin/maintain permission AND the repo carries an audit workflow.
Best-effort (low priority, addressed if fix budget allows — counts against --max-fix, not --max-wait):
- Style/nit findings: typically fixed because the effort is low
Invariant: required status checks are the merge authority. AI reviewer acks are bounded-wait advisory signals — apps rate-limit, freeze, or fail; --max-wait is the backstop. On exhaustion the loop bails to the operator (does NOT silently merge AND does NOT wait forever). Never wait indefinitely for any single reviewer app. The infra-error downgrade in scripts/ack-ledger.sh (ever_approved=0 defense) handles the specific case of a frozen review that the bot can't self-recover from; --max-wait is the broader safety net for slow-bot scenarios outside that pattern.
Why: helmet PR #35 stuck for a full session because a frozen Copilot review couldn't be classified by the pre-v1.30.1 ack ledger (introduced v1.29.1, PR #70). v1.30.1 added the body-text infra-error downgrade with the ever_approved=0 admin-bypass guard (PR #77, three sub-commits); v1.31 extracted the algorithm into scripts/ack-ledger.sh for single-source maintenance + added a fail-CLOSED || echo stale guard at the new call sites (PR #79); v1.33 added the --max-wait budget (PR #84). Codifying the principle prevents regression — a future "tighten the gate" PR must not reintroduce unbounded waits, must not silently merge past stale acks, and must not treat reviewer acks as co-equal with required checks.
Architecture: Dispatcher + Per-Round Worker
This skill is a thin dispatcher. The actual round work runs in a fresh pr-grinder subagent (opus, like every Claude route — ADR 0046), dispatched once per round. The split survives the model unification because neither of its reasons was price:
- Flattens conversation context — each round starts with O(1) tokens instead of O(N) accumulation across rounds
- Separates author from reviewer: the dispatcher keeps triage of subagent results, bail handling, merge decisions, and the skip-file protocol out of the worker's context
This file is dispatcher-only. The worker does not read it. agents/pr-grinder.md
is self-contained for Steps 1–6.5 — the 3-phase check verification, the four feedback
sources, the triage table, the ack ledger, and the bail table are all inline there.
This file holds no worker step protocol at all (no Step 1, no Phase 0/1/2 block, no
ack-ledger function). A worker contract that ordered a wholesale Read of this file was
paying ~25k tokens per round for dispatcher control flow it never executes; that order
was removed. Do not restore it — if the worker needs the dispatcher's side of a contract
it emits into, it reads the named section.
This skill is two files. The merge path — everything from RESULT_STATUS=clean
through the pr-grind-clean.local marker and gh pr merge — lives in
references/completion.md, and is mandatory reading before any merge-path
action. It is split out because it is ~48% of the document and is consulted
exactly once per grind, at the end; keeping it inline made every fix and wait
round re-read ~23k tokens of merge machinery it never uses. Read it when the
loop exits clean — not before, and never skip it.
Anti-Patterns (DO NOT)
| Trap | Why it breaks the loop |
|---|---|
| Looping rounds inside the subagent | Subagent contract is one round per dispatch. The dispatcher owns the loop. |
| Collecting feedback while checks are still pending | You'll miss reviewer findings, fix a partial set, push, and trigger a second review cycle unnecessarily |
| Declaring "Round complete" after push without waiting | The push triggers a new review cycle — you must wait for IT to finish before declaring done |
| Only waiting for CI (build/lint/test), ignoring reviewer bots | CodeRabbit, Cubic, Greptile are checks too — gh pr checks shows them as pending |
| Fixing pre-existing issues flagged by automated reviewers | Scope creep — only fix issues in YOUR changed code |
| Enabling GitHub auto-merge before pr-grind completes | The PR merges as soon as CI passes — before reviewer comments are addressed. pr-grind merges by default after all checks pass and comments are addressed. |
| Giving compound "grind then merge" instructions | Agent optimizes for merge as terminal goal, skipping CI wait. Just invoke /pr-grind — merge is the default. |
| Declaring PR clean without verifying check results | Checks completing (pass/fail/skip) ≠ checks passing — always verify status before writing the clean marker |
Safety Rails
- Max iterations: Two independent budgets — fix-rounds (default 5, override with
--max-fix N) cap how many dispatcher-owned fix commits can be pushed; wait-rounds (default 8, override with--max-wait N) cap how many polling rounds spent waiting for slow bots to ack HEAD. A round is classified as a fix round whenRESULT_COMMIT_SHA != "none"and as a wait round otherwise. Bail when EITHER counter exhausts its budget. Both--max-fixand--max-waitmust be>= 1— there is no "zero means unlimited" or "zero disables this class" form; if you want a larger budget, pass a larger number. The legacy--max Nflag is accepted as a deprecated alias that sets both budgets to N (emits a deprecation warning). The split exists because under the old unified--max, every wait-round consumed a fix slot — so a PR with 3 fix iterations + 4 slow-bot polls would exhaust at MAX=5 even though only 3 fixes happened. - Autonomous by default: Grinds without pausing between rounds
- Merges by default: After grinding clean, pr-grind merges the PR. Pass
--no-mergeto skip the merge and just declare "Ready for merge". This is NOT GitHub auto-merge — pr-grind merges after all checks pass and all comments are addressed, inside its own control flow. - Bail triggers: Stop immediately and clean up worktree if:
- A comment is a design/scope question (not a code fix)
- CI fails on an unrelated flaky test 3 times in a row
- The fix would require architectural changes
- The fix would require rewriting published git history (force-push,
git commit --amendon a pushed SHA,git filter-branch, interactive rebase on pushed commits) - Max fix-rounds reached (dispatcher pushed
MAX_FIXfix commits without converging clean) - Max wait-rounds reached (slow bot(s) never acked HEAD within
MAX_WAITpolling rounds) - External policy gap (branch protection requires
N >= 1human APPROVED reviews the author cannot self-provide, org-level rule blocks merge, or similar non-resolvable structural blocker). Excluded fromMAX_FIX/MAX_WAITaccounting — there is nothing to fix and nothing to wait for. Dispatcher emitsRESULT_BAIL_CATEGORY=policy; the operator decides via the surfaced decision message (see "Approver-Gap Detection"). - On any bail: if Step 0 created an ephemeral worktree,
cdback andgit worktree remove "../pr-grind-<PR_NUMBER>" --force 2>/dev/null || truebefore exiting. Skip whenNO_WORKTREE=1— i.e. either--no-worktreewas passed OR Step 0's auto-fallback engaged because the branch was already checked out. The|| truekeeps cleanup idempotent if the worktree was already removed.
- Out-of-scope-acknowledged discipline rails: the worker can dismiss a finding on YOUR PR's changed lines with one of 6 enumerated reasons (
schema-refactor,external-research,follow-up-deferred,cross-cutting-style,pre-existing-on-touched-line,false-positive) — seeagents/pr-grinder.mdStep 3. Three rails bound the carve-out: (a) worker per-round cap of ≤3 dismissals, self-enforced; (b) dispatcher cumulative cap of ≤5 dismissals across the whole grind (Invariant 4); (c) dispatcher cumulative cap of ≤3 follow-up issues spawned (Invariant 4). Hitting either dispatcher cap BAILs withRESULT_BAIL_CATEGORY=judgmentregardless of round status. The default is FIX — dismissal is the carve-out. The rails exist precisely so workers can't relabel tedious-but-real findings as out-of-scope to "ship faster," leaving real bugs tracked-but-unaddressed in spawned follow-up issues.
CWD Reset Across Bash Calls
The Claude Code Bash tool does not reliably preserve CWD across tool calls. Every NEW bash block added to this SKILL.md that touches the worktree MUST start with cd "$WORKTREE_DIR" (template-substituted to the literal absolute path resolved in Step 0). CWD inheritance can break on intervening Edit/Write/Read calls (verified empirically — interleaving non-Bash tool calls between Bash blocks can reset CWD to the session launch directory), subagent dispatches (each starts in whatever CWD the SDK chose, NOT necessarily the worktree), session boundaries (/save-session + /resume-session does not preserve CWD), and dispatcher↔worker handoffs (the dispatcher-owned commit block runs as its own fresh Bash process). Even when CWD happens to carry over between two back-to-back Bash calls, relying on it is fragile because the next intervening tool call breaks the chain silently. The failure mode is silent state corruption — commits land in the wrong repo, gh queries the wrong PR, file-writes land in the wrong location — not a loud error, which is the most expensive class of bug.
Shell state — environment variables, aliases, functions, shell options — does NOT persist across Bash tool calls. export FOO=1 in one block does NOT survive into the next, even back-to-back. See "Resolve flag-to-state translations" in START for the template-substitution convention this SKILL.md uses for boolean flags (ADMIN_FLAG_PASSED, NO_WORKTREE) — Claude template-substitutes the literal 0/1 into each block before the bash executes.
The rule (forward-looking): every NEW bash tool call added to this SKILL.md that calls git, gh, or touches a worktree-relative path opens with cd "$WORKTREE_DIR". The rule applies at Bash-tool-call boundaries, not to every embedded code-fence within a larger template. Pre-existing bash blocks in this SKILL.md predate this rule and rely on context-level CWD established by their parent dispatcher flow; they are not retroactively required to update.
The Dispatcher Loop
START
├── Resolve PR # (arg, current branch, or ask user)
├── Step 0: Create ephemeral worktree
├── Resolve budgets (with deprecation handling for legacy --max):
│ If BOTH `--max` and either `--max-fix`/`--max-wait` were passed →
│ BAIL with reason "conflicting flags: --max cannot be combined with --max-fix or --max-wait"
│ (the alias contract is "set both to N"; combining with explicit budgets is ambiguous).
│ If `--max N` was passed (and neither `--max-fix` nor `--max-wait`):
│ MAX_FIX = N
│ MAX_WAIT = N
│ emit "⚠️ --max is deprecated; use --max-fix and --max-wait. Note: legacy --max=N capped TOTAL rounds at N; the alias allows up to 2N rounds (N fix + N wait)."
│ Otherwise:
│ MAX_FIX = --max-fix N value (default 5)
│ MAX_WAIT = --max-wait N value (default 8)
│ Validate budgets after resolution:
│ If MAX_FIX < 1 or MAX_WAIT < 1 →
│ BAIL with reason "invalid budget: --max-fix and --max-wait must be positive integers (>= 1)"
│ # The lower bound is 1, not 0. A grind with budget 0 has no useful
│ # semantics: the dispatcher would either bail before doing any work
│ # (if zero meant "no rounds") or run forever (if zero meant "unlimited"),
│ # neither of which a sensible operator wants. Reject at the boundary.
├── Resolve flag-to-state translations (consumed by downstream bash blocks):
│ ADMIN_FLAG_PASSED = 1 if `--admin-on-approver-gap` was passed, else 0
│ NO_WORKTREE = 1 if `--no-worktree` was passed, else 0
│ REVIEWED_HEAD = the full 40-char HEAD_FULL_SHA captured in the
│ classification block, carried forward to BOTH
│ Completion merge blocks as `--match-head-commit`
│ (#427) AND written as the second field of the
│ pr-grind-clean marker (#505). Remember the SHA the
│ acks were classified against — do NOT re-derive it
│ at merge time or at marker-write time; re-deriving
│ stamps a post-classification push as reviewed.
│ # These are NOT exported as shell env vars — bash exports do NOT survive
│ # across Claude Bash tool calls (each tool call gets a fresh shell). The
│ # dispatcher (Claude) MUST remember each flag's resolved value in
│ # conversation context and template-substitute the literal 0/1 into every
│ # downstream Bash block that needs it. Concretely:
│ # - Completion's approver-gap caller block emits
│ # `ADMIN_FLAG_PASSED=<0|1 from above>` (literal value, NOT
│ # `${ADMIN_FLAG_PASSED:-0}` which always resolves to 0 in a fresh shell).
│ # - Step 0's auto-fallback and BAIL/COMPLETION cleanup branches read
│ # NO_WORKTREE from this state, NOT from `${NO_WORKTREE:-0}` env-fallback.
│ # Same substitution convention as `<PR_NUMBER>` / `<owner>` / `<repo>`
│ # template values used throughout this SKILL.md — Claude substitutes the
│ # literal value at run time before executing the bash.
└── Initialize: PRIOR_COMMIT_SHA=none, PRIOR_ATTEMPTS=[],
fix_round=0, wait_round=0,
round_number=0,
# round_number is pre-incremented at the TOP of each loop
# iteration (before dispatch), so the first dispatch receives
# ROUND=1, the second ROUND=2, etc. It is the N in
# "ROUND=<N>" and "Round N" in PRIOR_ATTEMPTS template strings.
total_scope_skipped=0,
total_issues_spawned=0,
# total_scope_skipped accumulates this-round contributions
# parsed out of every `scope-skipped:<reason>:<count>`
# segment in RESULT_BOT_LEDGER (segments are `+`-joined
# within a disposition; outer entry split is `,`).
# total_issues_spawned accumulates the comma-count of
# RESULT_ISSUES_SPAWNED ("none" → 0). Both gate Invariant 4
# (discipline rails — cumulative caps of 5 dismissals and
# 3 spawned issues per grind). Reset on each invocation,
# never persisted across invocations or surfaced in
# PRIOR_ATTEMPTS — the worker doesn't need to see them.
PRIOR_REVIEWER_ACKS="cubic-dev-ai=none,coderabbitai=none,greptile-apps=none",
PRIOR_CODEX_ACK="none"
# PRIOR_CODEX_ACK persists Codex's RESULT_CODEX_ACK across
# rounds (parallel to PRIOR_REVIEWER_ACKS), so the max-wait
# bail's STALE_AT_BAIL can name Codex when a Codex-only wait
# exhausts the budget. Reset per invocation.
LOOP (terminates when fix_round >= MAX_FIX OR wait_round >= MAX_WAIT):
│
├── round_number += 1 # pre-increment so ROUND=<N> is 1-indexed at dispatch time
│
├── Derive durable grind provenance — BEFORE the Agent dispatch, EVERY round:
│ # Rail A / ADR 0036. This is what makes #620's proportionality gate fire
│ # across invocations instead of only within one.
│ #
│ # It must sit HERE, pre-dispatch. `scripts/dispatcher-commit-block.sh` is
│ # a subprocess invoked AFTER the worker returns and only on fix-rounds,
│ # so a call placed there could never populate a wait-round or the round-1
│ # re-invocation this exists to fix — which is #620's defect repeating.
│ #
│ # Substitute the literals remembered from Step 0 (<WORKTREE_DIR>,
│ # <BASE_SHA>); shell state does not survive across Bash tool calls.
│ #
│ # `BASH_ENV= ENV= command bash` closes two specific cheap vectors that
│ # the helper cannot close from inside itself: an inherited `bash`
│ # FUNCTION (`command` bypasses function lookup) and a startup file
│ # sourced into the helper's own shell before its first line runs.
│ #
│ # It is NOT an environment-sanitization boundary, and must not be read
│ # as one. PATH is still whatever the caller had, inherited BASH_FUNC_*
│ # entries still reach the child, and a PATH shim can forge
│ # `GRIND_SHAS=none` / `STATUS=ok` outright.
│ #
│ # Do NOT reach for `env -i` here, and do NOT cite ADR 0016: that
│ # wrapper protects auto-firing GATES and explicitly does not transfer
│ # to dispatcher prose, which runs in the operator session's ambient
│ # environment. **ADR 0026 settled this as an accepted residual** for
│ # every credentialed call on this path (issue #475, closed as
│ # documented, deliberately not wrapped) — a plugin cannot sanitize the
│ # session it runs inside, and a dispatcher-wide wrapper would be false
│ # assurance. Rail A inherits that bound rather than reopening it; the
│ # two prefixes above are cheap defense-in-depth within it, nothing more.
│ HEAD_SHA=$(git -C <WORKTREE_DIR> rev-parse HEAD) \
│ || { echo "GRIND_PROVENANCE_FAILED rev-parse"; exit 1; }
│ BASH_ENV= ENV= command bash "${CLAUDE_PLUGIN_ROOT}/scripts/grind-pr-commits.sh" --context \
│ -C <WORKTREE_DIR> <PR_NUMBER> <BASE_SHA> "$HEAD_SHA" \
│ || { echo "GRIND_PROVENANCE_FAILED scan"; exit 1; }
│ #
│ # rc 0 → stdout is exactly THREE lines — `GRIND_SHAS=…`,
│ # `GRIND_SHAS_STATUS=ok` and `GRIND_HEAD_SHA=…`. Copy ALL THREE
│ # verbatim into the context block below. Do NOT re-resolve the
│ # head in a later block: shell state does not cross a Bash-tool
│ # boundary, and a re-resolved head would pair THIS set with a
│ # NEWER commit, which the consumer accepts — recreating the very
│ # bypass the binding closes. The scanner emits it so all three
│ # come from one scan.
│ # The certified set is a
│ # SNAPSHOT: pr-grind supports concurrent runs, so another
│ # invocation can advance the shared worktree between this scan
│ # and the worker's blame, and its new commit would be in neither
│ # GRIND_SHAS nor this invocation's PRIOR_ATTEMPTS. Binding the
│ # set to the HEAD it was derived at turns that into a visible
│ # BAIL instead of a silently inert gate. All THREE fields travel
│ # together or none do.
│ # rc ≠ 0 → BAIL `env` BEFORE dispatching. The worker is never launched on
│ # an unverifiable set. Do NOT substitute
│ # `GRIND_SHAS_STATUS=unavailable` and dispatch anyway — that
│ # value exists only to make the worker-side contract explicit.
│ #
│ # Use `--context`, not a bare call: it is what makes "an empty set renders
│ # `none`" executable rather than a rule in the caller's head. `helper |
│ # wc -l` returns 1 on empty output, and a pipeline would mask the
│ # helper's exit 3 as rc 0 — fail-open on the exact property Rail A rests
│ # on. Never pipe this call; never count its lines yourself.
│ #
│ # Re-derived every round, not cached per invocation: one rev-list pair is
│ # cheap, and re-deriving removes any need to reason about whether this
│ # invocation's own pushes are already reflected. They are, by construction.
│
├── Write-block preflight (every round, BEFORE dispatch) — #625:
│ # Optimization only; fail-OPEN. The PreToolUse gates stay fail-CLOSED;
│ # the worker's `env` bail stays the mid-round backstop.
│ bash "${CLAUDE_PLUGIN_ROOT}/scripts/pr-grind-write-block-preflight.sh" \
│ -C <WORKTREE_DIR>
│ # exit 0 → clear (or detector unreadable/unresolvable) → dispatch
│ # exit 1 → definite block (pending design-review markers, or freeze-scope
│ # that excludes this worktree). Surface the script's stdout to the
│ # operator (it names the blocking doc / freeze path and the
│ # release path, including the "do not drain unless abandoned"
│ # caveat). BAIL `env` WITHOUT dispatching — do not spend a round.
│ # exit 2 → usage error → fail OPEN (dispatch); do not invent a block
│ # Never create/disarm the operator-only design-review skip file; never
│ # invoke design-clear.sh from this path. Read-only observation of an
│ # already-active lease (mtime + slots) is allowed; never claim a slot.
│
├── Dispatch a round:
│ Agent(subagent_type="pr-grinder", prompt=<context block>)
│ ↳ Subagent does ONE round (Steps 1–6.5), returns RESULT_* tags
│
├── Parse subagent output (extract tags only — control flow is sequential):
│ The worker owns triage and staging only. The dispatcher owns commit
│ composition, litmus, commitlint, push, and
│ post-push ack synthesis through `scripts/dispatcher-commit-block.sh`.
│ Invariants still run before any terminal clean/continue decision.
│
│ RESULT_STATUS=clean → eventually: invariants pass, go to COMPLETION
│ RESULT_STATUS=bail → break loop, go to BAIL
│ RESULT_STATUS=needs_more → route as fix-round or wait-round below
│
├── Update discipline-rail counters (runs on EVERY status, including bail/clean):
│ # Out-of-scope-acknowledged accumulator. The worker may have dismissed
│ # findings even on rounds it ultimately bails or marks clean; those
│ # dismissals count toward the cumulative cap regardless of round
│ # status. Updating here (before the bail/recovery branch and before
│ # invariant checks) ensures Invariant 4 sees a fresh total.
│ scope_skipped_this_round = sum of every integer N matched by the
│ regex `scope-skipped:[a-z-]+:(\d+)` across
│ ALL bot-ledger entries this round.
│ Segments inside a single disposition are
│ `+`-joined; the entry split (which the
│ regex match honors implicitly) is `,`.
│ A disposition with no segments contributes 0.
│ total_scope_skipped += scope_skipped_this_round
│ issues_spawned_this_round = (RESULT_ISSUES_SPAWNED missing
│ OR == "none") ? 0
│ : count of comma-separated tokens.
│ total_issues_spawned += issues_spawned_this_round
│ # Missing-tag handling matters for the in-flight upgrade case: a
│ # worker on the old contract never emitted RESULT_ISSUES_SPAWNED,
│ # and the dispatcher must treat that as zero contribution rather
│ # than bailing "subagent output unparseable". The protocol is
│ # ADDITIVE — old workers operate under old semantics for the rest
│ # of their grind (Invariant 4 simply doesn't enforce, bounded by
│ # the worker's per-round cap of ≤3); new workers opt into
│ # Invariant 4 by emitting the new tags. Same reasoning applies to
│ # `scope-skipped:*:*` segments — old workers never produced them,
│ # so the regex match returns 0 contributions, which is correct.
│ # The two contributions ARE related (every spawn is also a skip
│ # under one of the spawn-eligible reasons), but tracked separately
│ # because skips and spawns have different caps (5 vs 3) and the
│ # worker decides per-finding whether to spawn. The dispatcher does
│ # not infer one from the other.
│
├── Dispatcher commit/state-synthesis block (post-inversion):
│ Evaluate guards first:
│ 1. RESULT_STATUS=needs_more AND staged changes AND RESULT_FIXES empty
│ → BAIL judgment ("inconsistent worker state").
│ 2. RESULT_STATUS=clean AND staged changes
│ → BAIL judgment ("orphaned staged changes on clean round").
│
│ Routing:
│ - RESULT_STATUS=needs_more + staged changes + RESULT_FIXES populated
│ → Fix-round: invoke `scripts/dispatcher-commit-block.sh`.
│ - RESULT_STATUS=needs_more + no staged changes
│ → Wait-round: skip commit-block, refresh ack ledger only.
│ - RESULT_STATUS=clean + no staged changes
│ → Merge path; worker-emitted acks are authoritative for clean path.
│ - RESULT_STATUS=bail
│ → BAIL.
│ - Any other RESULT_STATUS
│ → BAIL judgment with reason `unrecognized RESULT_STATUS=<value>`.
│
│ Fix-round delegation:
│ # PRIOR_COMMIT_SHA (#668): the dispatcher's remembered LAST FIX-ROUND
│ # SHA — conversation state, so template-substitute the literal (shell
│ # vars do not survive Bash tool calls; "${PRIOR_COMMIT_SHA:-none}"
│ # would always expand to none and defeat the double-count guard).
│ # RETAINED across wait-rounds (a wait-round's RESULT_COMMIT_SHA=none
│ # must not reset it — see "Update state" below) and "none" only until
│ # the first fix-round reports a SHA.
│ WORKTREE_DIR="$WORKTREE_DIR" \
│ CLAUDE_PLUGIN_ROOT="$CLAUDE_PLUGIN_ROOT" \
│ PR_NUMBER="$PR_NUMBER" \
│ RESULT_STATUS="$RESULT_STATUS" \
│ RESULT_FIXES="$RESULT_FIXES" \
│ RESULT_REVIEWER_ACKS="${RESULT_REVIEWER_ACKS:-}" \
│ RESULT_ACK_TIERS="${RESULT_ACK_TIERS:-}" \
│ NO_WORKTREE="${NO_WORKTREE:-0}" \
│ PRE_DISPATCH_BASELINE="${PRE_DISPATCH_BASELINE:-[]}" \
│ BUSDRIVER_ALLOW_NO_COMMITLINT="${BUSDRIVER_ALLOW_NO_COMMITLINT:-0}" \
│ PRIOR_COMMIT_SHA=<PRIOR_COMMIT_SHA — last fix-round SHA, literal, retained across wait-rounds; "none" until first fix-round> \
│ bash "$CLAUDE_PLUGIN_ROOT/scripts/dispatcher-commit-block.sh"
│
│ Parse the last stdout line as exactly one JSON envelope:
│ - Success: set RESULT_COMMIT_SHA, RESULT_REVIEWER_ACKS,
│ RESULT_ACK_TIERS, AND RESULT_CODEX_ACK from
│ `result_commit_sha` / `result_reviewer_acks` / `result_ack_tiers` /
│ `result_codex_ack`. Every success envelope carries all four, and
│ result_ack_tiers is ALWAYS computed from the SAME ack-ledger pass as
│ result_reviewer_acks (ADR 0001 core invariant): fix-rounds and
│ wait-rounds compute both freshly from the post-push / refresh
│ ACK_EMIT_TIER=1 pass; the clean pass-through carries the worker's
│ acks and tiers verbatim (one worker Step 6.5 pass). Because the two
│ are same-pass, the dispatcher uses RESULT_ACK_TIERS directly — no
│ reset, no fail-closed crutch. Invariant 3's bodyless-ack exemption
│ then fires iff a registered bot acked the CURRENT HEAD via tier D
│ (check-run) or E (commit-status) with n_total==0 — including on a
│ fix/wait round where, e.g., cubic's check-run registers before
│ slower bots (cubic=<sha> tier=D exempts; the others stay stale).
│ Backward-compat: if `result_ack_tiers` is absent (legacy
│ commit-block), reset RESULT_ACK_TIERS to the all-`none` default
│ (fail-CLOSED — strict pre-ADR-0001 behavior).
│ result_codex_ack is ALWAYS recomputed from the same post-push /
│ refresh fetch pass as the registered bots (fix-rounds and
│ wait-rounds) or passed through from the worker (clean path). This
│ closes the fix-round staleness gap: without recomputing here, the
│ dispatcher's PRIOR_CODEX_ACK would be the worker's pre-commit
│ value, which predates the push. Backward-compat: if the
│ `result_codex_ack` key is absent from the JSON envelope (legacy
│ commit-block that predates Codex gating), the DISPATCHER preserves
│ its stored RESULT_CODEX_ACK from the worker unchanged — old workers'
│ Codex acks remain stale-until-next-round (same pre-fix behavior),
│ not silently promoted to "none". Distinct from the commit-block
│ input fallback in the "Outputs" section below, which describes what
│ the script itself emits when the caller omits the RESULT_CODEX_ACK
│ env var (a different layer: script output vs. dispatcher state).
│ - Bail: set RESULT_BAIL_CATEGORY / RESULT_BAIL_REASON from
│ `bail_category` / `bail_reason`, then go to BAIL.
│
├── Invariant checks (fail-CLOSED — both must hold):
│ 1. If RESULT_STATUS=needs_more AND RESULT_COMMIT_SHA=none AND
│ RESULT_REVIEWER_ACKS contains no `stale` entries AND
│ RESULT_CODEX_ACK is not `stale` →
│ BAIL with reason "subagent emitted needs_more without a commit
│ SHA and without any stale ack — neither a fix nor a wait-for-
│ bots is justified, so the loop has no progress signal".
│ Legitimate `needs_more` rounds always have either a new commit
│ SHA (dispatcher pushed a fix) OR at least one `stale` ack — a
│ registered bot in RESULT_REVIEWER_ACKS, OR Codex via
│ RESULT_CODEX_ACK=stale (Codex is gated but tracked outside
│ RESULT_REVIEWER_ACKS, so a Codex-only wait-round — all three
│ registered bots acked HEAD but Codex is still reviewing — is
│ legitimate and must NOT be misread as no-progress). A round with
│ none of these is broken — re-dispatching would loop forever on no
│ progress. (Backward-compat: a worker that omits RESULT_CODEX_ACK
│ leaves it empty, which is `!= stale`, so the check reduces to its
│ prior registered-bot-only behavior.)
│ Note: a bot whose review was downgraded to `none` by the
│ infra-error path (see scripts/ack-ledger.sh) will not appear as
│ `stale`. If that downgraded bot was the ONLY reason the worker
│ considered the round incomplete, the worker should return
│ `clean` (or `bail`), not `needs_more` with all-`none` acks —
│ the invariant correctly catches that misuse.
│ 2. If RESULT_STATUS=clean AND (any registered bot in
│ RESULT_REVIEWER_ACKS has value `stale` OR RESULT_CODEX_ACK
│ is `stale`) →
│ BAIL with reason "subagent reported clean but reviewer ack
│ ledger has stale entries: <list>" (include `chatgpt-codex-connector`
│ in <list> when RESULT_CODEX_ACK=stale). Slow-Cubic / slow-CodeRabbit
│ race protection — clean cannot ship while a registered bot OR
│ Codex hasn't acked HEAD. Codex is checked here even though it lives
│ outside RESULT_REVIEWER_ACKS (its clean signal is a Tier-F reaction,
│ not a SHA-keyed structured ack — see RESULT_CODEX_ACK in the tag set).
│ Backward-compat: a worker that omits RESULT_CODEX_ACK leaves it empty
│ (`!= stale`), reducing this to its prior registered-bot-only behavior.
│ 3. Bot-ledger coverage gate (Bug 1 — prose-review enumeration):
│ For every bot in the **intersection** of RESULT_REVIEWER_ACKS
│ and RESULT_BOT_LEDGER whose ack value is a <short-sha>
│ (acked HEAD) — i.e., the bot definitely reviewed something
│ on this PR AND has an enumeration entry — that ledger entry
│ MUST have `n_total >= 1`. A `0/0` ledger entry for a
│ HEAD-acked bot means the worker didn't enumerate the bot's
│ body; merging would risk a Codex-style prose coverage gap
│ (PR with buried actionable findings the worker silently
│ skipped).
│
│ **Asymmetry: ledger and ack registry are not 1:1.** The
│ ledger includes `codescene-delta-analysis` (it posts findings
│ as Source 2 review threads) while the ack registry does not
│ (codescene has no /reviews entries, so its HEAD-ack signal
│ doesn't go through scripts/ack-ledger.sh). For ledger entries
│ whose login is NOT in RESULT_REVIEWER_ACKS, this invariant
│ does not apply — codescene and chatgpt-codex-connector are
│ enumerated for content but their coverage is gated through the
│ worked-example "always include codescene and
│ chatgpt-codex-connector in the default ledger" rule, not through this
│ invariant. The intersection rule keeps Invariant 3 strictly
│ scoped to the three registered ack-bots that the worker can
│ cross-correlate.
│
│ Parse RESULT_BOT_LEDGER as comma-separated entries of shape
│ `<login>=<n_actionable>/<n_total>:<disposition>`.
│
│ **Defensive count check FIRST.** The known-bot set is fixed
│ (5 bots: `cubic-dev-ai`, `coderabbitai`,
│ `greptile-apps`, `codescene-delta-analysis`,
│ `chatgpt-codex-connector`).
│ After comma-splitting, the number of entries MUST equal 5; if
│ it doesn't, BAIL with reason "malformed bot ledger: expected 5
│ entries, got <N> — possible disposition comma corruption (the
│ worker contract requires dispositions to contain no commas
│ because they would split into phantom entries and could hide
│ a HEAD-acked bot's `0/0` from this gate)". This count check
│ is what makes "MUST NOT contain commas" enforceable instead
│ of a soft hope.
│
│ Then for each entry where the corresponding RESULT_REVIEWER_ACKS
│ value exists AND looks like a short SHA (regex `^[0-9a-f]{7,40}$`):
│ - if n_total == 0:
│ **Bodyless-ack exemption (ADR 0001).** Look up the bot's
│ tier in RESULT_ACK_TIERS (worker tag; parse as
│ comma-separated `<login>=<tier>`, tier ∈ {A,B,C,D,E,none}).
│ - if tier is `D` or `E` → PASS. The HEAD-ack came from a
│ bodyless structured signal (D=check-run, E=commit-status)
│ with no enumerable Source 2/3/4 body — e.g., a
│ clean-only check-run bot. By ack-ledger's tier order (A→E,
│ first hit wins), reaching D/E proves the bot has zero
│ live Source-2 inline threads, so this exemption cannot
│ mask an inline finding. See agents/pr-grinder.md Step 2.6
│ "Bodyless check-run/status acks".
│ - otherwise (tier A/B/C, tier `none`, RESULT_ACK_TIERS
│ missing, OR the bot's tier missing/unknown —
│ **fail-CLOSED**) → BAIL with reason "worker did not
│ enumerate findings for <bot> despite ack on <short-sha>
│ (tier <tier-or-?>) — possible prose-review coverage gap;
│ manual review required".
│ A body-bearing tier (A/B/C) with n_total==0 is a genuine
│ enumeration gap. Tier `none` (or a missing tier map) on a
│ HEAD-acked bot should NOT happen under same-pass computation
│ — acks and tiers always come from one ack-ledger pass, so a
│ HEAD-sha ack is always paired with a D/E (or A/B/C) tier. It
│ can only arise from a legacy commit-block that emits no
│ `result_ack_tiers` (dispatcher defaults to all-`none`) or a
│ degraded post-push fetch (all-`stale` acks + all-`none`
│ tiers — but then the ack is `stale`, not a HEAD-sha, so this
│ branch isn't reached). In every one of these cases the
│ strict pre-ADR-0001 behavior (always bail) is the safe
│ default.
│ - if n_total >= 1 → pass (worker enumerated; disposition
│ is its decision)
│
│ `stale` and `none` ack values do NOT trigger this gate —
│ `stale` means bot hasn't re-reviewed yet (Invariant 2 already
│ gates on this for clean status); `none` means bot never posted,
│ or only posted infra-error markers, or acknowledged HEAD via a
│ check-run with conclusion=skipped and non-actionable body. The
│ matching ledger shapes are `<bot>=0/0:none` for bots that posted
│ nothing, OR `<bot>=0/N:no-findings` for bots whose N>=1 artifacts
│ were Case-1/2/3 downgraded with zero actionable findings (per
│ the n_actionable/n_total contract at pr-grinder.md:200). Only
│ HEAD-acked bots
│ prove a body exists that should have been enumerated.
│
│ 4. Discipline rails — cumulative caps for the out-of-scope-
│ acknowledged flow (see agents/pr-grinder.md Step 3
│ "Out-of-Scope-Acknowledged Workflow").
│
│ Runs on EVERY round status, including `clean` AND `bail`
│ (Invariants 1-3 run on `needs_more`/`clean` only — see the
│ "Parse subagent output" comment above; Invariant 4 is the
│ explicit exception). Accumulated breaches block ship even
│ when this round's classification is clean, AND surface
│ operator-visible context when the worker over-dismisses
│ findings and then bails — a worker that dismisses 5+
│ findings must still surface to the operator regardless of
│ whether it ultimately declared clean or bailed.
│
│ Both bails are dispatcher-emitted with category=`judgment`. This
│ widens the dispatcher emit set from `{budget}` to
│ `{budget, judgment}` — see agents/pr-grinder.md "Bail Triggers"
│ category enum doc.
│
│ Caps are INCLUSIVE — 5 dismissals and 3 spawned issues are
│ the maximum ALLOWED (worker can use the full budget); the
│ 6th dismissal / 4th spawn is what BAILs. The conditions below
│ use strict-greater-than so the cap value itself remains a
│ legal grind state. The natural-language wording ("≤5", "≤3")
│ in Safety Rails / Anti-Patterns / Worked Example all reflect
│ this inclusive reading; the pseudocode's `>` (not `>=`) is
│ what makes that wording true. Earlier drafts had `>=` which
│ BAILed the legal 5th/3rd — fixed in review.
│
│ - If total_scope_skipped > 5 →
│ BAIL with reason "out-of-scope dismissal count is
│ <total_scope_skipped> across <round_number> rounds —
│ exceeds discipline rail of 5; operator review required",
│ RESULT_BAIL_CATEGORY=judgment.
│
│ - If total_issues_spawned > 3 →
│ BAIL with reason "follow-up-issue spawn count is
│ <total_issues_spawned> across <round_number> rounds —
│ exceeds discipline rail of 3; PR scope is too narrow or
│ worker is misclassifying", RESULT_BAIL_CATEGORY=judgment.
│
│ The thresholds are deliberate: 5 dismissals = roughly one per
│ round at MAX_FIX=5, well above the per-round cap of 3 the
│ worker self-enforces (so honest workers won't trip it); 3
│ spawned issues = the point at which "this PR has scope creep
│ worth deferring" tips into "this PR's scope is wrong, replan."
│ Tightening the caps without operator data risks bailing
│ legitimate grinds; loosening them silently allows the
│ relabel-as-out-of-scope failure mode the rails exist to catch.
│
├── Codex first-engagement nudge on the CLEAN path (bounded-N per HEAD, ADR 0005 #673) — issue #467.
│ # Fire the `none`-case nudge the INSTANT a round converges to clean, decoupled
│ # from the COMPLETION merge machinery. Be precise about the gap this closes:
│ # within a faithful top-to-bottom COMPLETION run the nudge ALREADY precedes the
│ # Branch-Currency (BEHIND) and Approver-Gap bails (in references/completion.md,
│ # document order: nudge < BEHIND < approver-gap), so ordering-within-COMPLETION is not the
│ # bug. The bug is that COMPLETION can be SKIPPED WHOLESALE: a dispatcher that
│ # front-runs a cheap read-only merge-state probe (`gh pr view --json
│ # mergeStateStatus` + relevant-check-status.sh) to pick the merge path, sees a
│ # terminal BEHIND / approver-gap, and surfaces that decision WITHOUT ever entering
│ # COMPLETION — so COMPLETION's nudge never runs and a never-engaged Codex is
│ # silently skipped on exactly the PRs that end in an operator bail. Firing here,
│ # before any merge-path branching, makes the nudge independent of that shortcut;
│ # the bounded grace POLL stays in COMPLETION (it only matters right before merge).
│ # Safe against the COMPLETION re-nudge: codex-retrigger.sh's per-(PR,HEAD) attempt
│ # markers plus its cooldown bound the POST, so the two call sites cannot compound —
│ # at most PR_GRIND_CODEX_RETRIGGER_MAX (default 3) `@codex review` posts per HEAD,
│ # spaced by PR_GRIND_CODEX_RETRIGGER_COOLDOWN (default 180s). Pre-#673 this was a
│ # hard one-shot; that made a single dropped nudge terminal for the PR (see ADR 0005).
│ # COST (stated honestly, per the #467 review): on a clean `none` round this block runs
│ # the wrapper's detection (`gh repo view` + the Codex-active GraphQL probe) ONCE, and
│ # COMPLETION later re-derives active-ness independently — so a Codex-active / force-on
│ # repo pays ONE extra codex-active probe per clean-none merge vs. pre-#467. This is a
│ # deliberate, bounded tradeoff: the attempt markers + cooldown bound the POST (at most
│ # PR_GRIND_CODEX_RETRIGGER_MAX per HEAD, never unbounded), but NOT the detection, because
│ # COMPLETION needs genuine active-ness for its
│ # "engaged on recent PRs" warning + full-grace wait and a nudge-marker cannot supply
│ # that (it conflates force-on/kill-switched with historical activity). A detection-result
│ # breadcrumb WOULD remove the extra probe but is not worth another per-HEAD state
│ # artifact + arg plumbing on an already network-heavy merge path (codex-rescue concurred).
│ # The kill-switch gate below zeros BOTH probes for a Codex-less repo that sets
│ # PR_GRIND_CODEX_RETRIGGER=0 (Codex integration off) — the same switch gates COMPLETION's
│ # detection. Force-on repos under the kill switch are still covered by COMPLETION's
│ # force-on path when it is reached.
│ # Guard uses the worker-emitted RESULT_CODEX_ACK: on the clean path Invariant 2
│ # already proved it is not `stale`, so it is a <short-sha> (Codex engaged — no
│ # nudge) or `none` (never engaged — nudge). Empty (legacy worker) is `!= none`,
│ # so old-contract workers no-op exactly as before.
│ If RESULT_STATUS == clean AND RESULT_CODEX_ACK == "none" AND the Codex kill switch
│ is off (`${PR_GRIND_CODEX_RETRIGGER:-1}` != "0"), run this block BEFORE
│ proceeding to COMPLETION. Per the "CWD Reset Across Bash Calls" contract it
│ MUST open with `cd "$WORKTREE_DIR"` (template-substituted Step 0 path; the repo
│ root under --no-worktree) so the wrapper's CWD-derived force-on root and the
│ delegated CWD-relative marker resolve against the PR's own repo. `$PR_NUMBER`
│ is the Step 0 literal; HEAD is read inside the correct worktree after the cd.
│ CONTAIN gh routing FIRST (issue #470 P1 / #416): a committed .claude/settings.json
│ `env` block is repo-controlled, and GH_HOST / GH_REPO steer OUTBOUND credentialed
│ `gh` calls — GH_HOST sends them to an arbitrary host, GH_REPO re-points the target
│ repo. So the subshell PINS the host and CLEARS the repo override before any `gh`
│ runs (covering the wrapper's delegated codex-active-repo.sh / codex-retrigger `gh`
│ calls too), exactly as codex-nudge-premerge.sh:85-102 does. This routing pin is
│ deliberately scoped to the nudge, NOT extended dispatcher-wide: the dispatcher runs
│ in the operator session's ambient env, which a poisoned settings.json compromises
│ wholesale (PATH/BASH_ENV, every Bash call), so a broad env wrapper would be false
│ assurance — accepted residual, ADR 0026 (#475). Do NOT derive the repo
│ from an ambient `gh repo view` — that call is itself routable by GH_REPO/GH_HOST;
│ pass the dispatcher-resolved `<owner>/<repo>` PR metadata (same template values the
│ context block and COMPLETION use). owner/repo is passed so codex-active-repo.sh can
│ auto-detect — an empty repo arg is treated as inactive, silently dropping auto-detect
│ to force-on-only. The subshell ABORTS on a bad worktree (`|| exit 0`); the outer
│ `|| true` keeps a failed nudge from ever blocking the clean path:
│ ( cd "$WORKTREE_DIR" || exit 0
│ export GH_HOST=github.com; unset GH_REPO
│ bash "${CLAUDE_PLUGIN_ROOT}/scripts/codex-nudge-if-expected.sh" "$PR_NUMBER" \
│ "$(git rev-parse HEAD)" "<owner>/<repo>" || true )
│
├── Classify round and increment the appropriate counter:
│ # ONLY runs on RESULT_STATUS=needs_more — bail and clean rounds skip this
│ # block via the earlier branch in "Parse subagent output". This is
│ # intentional: bail terminates the loop (no future round to budget for)
│ # and clean ships the PR (same — no future round). Only needs_more
│ # rounds consume budget because only they cause another dispatch.
│ If RESULT_COMMIT_SHA != "none" → fix_round += 1 # dispatcher pushed a fix
│ If RESULT_COMMIT_SHA == "none" → wait_round += 1 # worker waiting for bots
│ # Classification reads RESULT_COMMIT_SHA, not the alias RESULT_HEAD_SHA —
│ # the dispatcher's tag-resolution step already canonicalized aliases
│ # before this point (see "Resolution order" in Dispatch a Round below).
│
│ # Codex sole-stale-blocker auto-re-trigger (bounded-N per HEAD, #673) — ADR 0005.
│ # On this WAIT-round (RESULT_COMMIT_SHA == "none", so HEAD is unchanged)
│ # where Codex is the SOLE stale ack — RESULT_CODEX_ACK == "stale" AND no
│ # registered bot in RESULT_REVIEWER_ACKS is "stale" (they all acked HEAD) —
│ # Codex will never self-ack the unchanged HEAD (it posts COMMENTED reviews /
│ # 0 reactions; its thread resolutions predate the push, Tier-A.2 fail-closed),
│ # so the next wait-rounds would just burn --max-wait and BAIL. Post `@codex
│ # review` so Codex re-reviews HEAD before the next round (→ fresh
│ # 👍/Tier-F ack → converge, or new findings → worker triages). The helper is
│ # deduped by attempt markers + cooldown (at most PR_GRIND_CODEX_RETRIGGER_MAX
│ # posts per (PR,HEAD)) so this is safe even though the
│ # worker's Step 6.5 mirrors the same call. Opt out: PR_GRIND_CODEX_RETRIGGER=0;
│ # phrase override (forks): PR_GRIND_CODEX_RETRIGGER_PHRASE. `|| true` keeps a
│ # failed post from ever staling the gate. Distinct from the COMPLETION
│ # first-engagement grace, which only RE-POLLS a `none` Codex (never a `stale`).
│ # #679 — post via the ordinary helper (skip-when-hot; no sleep — preserves
│ # worker→dispatcher mirror dedupe). Then --await-cooldown with the INTEGER
│ # remaining wait rounds AFTER this round (`MAX_WAIT - wait_round`, template-
│ # substituted — these are dispatcher conversation counters, NOT shell
│ # variables; `$(( MAX_WAIT - wait_round ))` in a fresh Bash would read as 0
│ # and skip pacing). If the marker is still hot and further rounds remain,
│ # SLEEP out the cooldown in the dispatcher loop so the next wait-round can
│ # spend attempt 2..N.
│ # Bash tool timeout MUST be >= COOLDOWN+60s (default COOLDOWN=180 → use
│ # timeout ≥ 240000ms on this invocation). A killed await leaves attempts
│ # 2..N unreachable — the #679 defect. Same class as COMPLETION's 480s Codex
│ # grace block: the long wait lives in a dispatcher-owned Bash call with an
│ # explicit raised timeout, never in the worker.
│ If RESULT_COMMIT_SHA == "none" AND RESULT_CODEX_ACK == "stale"
│ AND RESULT_REVIEWER_ACKS has no `stale` entry, run this block. Per the
│ "CWD Reset Across Bash Calls" contract it MUST open with `cd "$WORKTREE_DIR"`
│ (template-substituted to the literal Step 0 path — do NOT rely on shell-var
│ persistence or on the inherited CWD; `$PR_NUMBER` is likewise the Step 0
│ literal, and HEAD is read inside the correct worktree after the cd). The
│ cd runs in a subshell and ABORTS on failure (`|| exit 0`) so a bad
│ WORKTREE_DIR never lets git/gh run in the wrong repo:
│ ( cd "$WORKTREE_DIR" || exit 0
│ _head="$(git rev-parse HEAD)"
│ bash "${CLAUDE_PLUGIN_ROOT}/scripts/codex-retrigger.sh" "$PR_NUMBER" "$_head" || true
│ bash "${CLAUDE_PLUGIN_ROOT}/scripts/codex-retrigger.sh" --await-cooldown "$PR_NUMBER" "$_head" "<MAX_WAIT - wait_round>" || true )
│
└── Update state:
# PRIOR_COMMIT_SHA is the last FIX-ROUND's reported SHA and is RETAINED
# on wait-rounds: RESULT_COMMIT_SHA is "none" there, and overwriting
# would reset the #668 double-count guard — a later clean round sitting
# on the same Grind-PR commit would pass PRIOR_COMMIT_SHA=none and
# count the already-counted fix again.
PRIOR_COMMIT_SHA = RESULT_COMMIT_SHA if RESULT_COMMIT_SHA != "none"; retained otherwise
PRIOR_REVIEWER_ACKS = RESULT_REVIEWER_ACKS
PRIOR_CODEX_ACK = RESULT_CODEX_ACK # on fix/wait-rounds: overwrite with result_codex_ack from commit-block envelope (post-push); on clean path: use worker-emitted value. Backward-compat: if result_codex_ack absent from envelope (legacy commit-block), retain worker RESULT_CODEX_ACK unchanged — do NOT default to "none" (that would lose a stale signal from the worker).
PRIOR_ATTEMPTS += "Round N (fix=<fix_round>/<MAX_FIX>, wait=<wait_round>/<MAX_WAIT>): commit=<RESULT_COMMIT_SHA>; fixes=<RESULT_FIXES>; failures=<RESULT_REMAINING>; acks=<RESULT_REVIEWER_ACKS>; scope-skipped=<scope_skipped_this_round>; spawned=<issues_spawned_this_round>"
# commit= is the per-round provenance record: the SHA this round pushed,
# or the literal `none` on a wait-round. Without it the worker has only
# free-form `fixes=` prose plus PRIOR_COMMIT_SHA (the LATEST push), so it
# cannot map a finding back to the round that wrote the line — the Step 3
# proportionality gate's authorship discriminator then falls through to
# its uncertainty branch every time and can never fire (Codex + CodeRabbit,
# PR #620). Emit the SHA verbatim; the worker attributes a finding by
# blaming its LINE (`git blame -L`) and testing the resulting SHA against
# these values — not by the summary text, and not by which files a commit
# touched (a grind commit and an author finding can share a file).
# failures= is required — subagent's flaky-check bail (3+ rounds)
# reads it. Dropping it makes that bail unreachable and the loop
# will grind to MAX rounds instead of stopping early on a flaky
# check.
# acks= is preserved for diagnostics / human review of the loop
# transcript; the worker does NOT bail on stale-ack streaks (every
# commit-round emits all-stale by design, so a streak is the
# healthy case). Genuinely stuck bots fall out via MAX_WAIT.
# The fix=/wait= prefix in the round summary lets the worker (which
# gets PRIOR_ATTEMPTS in its context block) see budget pressure
# without needing the dispatcher to pass MAX_FIX/MAX_WAIT separately.
# scope-skipped= and spawned= record this-round contributions to
# Invariant 4's cumulative counters — visibility for the operator
# reading PRIOR_ATTEMPTS at bail time. Per-thread permalinks and
# spawn-issue numbers live in the spawned issues themselves
# (filter via `gh issue list --label scope-deferred`); duplicating
# them in PRIOR_ATTEMPTS would balloon the worker's context block
# for marginal clarity.
# Loop exits naturally when fix_round >= MAX_FIX OR wait_round >= MAX_WAIT
# without ever seeing RESULT_STATUS=clean → fail-CLOSED to BAIL, NOT to
# COMPLETION. The PR isn't clean; we just ran out of attempts. Writing the
# marker here would silently merge an unfinished PR. EXCEPTION: the
# wait_round >= MAX_WAIT branch below may still route to COMPLETION, but only
# via the explicit, condition-gated, logged ADR 0012 downgrade path (step 5) —
# never as a bare "ran out of attempts" fallthrough. Absent that opt-in/gate
# chain, exhaustion still fails closed to BAIL exactly as this paragraph says.
ON_LOOP_EXHAUSTED — two flavors, branch on which counter overflowed.
Both flavors emit RESULT_BAIL_CATEGORY=budget — this is the
dispatcher-only enum value documented in agents/pr-grinder.md
"Bail Triggers" (workers never emit `budget`; only the dispatcher
knows about MAX_FIX/MAX_WAIT exhaustion).
fix_round >= MAX_FIX → BAIL with reason "max-fix iterations (<MAX_FIX>) reached without clean status",
RESULT_BAIL_CATEGORY=budget
wait_round >= MAX_WAIT → derive STALE_AT_BAIL from PRIOR_REVIEWER_ACKS AND PRIOR_CODEX_ACK
(both persisted in the Update state block above): the comma-separated list of
registered bot logins whose ack value is the literal string `stale`, PLUS
`chatgpt-codex-connector` when PRIOR_CODEX_ACK is `stale` (Codex lives outside
PRIOR_REVIEWER_ACKS, so a Codex-only wait would otherwise produce an empty list
and read as a classification bug).
── ADR 0012: bounded advisory-bot stale-ack timeout downgrade (issue #295) ──
BEFORE bailing, attempt a bounded, logged, fail-CLOSED downgrade of the
stale advisory acks. This releases a green PR that is held hostage only
because a bot reviewed an old SHA, found nothing, and never re-acked HEAD
(e.g. Codex/Devin after a rebase). It NEVER touches merge authority — required
checks + litmus still gate; this only releases the *advisory* ack after those
are already green. Treats ALL registered advisory bots uniformly (no per-bot
special-casing — Codex and Cubic/Coderabbit/Greptile are aligned).
1. Opt-in gate: run the resolver and proceed ONLY if it prints `1`:
`OPTIN=$(bash "<PLUGIN_ROOT>/scripts/advisory-downgrade-optin.sh")`.
It returns `1` iff the per-repo file
`<STATE_DIR>/pr-grind-advisory-downgrade.local` (`<STATE_DIR>` =
`${BUSDRIVER_STATE_DIR:-.claude}`) is present at the main-repo root AND
accepted as operator consent — a non-repo-controlled (not in index/HEAD,
not gitlinked), non-symlink regular file (ADR 0012 boundary). There is NO
global env-var / global-file switch by design (both are repo-injectable —
see ADR 0012); to opt in many repos the operator drops the per-repo file
into each with a trusted loop, or runs `scripts/enable-advisory-downgrade.py`
(the hardened bulk enroller from #326 — openat+O_NOFOLLOW writes, acceptance
delegated back to this resolver).
Fail-CLOSED: `0` — not opted
in, or the resolver could not confirm/query the repo root — → skip to BAIL
below (unchanged). Run it from inside the PR's worktree so the per-repo
lookup's main-repo root is the PR's own repo (same CWD contract as the
sibling opt-ins).
*Truncated - read the full file at https://github.com/chris-yyau/busdriver/blob/047a4609c26b2c4da5e4e7d4504473e0d518c7f2/skills/pr-grind/SKILL.md.