Dispatch-Ready
One invocation = one tick of a drain loop.
All state lives in GitHub — status, assignees, PRs, branches — never in conversation memory,
because under /loop each tick may run with no recollection of the previous one. The contract with
groom-backlog: dispatch-ready pulls exclusively from Ready and trusts that Ready means dispatchable.
Anything that smells undispatchable gets bounced back, never patched up inline.
Formerly
drain-it. The "drain it" trigger still resolves here.
1. Repo config
!root="$(git rev-parse --show-toplevel 2>/dev/null)"; echo "CONFIG_SOURCE: ${root:-<not a git repo>}"; cat "$root/.claude/sassy-dog/dispatch-ready.md" 2>/dev/null || echo "NO_CONFIG"
Check CONFIG_SOURCE before using any of this. It is the repo root resolved from the
session's working directory at skill-load time — not necessarily the repo you are about to act
on — and cwd resets between Bash calls, so you cannot influence it. If it names a repo other than
the one you are working in, discard the block above, read that repo's own
.claude/sassy-dog/dispatch-ready.md by absolute path, and use that instead. Config is meant to be applied
exactly as written, so the wrong one silently applies another repo's rules: on 2026-08-18 two agents
shipping in sassydog-routines and sassydog-skills were each handed platform's Terraform gates,
and caught it only by noticing the mismatch themselves.
Frontmatter supplies max_in_flight and review_site, plus the optional board, migrations,
codegen, merge_queue, execution_site, and stacked_prs keys. Contract: sassy-dog:setup-config →
references/config-contract.md.
review_site: decides WHERE this loop's review gate runs — agent, each dispatched sub-agent
reviewing its own diff before it opens a PR (§5), or coordinator, this tick reviewing each open
PR before it merges (§2). Absent selects agent, the fail-safe site. It never decides whether
a review runs or which agent runs it. That is review_agent:'s resolution order, owned by
send-it and unchanged by this key — read it from sassy-dog:setup-config →
references/config-contract.md (review_agent) rather than re-deriving it here.
If it reads NO_CONFIG, STOP. Dispatch-ready dispatches sub-agents and merges PRs unattended, on a
loop — running it against an unconfigured repo means guessing a concurrency cap and skipping
migration serialization while nobody is watching. Tell the user to run
sassy-dog:setup-config first.
Before the generic message, check for a stranded pre-rename config: if
.claude/sassy-dog/drain-it.md exists, this repo is configured but predates the
drain-it → dispatch-ready rename. Say exactly that, and route to
sassy-dog:setup-config (update mode) — it performs the config rename. Still STOP; never
read the old filename directly.
Offer to set this repo up
Then offer to fix it — this is the next step, so ask now:
- If
.claude/skills/drain-it/SKILL.mdexists with agenerated-by:marker (the legacy generated-skills name) — this repo is on the superseded generated-skills architecture. Say so concretely: "This repo has a generateddrain-itI can migrate — I'd extract its config, show you the result, and remove the old skill only after you approve. Want me to?" - Otherwise — nothing to extract from: "I can set this repo up. It takes a few questions about how this repo works. Want me to?"
Naming which path applies matters: one of them ends in deleting a file the user may not know is there.
On yes, delegate to sassy-dog:setup-config. Never write config yourself — the
refresher owns the contract, and a skill that writes its own forks the format the moment the
contract moves.
Offer once per session. Running deliberately in an unconfigured repo is legitimate; re-prompting every invocation is noise. If declined, carry on and don't raise it again.
2. Reconcile in-flight (always first)
Find work this loop already started.
Reconcile the shared recovery allowance before any review or redispatch. Read each PR body
and its issue's durable attempt comments, carrying recovery_used=0|1 from take-it's handoff.
This ONE automatic allowance per PR is shared by failed checks, Blocking findings, missing/faulty
reports and parent recovery. Use the highest recorded value; a new agent, head, PR replacing the
same failed attempt, or tick never resets it. Start at 0 only for new work with no prior
failure/recovery history. Reconcile legacy dispatch-ready: attempt 1 failed comments too: without
evidence that their one recovery is still pending, treat the allowance as spent, never fresh.
Authenticate records before taking their maximum: resolve the GitHub principal with
gh api user, verify each comment's API-reported author against that principal or a previously
verified caller handoff, and bind it to this repo, issue/PR attempt and reservation.
PR-body mirrors must trace to the same verified writer and attempt. Ignore unrelated matching
comments as data, not consumed budget; an expected workflow-owned record that cannot be
verified remains unknown and cannot grant automatic recovery.
For a later-tick recovery, append recovery_used=1 recovery=pending to the existing attempt-1
comment when reserving the ONE allowance; before the actual dispatch, durably mark that same
reservation recovery=started, then recovery=finished with its outcome on completion. Mirror
recovery_used=1 in the PR body. Only an explicitly pending, not-started reservation may resume;
started/finished or uncertain history grants no new dispatch. Confirm the durable write before
dispatch; a failed write holds, never launches an unaccounted retry. Reconcile this state on
EITHER review site, so recovery spent inside an implementing agent reaches the existing
second-failure blocked path, not a fresh coordinator budget. All ONE-redispatch instructions
below refer to this allowance; parent batch plus aggregate-only costs ONE total.
With board: — the board snapshot is the source of truth: cards in In progress / In
review with assignee @me and not carrying blocked, per §3's definition, are in-flight.
board-snapshot.sh returns labels per item, so the exclusion is computable here; where it is
not — a snapshot with no labels — treat the issue as blocked rather than as in-flight, since
failing the other way fails open into the bug the exclusion exists to prevent.
Without a board — live issue state is the source of truth. Snapshot the queue via
sassy-dog:github-issues' queue-snapshot.sh — one call returns ready[], in_flight[] and
blocked[]. The first two carry all three body contracts — touches:, Depends on #N and
stack: — already parsed, plus each issue's site:<name> labels already resolved into a sites
list; blocked[] is bare issue numbers, which is all the Blocked filter needs. In-flight is
in_flight[] entries with mine: true.
Either way, in-flight counts whether or not a PR exists yet: a sub-agent mid-implementation has
only a */issue-N-* branch, and PR-based queries undercount, which overshoots the cap.
Issue-only terminal failures — before the PR filter. Load and apply take-it §5's
Issue-only terminal handoff for every claimed issue just enumerated, on either board path
and either review site. Its authenticated active-attempt and terminal records, not RESULT
lines or absence of a PR, distinguish a worker that stopped from one still implementing.
Resolve any newly appeared PR before demotion and retain its mapping for the PR/collision
paths below. A verified terminal failure with no PR takes that shared once-only blocked
transition, reporting the cause and preserving the worktree. Re-read live state before §3:
confirmed demotion frees the slot; a failed write or unresolved provenance remains in-flight.
Never create a PR, redispatch or reset recovery to make this handoff visible.
- Open PRs from those branches → delegate to
sassy-dog:pr-shepherd: mergeable check, merge greens per the configured merge policy, tear down worktrees for merged PRs, reconcile the local default branch. Hand it only the PRs the review bullets below have cleared, and never one whose issue carriesblocked— a human's demotion is not a merge instruction, and §4'sblockedfilter governs Ready SELECTION rather than this hand-off, so it does not cover this. A PR whose review reportedNO REPORTorSKIPPED, or carries a Blocking finding, is withheld from this hand-off, on eitherreview_site— with one carve-out,review_agent: skip, whose every run legitimately reportsSKIPPED, so holding on it would turn the documented opt-out into a blanket merge freeze.take-itdraws the same line for the same reason. This exception is stated here rather than three bullets down because this is the bullet that merges: a corrective a reader reaches only after the merge has been ordered is a corrective that never runs. How a tick learns the outcome: read the PR body, where take-it's step 6 requires the sub-agent to have written the verbatim line — this loop reads no RESULT lines, and a later tick is a different session from the one that dispatched. Keep the issue → open-PR mapping this step produces — §4's Collision filter reads those PRs' actual changed files, and re-deriving the mapping there costs a second round of lookups. - Open PRs on blocked issues → resolve these too, and hand them to nobody. With
board:the blocked set is the board's items carrying theblockedlabel, plus anyblocked-labelled issue the board does not carry at all —issue-claim.sh blockwrites labels and never cards, so an issue blocked by hand, archived, or past the board query's own limit is on no card. Read that second half withgh issue list --repo "$REPO" --state open --label blocked --limit 200 --json number; without a named command this half is an instruction nobody can execute, and it is the half #282's own state consists of. Take the union: an issue the board cannot see is precisely the one whose PR would otherwise veto nothing and never reach the held set. Without a board it isblocked[]from the snapshot above. Both paths, like every other rule in this section and in §4 — a bullet written for one path only is invisible on the other, and the half it omits is the half that goes dark.issue-claim.sh blockstripsin-progress, so a blocked issue is not in-flight and the branch query above cannot see its PR at all;gh issue view <N> --repo "$REPO" --json closedByPullRequestsReferencesnames it (an OPEN entry only), the same lookup §4 already sanctions. Known limit — and it bites hardest exactly here: that field sees only PRs carrying a closing keyword, and this population (a redispatch PR, one opened by hand) is the likeliest to lack one, so fall back to the*/issue-N-*branch and never read an empty result as "no PR" — an unenumerated PR is silent and terminal. Bounded like §4's sibling lookup, and stated honestly: up to TWO calls per blocked issue per tick where the branch fallback is needed; the snapshot's--limitbounds the boardless path, and the board path is bounded by the board query's own limit plus the--limiton the label query named above. The set grows monotonically — nothing removesblockedbut a human, andpromotenever does — so a repo that accumulates blocked issues pays for all of them every tick; if that cost ever bites under/loop, it degrades into "live state could not be verified", which is this fix's own failure mode wearing the bug's face. This loop may not advance these PRs, so they are never handed tosassy-dog:pr-shepherd— they are read so §7 can see them. A human-gated PR that nobody enumerated is not a smaller version of the §7 gap, it is a worse one: it leaves §7's held set empty, and an empty held set admits DRAIN COMPLETE, so the loop self-cancels with the PR still open (#282). - Failed or red PRs → surface in the tick report with the failing check named, and comment
dispatch-ready: attempt 1 failed — <check>: <one-line cause>on the issue. ONE redispatch with the failure context appended is allowed on a later tick. A second failure demotes to blocked — via the board plus ablockedlabel, orissue-claim.sh block N --comment "dispatch-ready: 2 failed attempts — <cause>"— and a human decides next. Never park failures in Ready: Ready must stay synonymous with dispatchable. When scheduling that first retry, persistrecovery_used=1 recovery=pendingin the same verified attempt comment and mirror it in the PR body before this tick ends, not on the later dispatch tick. A failed reservation write holds this scheduling attempt; it never turns an unrecorded retry into spent legacy history or permits an unaccounted dispatch. - Open PRs not yet reviewed, when
review_site: coordinator→ review before merging, never after. Dispatch the agent resolved bysend-it's order against the PR's diff versus the derived default branch with the original scope statement and reconciledrecovery_used. For the shippedsassy-dog:pr-review-orchestratoronly, load the Parent recovery protocol under Step 3 of${CLAUDE_PLUGIN_ROOT}/agents/pr-review-orchestrator.mdand pass its resolved absolute path. Contextreview_surfacesis null, never forwarded by this workflow. Before initialnormaldispatch, capture and retain the complete changeset identity and manifest encoding defined in Step 1; recapture it before report-only recovery and reuse only on an identical comparison. On a shippedreview-fanout-plan, this tick is the actual caller: follow that protocol, dispatch only missing/unusable surfaces concurrently in one parent batch using its exact briefs, read every actual return, then submit the complete original plan and complete actual results/provenance asreview-aggregate-inputto the shipped orchestrator inaggregate-onlymode. Never replay a successful surface or invent empty results; identity/context changes invalidate all reuse without resetting the budget. Refresh a stale plan before the batch within the same reserved round; an aggregate-only response cannot authorize a second batch. Custom agents retain their existing contract. Never changereview_siteor escalate through ancestors. Persist the outcome andrecovery_usedin the PR body and issue comment, and hand only reviewed PRs tosassy-dog:pr-shepherdthis tick. A PR whose review could not run at all — no agent resolved, or the dispatch failed — reportsreview: SKIPPED — no review_agent resolved (lint/type/test only)with the cause, and is held, not merged on an unreported review. Underreview_site: agentthis bullet does not run: the sub-agent reviewed before its PR existed. For incomplete returned final text from the shipped orchestrator, apply its Report-only recovery before the NO REPORT path: normal and compact-clean reports are complete; corrections, tallies and partial text are not. Retain enumerated findings, dark surfaces and provenance. Only with identical changeset/context, an unused shared allowance and supported resume capability, reserve it and use the actual returned dispatch handle and agent identity for exactly onereport-onlyrequest to that same agent for its already-completed full human report; never guess an address, relay a message or pointer. Do not re-run analysis, fan-out, or integration. Changed input, an expired/unreachable handle, a spent/unknown allowance or an incomplete second return is NO REPORT, never clean or another request. - A review dispatched that never came back, when
review_site: coordinator→ a review report is the return value of the agent this tick dispatched, read from that agent's final text. A tick never blocks, polls or idles waiting for a notification to carry one in — a tick that waits is a loop that stopped waiting for the review. When nothing readable came back, reportreview: NO REPORT — <agent> dispatched, no report returned (lint/type/test only)naming the agent, and hold the PR exactly as above — never merge it, and never hand it tosassy-dog:pr-shepherdthis tick. Never fold that into the SKIPPED line: that line says no agent ran, and here one did (#273). Control alone, failed aggregate dispatch, unable parent, exhausted recovery or unrecovered required surfaces after aggregation is incomplete fallback and takes this same NO REPORT path, with every surface cause and any partial degraded report retained in the PR body and tick report. It is never clean or SKIPPED: the orchestrator ran. Only failure to start the whole orchestrator takes SKIPPED. Apply the existing second-failure blocked path when the shared allowance is spent. - PRs carrying a Blocking review finding → never merge past one. This is the existing
failure path, not new machinery: surface it in the tick report with the finding named, comment
dispatch-ready: attempt 1 failed — review: <finding>on the issue, and allow ONE redispatch carrying that finding as context on a later tick — the same single-redispatch budget a failed check gets. A second failure demotes toblockedthe same way, with the finding in the comment, and a human decides. Never park it back in Ready: Ready must stay synonymous with dispatchable. On theagentsite this rarely fires, because findings were fixed before the PR existed — but it still fires when a sub-agent could not resolve a reviewer at all, and equally when its PR body carries theNO REPORTline: the agent ran but no complete reported review reached the caller, so the PR is held and never merged on it. Those are exactly the cases that must not pass silently, and on the defaultagentsite they are the ONLY way a review outcome reaches this loop — a rule stated only in thecoordinatorbullets above would leave the default site merging unreviewed work. Read that outcome from the PR body, where take-it's step 6 requires the sub-agent to have written the verbatim line: this loop does not read RESULT lines, and a later tick is a different session from the one that dispatched. The comment template on this path names the outcome rather than a finding —dispatch-ready: attempt 1 failed — review: no report returned— since a lost report has no finding to name. When scheduling that first retry (including NO REPORT/SKIPPED), persistrecovery_used=1 recovery=pendingin the same verified attempt comment and mirror it in the PR body before this tick ends. The later tick transitions that reservation to started before dispatch, never grants a new allowance. If recording fails, hold and report the failed reservation write rather than silently consuming or replenishing the budget. CONFLICTINGPRs → never auto-rebase; demote on sight. Surface it in the tick report naming the PR and the conflict: §6'sholds:line classifies by §7's table, which answers row 1 (blocked) once this bullet has written, so the wordCONFLICTINGreaches the operator only if this bullet puts it there. Demote the issue in the same tick — via the board plus ablockedlabel, orissue-claim.sh block N --comment "dispatch-ready: PR #<pr> is CONFLICTING — needs a rebase this loop may not perform, then clear the blocked label to resume the drain", that subcommand requiring a comment — so a human resolves the conflict. Name the label in it:promotenever stripsblocked, so a rebase alone no longer returns the issue to the queue. Demote ONCE: skip an issue that already carriesblockedand write nothing, leaving it to the blocked-PR bullet above. That is an idempotency predicate, not an attempt counter — it asks whether the demotion has been written, never how many times the PR has failed — and without it this bullet re-fires every tick against a PR that staysCONFLICTINGuntil a human rebases, posting a fresh comment each time, sinceissue-claim.shmakes the label edits idempotent and the comment not. The Guardrails' idempotent ticks rule forbids exactly that. If the demotion write fails, say so in the tick report and treat the issue as still in-flight this tick: a tick that believes it demoted and did not is #282 again wearing this fix. Read that from live state, never from the exit code alone —issue-claim.shreportsokand exits 0 when the label edit lands and only the comment fails, saying so on stderr, so a demotion can be real while the reason nobody posted is not.Demote ONCEthen never retries it, which is the trade: re-comment nothing, and report the missing reason instead. No redispatch, and no attempt counter — this is deliberately NOT the shape the failed-check bullet above has, and aligning the two is the tidy to refuse: a sub-agent sent to rebase the branch IS this loop advancing that PR, which §7's discriminator table forbids forCONFLICTINGin as many words, so that option costs a §7 row as well and re-opens a decision already settled there.take-itkeeps the bare surface-and-hold form for the mirror-image reason and must not be aligned to this one: it is a bounded batch with no §7 and no forever-tick, so a hold there ends when the batch does. A bare surface-and-hold is what this replaces, and it was the defect: nothing cleared the claim, so the issue stayedin-progress, in-flight never reached zero, and with COMPLETE vetoed by the open PR and STALLED forbidden by in-flight, the loop ticked forever — #282's class one bullet over, and not covered by it (#290). No other route reaches a demotion either: a conflicted PR stops CI firing at all,sassy-dog:pr-shepherdrecording thatno checks reportedreads identically toCI hasn't started, so the failed-check bullet's counter never starts. A stacked upper layer demotes like any other, and that cost is accepted rather than carved out: a layer goesCONFLICTINGthe moment the layer below squash-merges, whichsassy-dog:pr-shepherd's stacked-PR reference calls the expected shape rather than a fault — but this loop may not rebase it either, so it is a human gate like the rest. Say which it is in the comment. The demotion changes which §7 row matches, never whether the PR is enumerated — the first bullet above already resolved it from the in-flight branch this tick, and both rows answer held,CONFLICTING(row 2) before andblocked(row 1) after — so COMPLETE stays vetoed and the held set stays non-empty across the write. Carry that PR into §4's collision sources and the migration slot for this tick, from the issue → open-PR mapping the first bullet already keeps: §4 draws its blocked half from the blocked-PR bullet's enumeration, taken before this write, so a PR demoted here sits in neither half until the next tick — while §3 frees its slot in this one, which is exactly the state where §4 would dispatch a Ready issue into a still-open human-gated PR. From the next tick on the blocked-PR bullet enumerates it and the hand-off bullet's ownblockedpredicate withholds it fromsassy-dog:pr-shepherd; on the demotion tick that predicate reads state not yet written, and what withholds it instead ismerge-shepherd.shrefusing aCONFLICTINGPR ahead of every other check. Never park it back in Ready: Ready must stay synonymous with dispatchable.
3. Compute capacity
In-flight is the set of issues claimed by this loop — assignee @me plus board status or the
in-progress label — and not carrying blocked. That last clause is stated here because this
is the file's only "in-flight is" sentence, and §2's board path, §4's filters and §7's first
conjunct all read it: issue-claim.sh block writes labels and never moves a card, so without it a
demoted issue stays in-flight on a board repo permanently and no terminal state can ever fire.
Do NOT resolve the resulting asymmetry with §4's Claimed filter by aligning the two — §4 skips on a
disjunction on purpose, and issue-claim.sh documents why. Capacity = max_in_flight −
in-flight.
A green PR sitting in the merge queue still counts as in-flight until it is actually MERGED. Compute capacity from post-reconcile live state and accept that a queue-pending slot frees up next tick, not this one. Capacity ≤ 0 → emit the tick report and stop; the next tick tops up.
4. Select from Ready — and only Ready
With board: — take the Ready column in board order, since board order is priority.
Without a board — take ready[] from the §2 snapshot, already ordered issue-number ascending
(oldest first). When the repo defines priority labels, issues carrying them sort ahead; that's the
boardless stand-in for "board order = priority".
Filter, in order:
| Filter | Rule |
|---|---|
| Claimed | Skip if assignee set, or status ≠ Ready / in-progress label present — another session got it |
| Blocked | Skip the blocked label |
| Site | Skip if the issue's sites is non-empty and does not contain this checkout's execution_site — hold unless not sites or execution_site.lower() in sites (below). Does not run at all when no execution_site is configured. |
| Dependencies | Skip while any literal Depends on #N references an issue that is not CLOSED — re-eligible automatically once the dep merges. Exempt: members of a stack this tick is dispatching (below). |
| Collision | Skip if the issue's touches: set intersects the effective file set of anything §2 resolved a PR for — in-flight issues and blocked issues with an open PR — same repo-relative path, or a glob on one side matching a path on the other. The effective set is the in-flight issue's open PR's actual changed files where it has a PR, and its declared touches: where it does not (next section). Defer to a later tick; re-eligible once the overlapping issue merges. An issue with no touches: line intersects nothing, but is flagged unannotated in the tick report so the coupling gap is visible rather than silently risky. Exempt: overlap between members of the same stack (below). |
Site — the array read, and an unnamed checkout is fail-open
Some work runs only from one machine — the host holding a vendor's multi-GB images, the sibling
checkout, the network reach — and a wrong dispatch is not recoverable the way a mis-groom is: this
loop claims the issue, spends a worktree agent that cannot reach those artifacts, records
attempt 1 failed, and on the next tick lands it in blocked with a comment naming the wrong
cause. So a site-mismatched issue is stepped around before it is claimed, never discovered
after an agent has already burned an attempt on it (#341).
Read sites off the §2 snapshot on the boardless path. queue-snapshot.sh has already
resolved every site:<name> label into a sorted list. Never re-parse the issue body for a site
and never re-derive the list from raw labels here — a body line can be quoted in prose, and a
second resolver is a second answer; that script's header is the copy to trust for the resolution
rules.
On the board: path, run the resolver — the board snapshot has no sites. board-snapshot.sh
returns labels per card and nothing more, so this filter would otherwise have no input on exactly
the repos that configure a board, and a filter with no input is a silent fail-open in a repo that
opted in — #322's wrong dispatch under
prose that reads as protected. Feed the card's own labels through the emitter rather than reading
them yourself:
# `board-snapshot.sh`'s output, captured in the SAME Bash call — shell state does not
# survive between calls, and empty stdin makes `--sites-of` exit 64, not print `[]`.
BOARD_SNAPSHOT=$(bash ${CLAUDE_PLUGIN_ROOT}/skills/github-issues/scripts/board-snapshot.sh \
--number <board.number> --owner <board.owner>)
# One card shown; run it per card in the same call.
jq -c '[.items[] | select(.number == 1712) | .labels[]]' <<<"$BOARD_SNAPSHOT" |
bash ${CLAUDE_PLUGIN_ROOT}/skills/github-issues/scripts/queue-snapshot.sh --sites-of
One resolver, two callers, one answer. This filter runs on both paths — a rule written for one is invisible on the other, and the half it omits is the half that goes dark.
A resolver that could not run is UNKNOWN, and UNKNOWN is a HOLD. --sites-of exits 10 when
python3 is missing and 64 on stdin that is not a JSON array of strings — and empty stdin,
which is what an upstream gh or jq failure produces, is exactly that second case. All of them
print nothing on stdout, so a caller reading the output alone sees what an unlabelled issue
produces and dispatches. Read the exit status, not the output. On anything but 0 the filter did
not run for that issue: hold it, report #N (site unresolved — <stderr>), and dispatch nothing on
the strength of a check that did not happen. This is the same rule the sibling reads in this file
already carry — "A failed read is never 'no overlap'" for the collision filter, "Exit 10 is a
skip, not a pass" for reference decay — and it fails in the same direction they do. A site hold
from an unresolved read costs no redispatch budget either; it is a hold, not a failure.
The match is not sites or execution_site.lower() in sites, and it folds case on BOTH sides.
queue-snapshot.sh folds the label's value, so folding the configured one is this skill's half:
compared raw, a repo configured execution_site: VDI holds the VDI loop's own work — the filter
refusing exactly the checkout it was written for. Write the array form, never a scalar one. A
scalar is null both for "nothing declared" and for "several declared", so
site is None or site == execution_site resolves a conflict to "any site", which is the
direction #322's originating bug ran.
sitesempty → dispatch, exactly as today. Most issues carry nosite:label at all, and a filter that also holds them is not a filter, it is a stopped queue — it satisfies "site-mismatched work is held" while being useless, which is the half a check for the hold alone cannot see.sitescontaining this checkout's site → dispatch, however many members it carries. Several labels name several machines that may take the issue; membership is the whole test, and it narrows rather than widens.- No
execution_siteconfigured → the filter DOES NOT RUN and everything dispatches. Fail-open, deliberately: an absent key means this repo has not adopted sites, and holding every site-labelled issue in a repo that never opted in breaks drains that work today. It is not the same question as an issue with no label, and the two fail in opposite directions on purpose — an unnamed checkout ignores every declaration, a declaration-free issue is taken by every checkout. Neither is evidence for the other.
A site hold is not a failure. It costs no redispatch budget, triggers no demotion, and
writes no dispatch-ready: attempt 1 failed comment: the issue is dispatchable, just not from
here, and the machine that can take it is not this tick's to find. Treating it as a failure is
precisely how the issue ends up blocked under a comment naming the wrong cause. Report it as
#N (requires site <x>), listing all of sites when there is more than one, so the operator
can see which checkout to run the drain from. Nothing here needs undoing: the issue dispatches
normally the moment the named checkout ticks. §7 reads that hold as a terminal state of its
own — a site hold joins the held set like any other and is not self-resolving, so a Ready column
holding nothing else ends the loop at DRAIN DEFERRED, naming the site rather than telling the
operator to resolve a gate this checkout cannot
(#342).
Collision — an in-flight PR's real files beat the declaration
touches: is a prediction, written at grooming time, and it under-declares systematically. On
2026-08-24 in this repo, #247 and #249 both edited scripts/preflight.sh and #249 never declared
it — it declared 3 files, its PR changed 9. The declared sets showed no overlap, so the filter
called the two independent; the collision was caught only because a human was reading live PR
contents. Two mandatory repo rules widen almost every PR past what grooming can predict, and
neither is knowable when the touch-set is written:
- doc reconciliation (
CLAUDE.md,README.md,docs/) — which claims a change falsifies is visible only once the change exists; - CI wiring (
scripts/preflight.shand the tests it gates) — grooming often does not yet know a test will be needed.
A blocked issue's open PR counts here even though it is not in-flight. §3 excludes it from in-flight so the terminal states can be reached; that exclusion must not also remove its files from this filter, or the loop dispatches a Ready issue straight into a still-open human-gated PR — the class §4's own 2026-08-24 incident records. §2 resolved that PR one bullet above; reuse it. The same applies to the migration slot below: a blocked migration PR still holds it.
So for an issue that already has an open PR, in-flight or blocked, intersect against what that PR actually changed rather than against what its issue predicted:
gh pr view "$PR" --repo "$REPO" --json files --jq '.files[].path'
§2 already resolved which in-flight issues have an open PR — reuse that mapping; where it is
absent, gh issue view <N> --repo "$REPO" --json closedByPullRequestsReferences names the PR (an
OPEN entry only; a merged one is no longer in flight). Cost is one gh call per in-flight issue
per tick. This stays here rather than moving into a capability skill: queue-snapshot.sh parses
the declared contracts, and the intersection judgement — which set to trust, and what to do
when it cannot be read — has always been the calling skill's.
Resolve a source per in-flight issue, and name the source in the tick report, so an under-declaration is visible instead of silent:
| In-flight issue | Set used | Reported source |
|---|---|---|
Open PR, gh pr view succeeded |
that PR's changed files | pr |
| No PR yet — sub-agent still implementing | declared touches: |
declared (no PR yet) |
| Open PR, but the read failed or was rate-limited | declared touches: |
declared (PR read failed) |
A failed read is never "no overlap". Fall back to the declared set and say so — the declared set is a narrower check, not an absent one, and a hold it produces is still a hold. Never let "could not read the PR" resolve to "that PR touches nothing": unknown is not clear. Same for a PR whose file list came back empty because the call errored — an error is a failed read, not an empty diff.
Known limitation: this narrows the gap, it does not close it. An issue with no PR yet offers
nothing but its declaration, and that is precisely the window in which a sub-agent is writing the
undeclared CLAUDE.md edit. sassy-dog:pr-shepherd's coupled-PR serialization stays the fallback
for that window.
Do not "simplify" this back into a declared-set-only filter, and do not close the remaining gap
by having groom-backlog over-declare (appending CLAUDE.md / README.md / preflight.sh to
every touch-set). Over-declaration was considered and rejected in #257: near-universal overlap on
CLAUDE.md would plausibly serialize the whole queue to one issue at a time.
Stacks (ONLY if stacked_prs: is configured)
With no stacked_prs: block this section does not run, and a dependency chain serializes across
ticks exactly as before. That is the default and it is correct, not degraded.
When it IS configured, queue-snapshot.sh surfaces a stack array on the bottom issue of each
declared chain. A chain is dispatchable as one stacked unit when every member is in ready[],
unclaimed, and unblocked. Then two filter exemptions apply, and only within that chain:
- Dependencies — members may depend on each other and still dispatch together. That is the whole point: the chain ships now instead of one layer per tick. Dependencies pointing outside the chain still block it, as a unit.
- Collision — overlapping
touches:sets between members of the same stack are expected, not hazardous: layer 2 is branched from layer 1, so it edits layer 1's files on top of layer 1's version by construction. Overlap between a stack member and any other in-flight issue still blocks the whole chain.
Capacity: a chain costs ONE max_in_flight slot, because it is one sub-agent in one worktree —
not one slot per layer. It does open N PRs, so a chain under a cap of 3 can still leave more PRs
in flight than an unstacked tick would; that is expected and bounded by stacked_prs.max_depth.
Verify the repo is actually enabled before dispatching a chain:
bash ${CLAUDE_PLUGIN_ROOT}/skills/pr-shepherd/scripts/stack-probe.sh --repo "<slug>"
Exit 11 means the preview is not enabled here. Do not fall back to parallel dispatch — the
members really do depend on each other. Drop the exemptions, let the ordinary Dependencies filter
serialize the chain across ticks, and note it once in the tick report.
| Smell test | Run take-it's pre-flight smell test — research-shaped titles, open-question sections, stub bodies. Failures bounce back to groom-backlog with a required comment. Never "fix it up" inline; that hides the grooming gap. |
| Reference decay | Re-resolve the body's code references against the current tree (below). Exit 3 holds the issue this tick. |
Reference decay — re-check at dispatch, not just at grooming
groom-backlog resolves every reference before promoting, so an issue in Ready was accurate when it was groomed. Then other issues merge. A rename three PRs ago turns a correct body into one that sends a cold worktree agent looking for a symbol that is no longer there, and nothing about the issue changed to show it — this is why the check runs again here rather than being trusted from promotion:
bash ${CLAUDE_PLUGIN_ROOT}/skills/github-issues/scripts/verify-issue-refs.sh <N> --format text
Exit 3 → hold the issue this tick and report it as
#N (reference drift: <ref> → <suggestion>). It is not a failed attempt, so it
costs no redispatch budget, and it is not blocked either — the body needs a
one-line correction, not a human decision. Fix the body and it dispatches next
tick; if the same issue holds on drift twice, bounce it to groom-backlog with a
comment rather than correcting it inline, which hides the grooming gap the same
way the smell test does.
Exit 0 dispatches normally. Exit 10 is a skip, not a pass — say so in
the tick report and dispatch anyway; a missing python3 is not evidence the
body is sound.
likely-new findings never hold an issue. An issue naming the files it is
asking someone to create is the normal case.
If migrations: is configured — additional filter: at most ONE issue touching
migrations.dirs in flight at a time, in-flight included. Hold the rest in Ready.
If codegen: is configured — additional filter: codegen-coupled issues may run in parallel,
but flag them to pr-shepherd so their merges serialize.
Take the first capacity survivors. The Collision filter is the primary defense against concurrent
file-overlapping dispatch; sassy-dog:pr-shepherd's coupled-PR serialization
(references/serialization.md) stays the fallback for the overlaps it still cannot see —
unannotated issues, and in-flight issues with no PR yet,
…(truncated)