Dispatch the PRD queue
Third sibling of /issue-queue and /pr-review-queue, for PRDs. Same discipline: select, ask, claim, dispatch, report. The work happens inside dispatched units, never here.
What makes this one different is step 8. For issues and PRs the composed task is the same document whatever shape the unit takes. For a PRD it is not: /prd-full is the right instruction for a single agent and the wrong one for a team, so the shape answered in step 7 decides which of two task documents step 8 writes. That decision is the reason this skill exists, and it is the one thing here you must not carry over from /issue-queue unchanged.
When to use this
Several PRDs are open and the question is which are genuinely available to start, and can more than one run in parallel. This skill answers that and starts them.
Not this skill:
- One PRD you intend to run yourself, now →
/prd-fulldirectly (or/prd-startif you want the loop under your own hand). Dispatching a single unit puts a worktree between you and the work. - Issues rather than PRDs →
/issue-queue. Read the inversion in step 2 before you copy its filter:/issue-queueexcludes thePRDlabel by construction, and this skill selects on it. The two queues are complements, not variants — every open issue belongs to exactly one of them. - PRs rather than PRDs →
/pr-review-queue. - A PRD that does not exist yet →
/prd-create. This skill queues PRDs; it does not write them.
What this skill does NOT do
It never implements a PRD, never writes a test plan, and never diagnoses beyond what selection requires. Reading a PRD document to judge scope and pick a shape is in bounds; reading src/ to design the implementation is not. If you are editing files under src/ or tests/, you have left this skill.
It also does not decide the shape. Step 7 asks.
Prerequisite — this skill only runs inside a deck pane
dot-agent-deck dispatch reads DOT_AGENT_DECK_PANE_ID and exits FAILURE without it (src/main.rs, the Commands::Dispatch arm). That check runs before the --list-targets branch, so both the dispatch and the shape query fail outside a managed pane — with Error: DOT_AGENT_DECK_PANE_ID environment variable not set.
If you see that, stop. Selection still works and is worth reporting, but nothing can be dispatched from here; say so rather than falling back to running the PRD yourself.
Step 0 — Fetch, and verify against origin/main
git fetch origin --quiet
git rev-list --left-right --count HEAD...origin/main # "0 12" means 12 behind
Verify every claim — that a PRD's target code still looks the way the document says, that a symbol exists — with git grep against the remote ref rather than the checkout:
git grep -n "fn prepare_orchestrator_prompt" origin/main -- src/orchestrator_context.rs
A stale checkout does not fail loudly. It reports every recently-added symbol as absent, so every "still unimplemented" conclusion inverts — and a PRD is exactly the kind of long-lived document most likely to describe work that has since partly landed.
Verification never needs a pull, so this step never does one — git grep origin/main reads the ref the fetch just wrote, whatever state the checkout is in. What the units are built on is a separate question, and step 0b answers it differently: there an up-to-date base is the default rather than something to report.
Step 0b — Bring the base up to date, because every unit is cut from it
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 inherits, and no flag anywhere overrides it. Step 0's fetch fixes what you verify against and does nothing at all about what the units are built on.
That matters more for a PRD than for an issue. An issue unit is a handful of commits and a short PR. A PRD unit runs the whole lifecycle — plan, implement, gate, PR — and will rebase or merge before it finishes anyway; what it cannot do is get back the hours it spent planning tests and reading src/ against a base that was already wrong. The cost is not a conflict at the end, it is the work done before the conflict.
So bring the base up to date when it is safe to, rather than reporting it stale. Step 0 already fetched, so reading the state costs nothing. Two of the three are new; the third is the same distance read as step 0's, wanted this time for its left number as well:
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
This reverses what this step used to say, so read why before restoring it. Until issue #760 it surfaced the staleness and asked, on the ground that 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 old rule asked in every case because it distinguished none of them, and distinguishing them is three commands that cost nothing after a fetch you were already doing. 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>.
Asking was measured, and it was not enough. 2026-08-30, on this queue's own workload. Two orchestrations were dispatched for the desktop PRDs #740 and #745 from a local main at 820ba40, six commits behind origin/main at 83d9bf3. One of those six was daf94f0, the commit that introduces desktop/ in the first place — so both units were cut from a tree with no desktop/ directory at all, which is the entire subject of both PRDs. Neither could have done anything; both were stopped and re-dispatched after a pull, with not one original commit between them. A unit cannot discover this about itself. It sees a valid checkout, finds the code its PRD describes missing, and reasonably concludes that the PRD is stale rather than that its base is — which for a long-lived PRD document is an entirely plausible conclusion, and is the failure mode this step exists to prevent.
git merge --ff-only origin/main, never git pull, and the difference is not stylistic. Step 0's fetch 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 case the old rule was written for, unchanged — and 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 and carries its unmerged work into every PR the batch produces. 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.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 and turn up in every unit's PR. 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.
git log --oneline HEAD..origin/main names the commits behind the count, which is what makes a refusal actionable rather than a number.
Do not stop the queue over a refusal. Nothing in selection depends on the checkout — step 0 verifying against origin/main is exactly what makes that true — so carry the refusal forward and put it in front of the runner at the same moment you ask how many to dispatch (step 5), 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 9). "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.
Step 1 — Resolve identity at runtime
ME=$(gh api user --jq .login)
OWNER=$(gh repo view --json owner --jq .owner.login)
REPO=$(gh repo view --json name --jq .name)
OTHER=$(gh api "repos/$OWNER/$REPO/collaborators" --jq '.[].login' | grep -vx "$ME" | head -1)
Never hardcode a login. This repo has two maintainers and a hardcoded one silently hands the other person somebody else's queue. $OTHER is needed as well as $ME here, because every composed task carries "request review from the other maintainer" as its stop condition and the unit cannot resolve that for itself — it runs as the same account you do.
Then pass --repo "$OWNER/$REPO" on every gh call below. Without it gh re-resolves the repo from the cwd on each invocation, so a run from inside a dispatch worktree can query a different remote than the one just resolved.
Step 2 — Select candidates: select on the PRD label
The rule: open issues carrying the PRD label that are unassigned or assigned to the runner.
LIMIT=300
PRDS=$(mktemp)
gh issue list --repo "$OWNER/$REPO" --state open --limit "$LIMIT" \
--json number,title,body,labels,assignees,createdAt > "$PRDS"
jq 'length' "$PRDS" # equal to $LIMIT means TRUNCATED — raise and re-run
jq -r --arg me "$ME" '.[]
| select((.labels|map(.name)|index("PRD")))
| select((.assignees|length)==0 or ([.assignees[].login]|index($me)))
| "\(.number)\t[\([.labels[].name]|join(","))]\t\(.title)"' "$PRDS"
That select is the inverted twin of /issue-queue's, and the inversion is the whole point. /issue-queue step 2 emits select((.labels|map(.name)|index("PRD"))==null) — it drops PRDs by construction so they land here instead. Copying that line across is the single most likely mistake in this skill, and it fails silently: the queue comes back full of ordinary issues and looks perfectly plausible. Check the ==null is gone, not merely that a filter exists.
Three notes on the rest:
--limitis a bound you must act on, not a disclaimer.gh issue listdefaults to 30 and silently truncates at whatever limit is in force. Thejq 'length'line is the check: a count equal to$LIMITmeans raise it and re-run. A truncated queue looks exactly like a complete one.- The label is the only reliable signal, not the title. Titles are inconsistent here — of the 39 open PRD-labelled issues on 2026-08-25, some read
PRD: …(#635, #627) and some do not (#468, #421, #242, #190). A title-prefix filter would drop the second group. - Assignment on this repo is sparse. On 2026-08-25, 0 of those 39 had any assignee, so the assignee filter admitted everything. That does not make it useless — it is what keeps two maintainers from colliding once assignment is in use, which is what step 6 puts into use.
Keep the full JSON rather than printing from it and discarding: steps 3, 5 and 8 all need bodies, and re-fetching them one at a time is both slower and a second chance to get the filter wrong.
Step 3 — Require a PRD document on origin/main
A PRD label is not a PRD. The whole lifecycle a dispatched unit runs reads prds/<n>-*.md: /prd-start validates readiness from it, /prd-next picks tasks out of it, the orchestrator role template's step 1 says to read it and nothing else, and /worktree-prd's create.sh aborts outright with No PRD file found matching prds/<number>-*.md. Dispatch a label with no document and the unit either stalls at its first step or improvises a PRD of its own — the expensive failure, because it looks like progress.
for n in $(jq -r '.[] | select((.labels|map(.name)|index("PRD"))) | .number' "$PRDS"); do
git ls-tree --name-only origin/main prds/ | grep -qE "^prds/${n}-" || echo "NO DOC: #$n"
done
Measured on 2026-08-25: 34 of the 39 open PRD-labelled issues have a document on origin/main; five do not — #610, #417, #239, #193 and #183. That is 13% of the queue, so this is a routine state rather than an exotic one.
origin/main, not ls prds/, for step 0's reason: a document merged since your last pull is present on the remote and absent locally, and the local check would wrongly disqualify it.
A missing document is not a defect to fix here and not grounds for silently dropping the row. Show it in step 5 as "no PRD document — needs /prd-create first", and let the runner decide.
Step 3b — Spot-check the premise, and mark the row rather than dropping it
Step 3 asks whether the candidate has a document. Step 4 will ask whether someone is already working it. Neither asks whether the document is still true. A PRD that was implemented and never closed presents itself as available work indefinitely, and before this step no step asked the question — step 5's scope read might happen to surface it, but nothing required anyone to look.
Steps 3b and 4 are independent, and may be run in either order. Where the candidate list is long, run step 4 first and premise-check only what survives it — there is no reason to read a document for a PRD already in flight. This sits at 3b because a document's existence and its truth are the same question asked twice, not because it has to run before the in-flight check.
Step 0 already contains the correct warning, and that is exactly the problem: "a PRD is exactly the kind of long-lived document most likely to describe work that has since partly landed." That is advice about which ref to verify against, not a step that gates a candidate. This step is where it becomes actionable.
A PRD is the worst case for a stale premise, for three compounding reasons. It is long-lived by construction, so more main has moved under it than under any issue. It lands in pieces — the in-flight check needs its own substep 4b because a PRD spans several PRs — so partly landed is its normal state rather than an exotic one. And a PRD unit is expensive: the whole lifecycle, and possibly a six-role team. The near-miss was a PRD. #236 was selected for dispatch and presented as "a live data-loss bug", quoting its own present-tense problem statement, when RemovalPolicy::KeepIfDirty and the worktree list|reclaim verbs had both shipped weeks earlier. It carries the PRD label and a document at prds/236-worktree-removal-safety-reclamation.md, so it would have reached this queue as an ordinary row. It was caught only because the runner happened to ask what a phrase in it meant; without that question a six-role team would have been dispatched onto finished work.
This step produces a note on a row. It never removes one, and it never closes anything. A heuristic that hides real work fails invisibly, which is worse than the state it replaces — the runner cannot correct a row they were never shown. Adjudicating a stale PRD is also not selection's job: a looks stale row goes to the runner as a question, and /prd-close is the runner's tool, not this skill's.
Check the document, not only the issue body
The scope read in step 5 already comes from prds/<n>-*.md rather than the issue body, and so does this check — that is what the unit will actually work from, and the two can disagree. Read the document's problem statement and its milestone list, on origin/main:
git show origin/main:prds/<n>-<slug>.md | sed -n '1,80p'
Partial completion is the outcome to look hardest for, because it is the one that reads as fully live. A PRD whose first three milestones shipped still describes all of them in the present tense. Where the document carries milestone checkboxes, they are a claim to check rather than an answer — they are updated by hand, by /prd-update-progress, and a document that stopped being updated is precisely the one most likely to be stale.
Then state the central claim in one line, find its anchor — a symbol in backticks, a src/*.rs path, a version string, a CLI verb, a config key — and check it against origin/main, never the checkout, for step 0's reason:
git grep -n 'prepare_orchestrator_prompt' origin/main -- src/
git ls-tree --name-only origin/main -- src/foo.rs
Classify the row into exactly one of three outcomes, and report all three in step 5:
premise holds— the document's central claim is still true oforigin/main.premise looks stale — verify— the evidence points at work that already landed, in whole or in part. Say which milestones look landed and what the evidence was, so the runner can judge it in one line. A partly-landed PRD is usually still dispatchable — the note changes the unit's starting point rather than disqualifying the row.premise not mechanically checkable— no anchor, or an anchor a grep cannot settle. This is an ordinary outcome, not a failure, and such a row is exactly as dispatchable as one markedpremise holds.
What these greps do NOT decide, measured on this backlog
Symbol absence is not evidence of staleness, and for a PRD it is actively misleading. Extracting every backticked [a-z][a-z0-9_]{5,} identifier from the 215 open issues on 2026-09-04 and testing it against origin/main flagged 65 — 30% of the backlog — and in a twelve-row sample none indicated a stale premise. The single largest category was a symbol the issue proposes creating, which is absent precisely because the work is undone: check_gemini_available (#211, the Gemini adapter PRD) and check_aider_available (#212, the Aider adapter PRD) are both PRD rows where absence means the PRD is live. A PRD describes what does not exist yet, so for this skill the naive sweep does not merely add noise — on a PRD row it can point the wrong way entirely, reporting a live PRD as stale for the very reason it is live. A separate heuristic premise-check over this backlog on the same day produced 24 flags with 23 false positives. Its one true positive was #483, where the cited ensure_claim_label survives only in the prose of prds/421-issue-triage-labels-and-dispatch-claims.md and not in src/ — a reminder that where you search decides the answer as much as what you search for.
The remaining sampled flags were a git SHA (dac0ad0, e22cd1e), fields of a dependency crate's termios (c_ispeed, c_line, from libc), GitHub Actions YAML keys (workflow_call), a label name (wontfix), an external binary (ffmpeg), and a test id absent as a whole token but present as the prefix of two real test functions (spawn_006) — seven kinds in all, of things that merely look like a symbol.
File-path absence is quiet but not clean. The same sweep found only two of 215 issues citing a src|tests|xtask/*.rs path absent from origin/main, and both were wrong: #248's src/unix/mod.rs is inside the libc crate and #293's src/win/psuedocon.rs is inside portable-pty. Check whose tree a cited path names before reporting it missing.
A later merged PR naming the issue is far too common to flag on. 119 of the 215 open issues — 55% — are named by a PR merged after they were filed, which for a PRD is the ordinary state rather than a signal. Narrowing to a resolving verb (fixes, closes, implements, supersedes) within 90 characters of the reference still flags 47 — 22%, and the sample is dominated by references that say the opposite on reading: "recorded as #864, not shipped", "filed not fixed". It is useful for locating which milestones landed once a row is already suspect — PR #779's "Closes the first two iterations of PRD #745 (M1–M12)" is exactly the partial-completion evidence this step wants — and it is not a trigger on its own.
Two further traps, both of which have produced a confident wrong answer here:
- A grep that matches the fix looks like a grep that matches the bug. #358's audit had to separate "credentials absent from
/tmp" — which the issue itself had already observed and correctly distrusted as luck — from "the containing directory is now owner-only", which was the actual fix. Absence of a symptom is not evidence of a fix. - Word-boundary and pattern mistakes invert the answer in both directions, and they catch careful people. A
grep -w sigterm_001reported a test as deleted when it exists as the prefix of a longer name, and agrep -icE 'trust'counted 49 hits that were all the framing mechanism rather than the claim. The sweep quoted above walked into the same trap while being written:c_lineis absent from this tree as a token, but a substring search finds it inside the unrelatedcmd_c_lineinsrc/platform/paths.rs, so the two searches disagree about whether it is present. Anchor patterns at the boundary you actually mean, and read a sample of the hits before believing the count.
So the reliable signal here is a reading, and the commands above only make it fast. When the evidence is ambiguous — and it usually is — mark the row premise not mechanically checkable. That bucket exists for evidence that cannot settle the claim, and a row carrying it is exactly as dispatchable as one marked premise holds, so nothing is lost by using it.
Do not reach for premise holds to express doubt. It asserts the claim was checked and still stands, step 5 prints it as a confirmation, and no later step re-checks it — so a row that quietly upgrades "could not tell" to "verified" is the same unverified-claim defect this step exists to catch, reintroduced by the step itself. A row wrongly marked unclear costs a glance; a row wrongly marked verified costs the work, and a row wrongly dropped costs it silently.
Step 4 — Eliminate what is already in flight
Three independent checks, because no one of them is sufficient. This is /issue-queue step 3 carried over intact — the incident that produced it (a duplicate dispatch onto a bug already being fixed, yielding two PRs with identical closing refs) is shape-independent — with one PRD-specific correction in 4c.
4a. PRs that declare a closing reference.
gh api graphql -f query='
query($owner:String!, $repo:String!) {
repository(owner:$owner, name:$repo) {
pullRequests(states:OPEN, first:100) {
pageInfo { hasNextPage }
nodes {
number title headRefName
closingIssuesReferences(first:25) { pageInfo { hasNextPage } nodes { number } }
}
}
}
}' -F owner="$OWNER" -F repo="$REPO" \
--jq '.data.repository.pullRequests |
(if .pageInfo.hasNextPage then "WARNING: more than 100 open PRs — paginate\n" else "" end),
(.nodes[] | select(.closingIssuesReferences.nodes|length>0)
| "PR #\(.number) [\(.headRefName)] closes: \([.closingIssuesReferences.nodes[].number]|join(", "))")'
first:100 is GraphQL's per-page maximum and hasNextPage is printed rather than assumed. If either warning fires, paginate with after: before trusting this list — a truncated in-flight scan is worse than none, because it reports a clean result.
4b. PRs that advance a PRD without declaring it. closingIssuesReferences sees only explicit Fixes #N / Closes #N keywords, and a PRD is the case where that is most often absent by design: a PRD spans several PRs and only the last one closes the issue, so every earlier PR advancing it is invisible to 4a while making the PRD very much in flight.
PRS=$(mktemp)
gh pr list --repo "$OWNER/$REPO" --state open --limit 200 \
--json number,title,body,headRefName > "$PRS"
jq 'length' "$PRS" # equal to the --limit means TRUNCATED — raise and re-run
jq -r '.[] | "#\(.number) [\(.headRefName)] \(.title)"' "$PRS"
Read the bodies of any whose title or branch names a candidate PRD — they are already in $PRS, so this costs nothing but attention:
jq -r '.[] | select(.number==<pr>) | .body' "$PRS"
There is no mechanical substitute for that reading, and the cost of skipping it is two teams on one PRD.
4c. Dispatch branches and worktrees, including ones with no PR yet. A unit that has started but not pushed is invisible to both queries above.
Step 6 names units prd-<n>, so a unit following this skill's convention is detectable mechanically. But do not check only that name. PRD-labelled issues have been dispatched under /issue-queue's convention as well, because a PRD is an issue and the older skill's naming was the only one that existed:
git branch -a --format='%(refname:short)' | sed 's#^origin/##' | sort -u \
| grep -Ex "agent/dispatch-(prd|issue)-<n>(-.*)?" && echo "IN FLIGHT: #<n>"
That alternation is measured, not defensive. On 2026-08-25 agent/dispatch-issue-421 exists locally for #421, which carries the PRD label; a prd--only grep returns nothing for it. Match exactly-or-dash so #49 does not match agent/dispatch-issue-490.
The convention only covers names that follow one, so also list every dispatch branch and worktree and read them yourself:
git branch -a --format='%(refname:short)' | sed 's#^origin/##' | grep dispatch | sort -u
ls -d ../*-dispatch-* 2>/dev/null
An off-convention name cannot be mapped back to a PRD mechanically — agent/dispatch-fix-skip-detection is on this repo right now and contains no number at all. Treat an unrecognised *dispatch* branch as a question for the runner, not as noise.
A branch that outlived its worktree is finished or abandoned work, not in flight — but its name is still taken, and for PRDs that state is ordinary rather than rare. #421 is the worked example: its dispatch produced PR #464, which merged the PRD document and left the PRD itself unimplemented. The issue is open and genuinely available, and agent/dispatch-issue-421 is permanently spent. Step 6 is where that is handled.
Step 5 — Show the queue, then ask how many
Print each candidate with number, title, the PRD document path, a one-line scope read, step 3b's premise mark, and any in-flight or missing-document note. Print the premise mark on every row, including premise holds — a mark that appears only when something is wrong is indistinguishable from a step that was skipped. The scope read comes from the document, not the issue body — that is what the unit will actually work from:
sed -n '1,60p' prds/<n>-<slug>.md
Show what was excluded and why. In-flight exclusions especially: that is where the runner is most likely to know something the queries cannot see.
If nothing survives, stop there. After the document check and in-flight elimination the list can legitimately be empty. Report the counts at each stage and what they removed, and do not go on to ask how many to dispatch — there is nothing to dispatch, and asking implies otherwise.
Otherwise ask how many to dispatch, recommending 1–2. That is deliberately lower than /issue-queue's 2–3, for two reasons that compound:
- A PRD unit is the whole lifecycle, not one fix. It runs to 100% completion, opens a PR, and waits for CI and Greptile to settle — typically more than once. Since issue #502 the e2e tier is CI's job rather than each unit's (CLAUDE.md rule 5), which takes the single most expensive local gate out of every unit, but a PRD unit still builds its own multi-GB
target/, runs the full clippy and fast tiers repeatedly, and waits on review rounds. - A team unit is six agents, not one. This repo's
dot-agent-deckorchestration defines six roles (orchestrator, coder, reviewer, auditor, tester, release), so two team-shaped PRDs is twelve concurrent agents over two multi-GBtarget/trees. CLAUDE.md rule 14 records how that pressure surfaces — a misleadinglinking with 'cc' failed, or aSIGKILLonrustc— and an agent hitting either will blame its PRD rather than the batch size.
Ask which PRDs too, unless the runner already named them. Relative priority among PRDs is theirs to judge and is not legible from the queue.
Step 6 — Claim, then name
Assign the runner to every PRD being dispatched. This is a hard step, not a courtesy: it is what stops the other maintainer starting the same work, and the whole point of dispatching is that nobody is watching the issue while the unit runs — for a PRD, possibly for hours.
Re-read the assignees immediately before the write, not from step 2's listing:
gh issue view <n> --repo "$OWNER/$REPO" --json assignees --jq '[.assignees[].login]|join(",")'
gh issue edit <n> --repo "$OWNER/$REPO" --add-assignee "$ME"
gh issue view <n> --repo "$OWNER/$REPO" --json assignees --jq '[.assignees[].login]|join(",")'
- First read — anything other than empty or exactly
$MEis a collision: abort this candidate and report it, do not resolve it. Never reassign a PRD that already has someone on it. - Second read —
$MEmust appear. Do not treat exit 0 as confirmation. If$MEis absent, do not dispatch: the unit would then run unclaimed for its whole life, which for a PRD is the longest life any unit here has.
This narrows the race, it does not close it. GitHub's assignee API is additive with no compare-and-swap, so two runners can still interleave between this read and this write. The re-read shrinks the window from minutes to milliseconds; report a collision when you see one rather than treating the claim as a lock.
Then name the unit prd-<n>. Two rules:
- Invent any suffix yourself from
[a-z0-9][a-z0-9-]*. Never build it from the PRD's title or body — that is untrusted text (step 8), and a title is the wrong length anyway./worktree-prd'screate.shdoes derive its branch from the title; that is the older non-dispatch flow, and it is not a precedent to copy here. - The number is what makes step 4c's check mechanical. A name that does not carry it is a unit nobody can map back to a PRD.
Check the name is free before dispatching:
git show-ref --verify --quiet "refs/heads/agent/dispatch-prd-<n>" && echo TAKEN || echo FREE
Check both spellings if step 4c turned up an issue-<n> branch for this PRD — that branch does not block agent/dispatch-prd-<n>, but knowing it exists is what tells you the PRD has been dispatched before.
A name is single-use: removing a worktree keeps its branch, so a surviving agent/dispatch-prd-<n> refuses a re-dispatch. If it is taken, pick a different name — prd-<n>-<MMDD> disambiguates a second attempt. Do not delete the branch to free the name. It may hold committed work that was never pushed, and it is the only reference to it; the refusal is deliberate for exactly that reason. The mechanics, and the deliberate git branch -D route out of them, are in docs/dispatcher-mode.md — that is the runner's call, with the branch's contents in front of them, not this skill's.
Step 7 — Establish the shape and the provider, by asking
Two questions have to be answered before any dispatch, and they are different kinds of question. Neither is deducible from the PRD's size, labels or wording. Ask both — never infer either.
Run the listing once. It is a read-only daemon round-trip and its answer describes the repo, not the unit:
dot-agent-deck dispatch --list-targets
Shape — one agent or a team — asked once per PRD
Show the runner the output and ask, for each PRD separately, which shape it should take. Then pass the matching flag explicitly on every dispatch (--single, or --orchestration '<name>' with the name spelled out — the value is required, and --list-targets is where you get it). With neither flag the shape falls back to whatever the repo's config implies, which is the guess this step exists to avoid.
Per PRD, not per batch, and here that is a stronger rule than it is in /issue-queue (#674). There, a batch-level answer produces one wrong flag; here it also picks the wrong task document, because step 8 branches on this answer. Two PRDs in one batch routinely differ in kind — a documentation-shaped PRD and a six-role implementation are not the same work — and the batch-level question has already produced an answer the runner did not want. A homogeneous batch may reuse one answer, but say so out loud rather than assuming it.
Provider — which orchestration — asked once per session
Since issue #705 this repo defines three orchestrations rather than one: mixed, anthropic and GPT. They run the identical six roles with the identical prompts, workflow and delegation contract; only which agent each role launches differs. So the listing now offers three, and the old single question — "one agent or a team?" — has quietly become a four-way one.
Do not ask it that way. Fold the provider into the per-PRD shape question and the runner re-answers a settled decision on every PRD in the batch:
- Shape is a property of the work. Is it divisible, does it need independent review? Two PRDs in one batch genuinely differ, which is why it is asked per PRD above.
- Provider is a property of the session. Which credits are healthy today, which stack the runner wants exercised. It does not vary with the PRD at all, and asking a runner the same provider question five times in one batch is the symptom to avoid.
So ask the provider once, the first time a PRD in this batch turns out to want an orchestration, and reuse that answer for the rest of the session. Re-ask only if the runner raises it, or if a dispatch fails on that provider's credentials — which is the case the three orchestrations exist for: switching the whole team to another provider mid-batch is a different --orchestration value and nothing else.
Pass the name explicitly, always. Never a bare --orchestration=. The bare form opens whichever orchestration the repo declares as its default, which is currently mixed — a fact about the config file, not a choice the runner made in this conversation. --list-targets marks that one with [default]; the marker is there to inform the question, not to answer it. If the runner expresses no preference, say which one you are taking and why (mixed is the declared default and exercises the most providers) rather than silently omitting the flag.
If the listing fails
If --list-targets errors, you have none of the answers. The message says which case it is: DOT_AGENT_DECK_PANE_ID environment variable not set means nothing can be dispatched from here at all (see the prerequisite), and the daemon did not answer list-targets means no daemon or an older build. Take that to the runner rather than acting on it — a failed query is not a reason to start guessing.
Step 8 — Compose the task in a FILE, and compose it for the shape
The file rules, first
The task goes in a file. --task-file is the default here, not an escape hatch:
dot-agent-deck dispatch prd-<n> --single --task-file '.dot-agent-deck/prd-<n>.md'
dot-agent-deck dispatch prd-<n> --orchestration 'mixed' --task-file '.dot-agent-deck/prd-<n>.md'
mixed is written out here as an example, not as a default to copy. Substitute whatever the runner chose in step 7 — anthropic or GPT are the other two. The flag's value is required either way; a bare --orchestration= would take the repo's declared default, which is an answer nobody in this conversation gave.
This is a safety rule, not an ergonomic one, and the product says so itself. The delegation protocol compiled into the binary and handed to every orchestrator it spawns (src/orchestrator_context.rs) states that --task "…" is a fallback safe only when the whole task is a single line of plain text with no backticks, no $, no ", no \ and no !. Both templates below are multi-line blocks quoting code and CLI flags, so they fail that allowlist on shape alone.
It fires on this skill's own material with no attacker involved: the most load-bearing sentence in a task is the one quoting a symbol, so it is the one most likely to contain backticks, and inline the caller's shell command-substitutes them away before dot-agent-deck sees argv. The dispatch reports success, because the mangling happened upstream of it.
Four rules for producing the file. 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 —prd-<n>, matching the unit name, is the natural one. Never build it from the PRD's title or body, which 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 exactly that path once the d
…(truncated)