# Dispatch Ready

> Loop-driven dispatcher: each invocation is one idempotent tick that reconciles in-flight PRs, then tops the pipeline back up to the configured concurrency limit, pulling ONLY from issues marked Ready, respecting dependencies and migration/codegen sequencing, until the loop reaches one of its four terminal states. Designed to run under "/loop 5m /dispatch-ready" but a single manual invocation is also valid. Use when the user says "let's work ready items", "work ready items", "dispatch ready items", "dispatch ready items in the backlog", "dispatch the backlog", "work the ready queue", "drain it", "drain the backlog", "work through Ready", "keep shipping until Ready is empty", or invokes it via /loop. Reads the current repo's settings from `.claude/sassy-dog/dispatch-ready.md`.

- Skill: `sassy-dog/dispatch-ready` (Agent Skill)
- Install (CLI): `npx skillmds@latest add sassy-dog/dispatch-ready`
- Raw SKILL.md: https://api.skillmd.com/api/skills/sassy-dog/dispatch-ready/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Product & Planning
- Author: Sassy-Dog (https://skillmd.com/u/sassy-dog)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/sassy-dog/dispatch-ready

---


# 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.md` exists with a `generated-by:` marker** (the legacy
  generated-skills name) — this repo is on the superseded generated-skills architecture. Say so
  concretely: *"This repo has a generated `drain-it` I 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 carries `blocked`** — a human's demotion is not a merge instruction, and §4's `blocked`
  filter governs Ready SELECTION rather than this hand-off, so it does not cover this. A PR whose
  review reported `NO REPORT` or `SKIPPED`, or carries a Blocking finding, is withheld from this
  hand-off, on either `review_site` — with one carve-out, `review_agent: skip`, whose every run
  legitimately reports `SKIPPED`, so holding on it would turn the documented opt-out into a blanket
  merge freeze. `take-it` draws 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 the `blocked` label, **plus any `blocked`-labelled
  issue the board does not carry at all** — `issue-claim.sh block` writes 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 with
  `gh 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 is `blocked[]` 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 block` strips `in-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 closedByPullRequestsReferences` names 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 `--limit` bounds the boardless path, and the board path
  is bounded by the board query's own limit plus the `--limit` on the label query named above. The set grows
  monotonically — nothing removes `blocked` but a human, and `promote` never 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 to `sassy-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 a `blocked` label, or `issue-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, persist `recovery_used=1 recovery=pending` in 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 by `send-it`'s order against the PR's diff versus the derived
  default branch with the original scope statement and reconciled `recovery_used`. For the shipped
  `sassy-dog:pr-review-orchestrator` only, load the **Parent recovery protocol** under Step 3 of
  `${CLAUDE_PLUGIN_ROOT}/agents/pr-review-orchestrator.md` and pass its resolved absolute path.
  Context `review_surfaces` is null, never forwarded by this workflow. Before initial `normal`
  dispatch, 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 shipped `review-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 as
  `review-aggregate-input` to the shipped orchestrator in `aggregate-only` mode. 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 change `review_site` or escalate through ancestors. Persist the outcome and
  `recovery_used` in the PR body and issue comment, and hand only reviewed PRs to
  `sassy-dog:pr-shepherd` this tick. A PR whose review
  could not run at all — no agent resolved, or the dispatch failed — reports
  `review: SKIPPED — no review_agent resolved (lint/type/test only)` with the cause, and is held,
  not merged on an unreported review. Under `review_site: agent` this 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 one
  `report-only` request 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, report
  `review: 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 to
  `sassy-dog:pr-shepherd` this 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 to `blocked` the 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 the `agent` site 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 the `NO REPORT` line: 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
  default `agent` site they are the ONLY way a review outcome reaches this loop — a rule stated
  only in the `coordinator` bullets 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), persist
  `recovery_used=1 recovery=pending` in 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.
- **`CONFLICTING` PRs** → never auto-rebase; **demote on sight.** Surface it in the tick report
  naming the PR *and the conflict*: §6's `holds:` line classifies by §7's table, which answers row
  1 (`blocked`) once this bullet has written, so the word `CONFLICTING` reaches the operator only
  if this bullet puts it there. Demote the issue in the same tick — via the board plus a `blocked`
  label, or `issue-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:
  `promote` never strips `blocked`, so a rebase alone no longer returns the issue to the queue.
  **Demote ONCE**: skip an issue that already carries `blocked` and 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 stays `CONFLICTING`
  until a human rebases, posting a fresh comment each time, since `issue-claim.sh` makes 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.sh` reports `ok` and
  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 ONCE` then 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 for `CONFLICTING`
  in as many words, so that option costs a §7 row as well and re-opens a decision already settled
  there. `take-it` keeps 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 stayed `in-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-shepherd` recording that `no checks
  reported` reads identically to `CI 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 goes `CONFLICTING` the moment the layer below squash-merges, which
  `sassy-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 and `blocked` (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 own
  `blocked` predicate withholds it from `sassy-dog:pr-shepherd`; on the demotion tick that
  predicate reads state not yet written, and what withholds it instead is `merge-shepherd.sh`
  refusing a `CONFLICTING` PR 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:

```bash
# `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.

- **`sites` empty → dispatch, exactly as today.** Most issues carry no `site:` 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.
- **`sites` containing 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_site` configured → 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.sh` and 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:

```bash
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
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
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)
