About implement-spec-in-workflow
A workflow skill that turns a spec issue and its tickets into a dependency graph implemented by subagents, with one PR per ticket. It is for teams delivering larger changes through ticket-sized reviews, and supports parallel or sequential runs after the operator confirms runner and stack settings.
Key features
- 01Schedule ready tickets by dependency
- 02Create a separate PR for each ticket
- 03Run tickets in parallel or sequentially
- 04Support native stacks or chained PRs
- 05Orchestrate subagents with a workflow script
- 06Extract validation commands from ticket sections
Use cases
- 01Turn a spec issue into reviewable ticket PRs
- 02Implement independent tasks in a dependency graph concurrently
- 03Continue spec work on a named branch without rebuilding completed tickets
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ javrasya/ skills --skill implement-spec-in-workflowAfter installing, type/implement-spec-in-workflowin Claude Code to confirm it loaded.
Bundled filesworkflow.template.js
Details
From the project READMEA spec issue with tickets under it becomes a stack of PRs — one PR per ticket — implemented end to end by a dynamic workflow of subagents. The tickets are not a list of steps: they are a task graph with blocking relationships, so there is always a frontier of tickets ready to be grabbed, and the run schedules against that frontier rather than marching through phases. The stack exists so the operator reviews one small ticket-sized diff at a time, then merges the whole stack in one operation.
Your context is the scarce resource here. The spec body, the ticket bodies, the research, the diffs and the review findings all stay inside the workflow; you hold the issue number, fourteen interpolated values, and the summary that comes back. Read no ticket body beyond the ## Validation sections step 2 names, open no source file, and run no search — a discovery agent inside the workflow does that, and it does it better with a fresh window than you do with a full one.
workflow.template.js, beside this file, is the script. It carries every step; you render it and launch it.
Requires a runner for a script of agent() calls — either the Workflow runner, a harness's workflow primitive (Claude Code's Workflow tool, or an equivalent), or the session runner (formerly the Orca runner; ADR-0011, ADR-0017), which needs Node and crew installed: the runner is the crew package's (npm install -g @javrasya/crew, or from a checkout of javrasya/crew npm install -g <crew checkout>), and it runs every agent as a live session on a session host — crew itself, the default, or Orca, which additionally needs a session inside an Orca terminal whose runtime is ready (the crew runner reference). The script is plain JavaScript against four hooks: agent(), parallel(), phase(), log(), and is the same under both but for one rendered value, RUNNER (step 2). Native stack registration additionally requires the gh-stack extension — gated below, never auto-installed.
Steps
-
The stack gate. Two probes, two different outcomes — run them before touching anything else:
gh extension list | grep -q gh-stack— missing → stop. Tell the user to rungh extension install github/gh-stackand wait. Install it yourself only if the user explicitly says to.gh api "repos/<owner>/<repo>/stacks" --silent— 404 → stacks are not enabled for this repo (org rollout policy, or a host without the preview; installing nothing fixes it). Put it to the user: GitHub's native stacked PRs give a stack map on every PR and a one-click atomic whole-stack merge — enabling is described at https://docs.github.com/en/pull-requests/tutorials/roll-out-stacked-prs — or the run can degrade to a plain--basechain merged bottom-up by hand. Their call, never a silent fallback. HTTP 200 (even with[]) → native mode.
The runner. Asked alongside the stack gate, from two probes:
- Crew probe.
crew --helpexits 0: crew is on PATH. - Orca probe. Both
TERM_PROGRAMisOrcaandORCA_TERMINAL_HANDLEis set, andorca status --jsonexits 0 withok: true,result.runtime.state: "ready"andresult.runtime.reachable: true, and the crew probe passed. Anything else — a variable missing, a non-zero exit, stdout that is not JSON, any other state — fails it. - Crew probe failed (so the Orca probe did too): the run goes on the Workflow runner, exactly as it would without this step, and nothing is asked. When crew alone is missing — the rest of the Orca probe passed — say once that the session runner needs crew installed, and how.
- Crew probe passed → ask, and wait. The choices, in this order: the session runner on crew — the default, so offer it first and mark it so — where every agent is a live crew session the operator enters, watches, types into and answers from the run console, and the run lives in the crew daemon, outliving this session; the session runner on Orca, offered only when the Orca probe passed, where every agent is a live Orca tab and the run lives in its own Orca terminal; or the Workflow runner, the host's workflow primitive, as before. The operator picks. Being the default only puts crew first: never choose for them, including by defaulting when they do not answer.
- Session runner chosen, on either host → how workers run. A Claude worker starts in the permission mode the runner is given (
--permission-mode); without one it starts in Claude's own default permission mode. Workers inherit your own session's permission mode, as Workflow subagents do: when it does not prompt the operator —auto,acceptEdits,bypassPermissions,dontAsk— pass that mode on and ask nothing (auto stays auto). Only when your mode isdefault(manual), or when you cannot tell which mode you are in, would a worker stop at permission prompts the operator has to answer tab by tab, so then ask the operator how workers run:auto,acceptEdits,bypassPermissions, your own mode, or no flag (Claude's own default mode decides). Wait for the answer; it becomes the launch's--permission-mode, or none. On crew the launch always takes one (crew starthas a row for it), so there offer no "no flag":defaultis the manual mode. - Prior work. Ask it, and wait: does a branch already carry work for this spec, done before this run? The operator names it, or says none (ADR-0023). Offer the branches of the checkout to pick from (
git for-each-ref --format='%(refname:short)' refs/heads refs/remotes/origin), and say beside aspec/<n>-integrationbranch that it is an earlier run's whole-stack review fixes, never a ticket's work. Never look for such a branch yourself and never let the workflow's agents look: an observed run's discovery agent picked up an aborted run's integration branch, the run re-implemented a ticket whose work was already on it, and halted at publish. A named branch becomes the stack's layer 0; the graph agent then says, per ticket, whether that branch already finishes it, and those tickets are closed by the layer-0 PR instead of being rebuilt. - The run order. Ask it once the runner is settled, and wait: parallel, the default, builds every takeable ticket at once, as always; sequential builds one ticket at a time, each dispatched, implemented, gated and published before the next is cut from the tip, so no publish rebases onto another ticket (ADR-0020). Offer it on the session runner only, on either host. On the Workflow runner the run is parallel and nothing is asked; if the operator asks for sequential there, refuse before rendering anything and say why: a sequential run points its agents one after another at one folder, and the Workflow runner cannot point two agents at one folder. They choose parallel, or the session runner.
-
Resolve fourteen values. Use
gh issue view <n> --json title -q .title,git remote -v,git fetch originandgit rev-parsefor the first ten — titles and refs, never bodies. The two recipe sets are the one exception: read each ticket's## Validationsection and nothing else of it.value how __SPEC__the spec issue number, from the arguments __REPO__owner/nameof the repo the spec belongs to__REPO_DIR__absolute path of that checkout __BASE_REF__the branch the stack merges into. The user naming one settles it. Otherwise check git branch --show-currentagainst the repo's default branch: when they match, use the default branch silently; when the checkout sits on some other branch, ask the user which branch the stack merges into — the default branch, or the one they are on — and wait. A checkout parked on a long-lived branch often means the work targets that branch, and a wrong base miscuts every PR in the stack, so this is never guessed. Prior work already sitting on a feature branch is not this value: that is__START_REF__.__START_REF__the prior work from step 1: the branch the operator named, or, for none, the base ref itself, which the workflow reads as "no layer 0". Never a guess and never an agent's find (ADR-0023). __BASE_SHA__the run's pinned base (ADR-0030): git fetch origin, then the full sha of__START_REF__—git rev-parse --verify 'refs/remotes/origin/<start ref>^{commit}'; for named prior work that origin does not have yet, its local branch,refs/heads/<start ref>. Every ticket the run cuts is cut from this sha, not from the ref, however the ref moves under the run. A ref that resolves to no commit, or a fetch that fails, refuses the run: tell the operator which ref, render nothing and launch nothing__STACK_MODE__nativewhen both probes in step 1 passed;chainwhen the user chose the fallback__RUN_ORDER__parallelorsequential, the run order from step 1; alwaysparallelon the Workflow runner, where a script renderedsequentialrefuses to run__RUNNER__sessionwhen the operator chose the session runner in step 1, on either host,workflowotherwise. A script rendered withorca, the value before, still runs and resumes the same. It picks the worktree wording and whether the script reclaims: under the session runner an isolated agent's worktree is one the session host made for it, and the script's reclaim steps remove nothing — the operator reclaims from the run view or the run console. On the Workflow runner the lane and finalize reclaim withgit worktree remove. Only this value differs between the two renderings__NOTES_DIR__~/.claude/spec-notes/<repo-name>-<spec>, absolute — durable, outside every checkout, readable by every agent__PER_CHANGE_COMMANDS__the commands under ### Run per changein the## Validationsection of each openready-for-agentsub-issue of the spec (gh api repos/<repo>/issues/<n>/sub_issues --paginate), closed andready-for-humanones skipped. A command is a line's first backticked span; a line with none, or one that is prose (absent: …,not applicable: …,Recipe measured …,Needs: …,Deferred repo gate: …, also after aLabel:), is none. Each command once, two that differ only in whitespace being one, kept as first written; rendered as a JSON array of strings (JSON.stringify), written bare —[]when no ticket has one. Crew extracts it the same way (recipeCommandSetsin crew'sarm.mjs)__AT_REVIEW_COMMANDS__the same, under ### Run at review__TICKET_RECIPES__each of those tickets' own commands, by the same rule, as a JSON object from its issue number to {"perChange": [...], "atReview": [...]}, each list once per command as above, written bare —{}when there are no such tickets. The script runs a ticket's own lists, so every command a role runs is one the baseline measured; the dispatcher's copy stands in only for a ticket missing here. Crew renders it the same way (ticketRecipesinarm.mjs)__PR_GUIDE__the absolute path of the vendored prskill,skills/engineering/pr/SKILL.mdin the repo this skill comes from: resolve this SKILL.md's real path first (realpath, since an installed skill is a symlink), then../pr/SKILL.mdfrom its folder. The publisher reads it to write each ticket PR's body. A path that does not exist refuses the run: render nothing and launch nothing. Crew resolves it the same way (prGuidePathinarm.mjs) -
Render the script: copy
workflow.template.jsto<notes-dir>/workflow.jsand substitute the fourteen placeholders. Substitute, do not rewrite — the publish lane and the frontier scheduling are load-bearing. On crew, skip this step:crew startrenders the template itself, from the values it is given in step 4 and those it resolves the same way (the repo, its path, the notes dir, the pinned base and the two recipe sets). -
Launch it against the rendered path, on the runner step 1 settled. Do not do any of its work yourself while it runs.
-
Workflow runner. It runs in the background and notifies you when it returns; do not poll it.
-
Session runner on crew. Arm and launch it with one command, every row of
crew start's form given as a flag — the permission mode from step 1,__BASE_REF__,__START_REF__,__STACK_MODE__and__RUN_ORDER__from step 2,claudeas the harness and the model you run on as its model — and stdin from/dev/null:crew start <spec> --harness claude --model <your model id> --base "<base ref>" [--start-ref "<prior work branch>"] --stack-mode <native|chain> --run-order <parallel|sequential> --permission-mode <mode> </dev/null--start-refonly when the operator named prior work; left out, the stack starts on the base. With no terminal crew never shows its form: it arms from the flags alone, and a row with no flag is refused, naming it, rather than asked. Everycrew startarms a new run, never one an earlier start made, live or not: a folder of its own,<notes-dir>/runs/<spec>-<UTC time>-<random>, holding itsworkflow.js, its research notes, its baseline'spre-existing-failures.json, the baseline measurement's halt recordsbaseline-blockers.jsonandbaseline-at-review-blockers.jsononce a measurement has halted on a blocker (a resume reads them to check those blockers first), and its state dirorca-run/. No validation file exists and nothing is drafted: each ticket carries its own validation recipe, andcrew startrefuses, before its form and arming nothing, a spec any of whose tickets lacks a## Validationsection holding a### Run per changesubsection, naming those tickets — runpreflighton the spec to write them, then start again. It pins the run's base as__BASE_SHA__above says, refusing, arming nothing, a cut-from ref that resolves to no commit. It renders the script there and launches the runner as a crew session titledimplement-spec #<spec>: <spec title>, printing the script's path, the ref and sha every ticket is cut from, its session id and the run console command that enters it,crew view "<run folder>/orca-run". A non-zero exit armed nothing: tell the operator its message and stop. Then wait, and report, exactly as on Orca below, with<notes-dir>/orca-runread as the state dir crew start printed; where that names the runner's tab or a node's tab, on crew it is a crew session (halted.json'sterminalis the runner's session id, each node'stabits worker's), entered from the run console,crew view "<run folder>/orca-run": Enter on the runner's row or a node's row enters its session. (crew consoleis a debug list of the daemon's raw sessions: never point the operator at it.) -
Session runner on Orca. First clear the previous run's end signals, since the notes dir outlives a run — every
--resumeand every re-arm of the same spec launches over the last run's files, and the new runner replaces them only once node has loaded, well after your wait has begun:rm -f "<notes-dir>/orca-run/summary.json" "<notes-dir>/orca-run/runner.pid" "<notes-dir>/orca-run/halted.json"Then start the runner as its own Orca terminal, titled after the spec, so its log is watchable and the run survives this session:
orca terminal create --title "implement-spec #<spec>: <spec title>" --command "crew run --host orca \"<notes-dir>/workflow.js\" [--permission-mode <mode>]" --jsonAppend no
; exit: the runner stays in its tab with the run view after the script ends, until the operator quits the view, and the tab then holds what it printed. Keepresult.terminal.handle; ifresult.terminal.surfaceis notvisible, tell the operator the run has no visible tab.Then wait for
<notes-dir>/orca-run/summary.jsonto appear, or for the runner to die — never for the terminal to exit, which it no longer does, and never on whether its tab is open, since the tab outlives the runner. The runner writes its process id torunner.pidin the state dir as it starts, andsummary.jsonas the script ends, before it waits on the operator's view, sosummary.jsonappearing is the run's end and arunner.pidnaming a gone process is the runner's death. A halted run (a ticket failed, or needs decisions only the operator can make; ADR-0016) has not ended and writes nosummary.json: the runner writeshalted.jsoninstead,{ at, runId, terminal, nodes: [{ node, title, reason, questions?, tab }] }, with a newateach time the held nodes change, and removes it once the run leaves halted. Wait with one command, bounded to an hour, as a background command where your harness has one,<seen>being theatof the last halt you reported, empty the first time:timeout 3600 sh -c 'd="<notes-dir>/orca-run"; seen="<seen>"; until [ -f "$d/summary.json" ]; do a=; [ -f "$d/halted.json" ] && a=$(node -p "require(process.argv[1]).at" "$d/halted.json" 2>/dev/null); [ -n "$a" ] && [ "$a" != "$seen" ] && break; [ -f "$d/runner.pid" ] && ! node -e "process.kill(+process.argv[1],0)" "$(cat "$d/runner.pid")" 2>/dev/null && break; sleep 15; done'The
nodeprobe is the liveness check because it sees Windows process ids, which Git Bash'skill -0may not. When the wait returns, look in this order.summary.jsonexists: the run ended (the runner may have exited between the checks, so this comes first). Elsehalted.jsonexists with anatother than<seen>: the run halted, or its held nodes changed. Tell the operator which nodes need them and why — each node'stitleandreason, itsquestionswhen it has them, and itstab— and thatrin the runner's tab (terminal) resumes every held node, or, with a node's row selected, that node alone; then issue the same wait again with<seen>set to thatat. On crew,ris pressed in the runner's session, entered from the run console. A halt never ends the wait: onlysummary.jsonor the runner's death does. Elserunner.pidexists and the same probe fails: on Orca, the runner died; on crew, recover first (below), and report it dead only if no runner comes back. Else the hour passed: ifrunner.pidis still missing, the runner never started — say so and point the operator at its tab; if the probe still succeeds, issue the same wait again. Do not read the terminal's log while it runs. An Orca outage (Orca restarting, as when it updates) fails no agent: the runner waits it out, and past 10 minutes pauses the run and carries on by itself once Orca is back, so a live runner with nosummary.jsonmay be paused; keep waiting. A crew outage differs: crew takes its sessions down with it, the runner's included, so on crew a dead runner with nosummary.jsonmay be a daemon that died (a crash, a kill, a reboot) under a run that is still recoverable. A daemon brings such a run back only when it starts, so start one: runcrew daemon start, then wait up to two minutes forrunner.pidto name a live process again (the recovered runner writes a fresh one; the samenodeprobe) —timeout 120 sh -c 'd="<notes-dir>/orca-run"; until [ -f "$d/summary.json" ] || { [ -f "$d/runner.pid" ] && node -e "process.kill(+process.argv[1],0)" "$(cat "$d/runner.pid")" 2>/dev/null; }; do sleep 5; done'. A live runner, orsummary.json, means the run came back: tell the operator crew recovered it, and issue the same wait again. Only when neither comes is the runner dead: report it so, as below.Once
summary.jsonexists, read it:ok: truecarries the workflow's return value inresult,ok: falsecarries the runner'serrorandworktrees_kept, the worktrees retained because their agent died or never started. The runner reclaims nothing and asks nothing: every agent's session and worktree stands until the operator reclaims it — on Orca from the run view (Ctrl+Rin the runner's tab, or the standalone view), on crew from the run console (crew view); see the crew runner reference. Tell them so; do not wait for it. A dead runner with nosummary.jsondied before it could write one — killed, out of memory, or crashed; say so, and point the operator at<notes-dir>/orca-run/runner.log, which holds every line the runner printed, and atjournal.jsonlbeside it, which records each agent's start, result or failure with its reason.
-
-
Report what it returned, and name the runner the run used — the Workflow runner, or the session runner with its host (crew or Orca) and its session's or terminal's title: the stack bottom-to-top with each PR's url and state, the merge instructions for the mode the run ended in — or, for a halted run, its
reasonand each unpublished ticket's state and detail, with any questions aneeds decisionticket left — tickets deferred to a human and why, the blockers the unblock session cleared (or, for a run halted at Unblock, each blocker still to clear), what the run kept and why — on the session runner, every agent's session (on Orca, its tab) and worktree, which stand until the operator reclaims them from the run view or the run console, beside the result'sworktrees_kept, retained because their agent died or never started; on the Workflow runner, the result'sworktrees_keptand why — and the notes directory. The workflow's return value is already a summary — pass it on rather than re-deriving it from the PRs.
To come back to a session-runner run later, finished, paused or dead, with no runner going: a crew-hosted run is listed with crew ls and opened in the run console with crew view; an Orca-hosted one with orca-runs, the run view standalone, listing every Orca-hosted run on the machine, which reclaims a run's agents and resumes a run whose runner died.
To iterate after a failure, edit <notes-dir>/workflow.js (on crew, <run folder>/workflow.js, the script crew start printed) and relaunch it with the runner's resume handle — resumeFromRunId on the Workflow runner, --resume on the session runner (on Orca, the same launch as step 4 with --resume added; on crew, crew run "<run folder>/workflow.js" --resume [--permission-mode <mode>] in place of crew start, which would render the script afresh — clearing summary.json, halted.json and runner.pid first, since the failed run's are still there — and waited on the same way, for a fresh summary.json), which replays from the journal in <notes-dir>/orca-run/journal.jsonl (on crew, <run folder>/orca-run/journal.jsonl): the unchanged prefix of agent() calls returns from cache and only the edited call onward re-runs.
What the workflow does
- Graph — one agent reads the spec and every ticket and returns the task graph: blocking edges, which tickets need a human, and the blockers — what this machine is missing that a person supplies once (a credential, a signing identity or profile, a device or service set up), each with its evidence and a check command. A blocker is not a ticket needing a human: the tickets that need it stay automated (ADR-0021). It never looks for a branch carrying prior work: that is the operator's
__START_REF__(ADR-0023), and when one was named the graph agent's one job about it is to say, per ticket and with evidence, whether the branch already finishes it; such a ticket is subsumed — no agent runs for it, it blocks nothing, and the layer-0 PR closes it. Blocking edges come from GitHub's native dependencies first (blocked_by, one API call per ticket) and from prose in a "Blocked by" section only where the API returns nothing — a ticket whose dependencies were recorded natively has no prose to read. - Explore — parallel research agents write markdown into the notes directory, outside the repo. Notes are capped and cite rather than quote — a note saves later agents the search, not the reading — and later agents read only the notes whose subject bears on their ticket, not all of them. Explorers return blockers too. Beside them, whatever the graph asked to explore, the script always starts the baseline agent (ADR-0030), a node of its own under Explore: in a worktree of its own with the setup hook run, it switches to
__BASE_SHA__, runs every__PER_CHANGE_COMMANDS__command once, and returns each one's exit code, its failures (a test by its id; any other check by tool, rule, file, a five-to-six-line snippet and message, never a line number alone) and, where the tool can deselect tests, a masked command.submitrejects a record that does not match its schema; the script writes the accepted one topre-existing-failures.jsonin the notes directory (on crew, the run folder), never committed. Where the record holds a masked command, every role that runs the per-change recipe — implementer, fixers, gate reviewer, publisher — is told the masked command in the original's place, a command the gate's cross-check adds included; readiness counts a check on either form. Each of those roles is pointed at the record, and one whose work is meant to fix a masked failure runs the unmasked command and says so. Nothing is dispatched until it has succeeded, and a resume keeps it rather than measuring again. Once Unblock is through, a second baseline node,baseline:at-review, measures every__AT_REVIEW_COMMANDS__command the per-change set lacks the same way, in the background: dispatch and every ticket go on without it, and only the whole-stack review waits for it to succeed. Its entries then join the per-change ones inpre-existing-failures.json, and its masked commands join theirs; a resume keeps whichever of the two nodes succeeded. A blocker there is handled as in the per-change measurement, except that the run halts after every ticket has published, before the review, its record on the Workflow runner inbaseline-at-review-blockers.json. A command that runs and fails is a pre-existing failure; one that cannot run at all — the tool will not start, or its exit is not a check failure and nothing can be read from its output — is a broken environment a mask would hide, so the baseline agent names it as a blocker in the shape discovery uses (what is missing, the evidence, a check command). On the crew host it callsneeds_you: its row shows needs you, it is never nudged while it does, the operator enters its session and fixes the environment with it, and once the command runs it carries on measuring and submits; dispatch stays held until then, and the run carries on as with any baseline result. Without that tool it returns the blocker as the node's decisions_needed, which holds the node, and resuming carries its session on. The Workflow runner has nobody in its sessions, so it halts there with each blocker's what, evidence and check named, recording the halt inbaseline-blockers.jsonin the notes directory; the next baseline prompt names that halt, so a resume is a call the runner has not made and the baseline node runs again, checking each blocker first. Unblock — when discovery found any, the run builds nothing until they are cleared: on the session runner it opens one attended session,needs youfrom its start, that the operator enters from the run console. Its agent guides and verifies with each blocker's check, never installs, signs or configures anything itself, and is never nudged, counted stuck or failed for waiting. It ends once every check passes; a blocker the operator gives up on halts the run, and resuming continues the same session. The Workflow runner has nobody in its sessions, so it halts there with the blockers listed. - Setup — only when the operator named prior work at arm time: that branch becomes layer 0, the bottom of the stack, with its own draft PR whose body says plainly that it carries pre-existing work this run never gated, and
Closes #<n>for each ticket the graph agent found already done on it. Its local ref is mirrored from origin first — the one branch in the stack the run inherited rather than created, and every latergh stack linkpushes it by that local name (see The stack). - Dispatch — a dispatcher per ticket is a router, not a planner: it reads the ticket once, skims code structure (file lists, grep hits; about five tool calls), and answers two questions — how many slices, and which acceptance criteria each owns. It reads no spec, no research-note body and no implementation, and it does not hunt for gaps or drift: the ticket was cut from the spec by a human and is trusted as written. Each slice brief is under 3K characters and has four sections — criteria owned (verbatim), files expected, notes to read, out of scope — and never a design; an observed dispatcher told to write "self-contained" briefs thought for 40 minutes before its first tool call and cost $17 producing a plan the implementer redid with the code open. One slice is the normal verdict — the dispatcher is biased against slicing, because under-slicing self-corrects (an overrun comes back as a remainder to re-slice) while over-slicing pays a fresh agent's warm-up per slice with no corrective. It also emits a
ticket_brieflater fix agents use instead of the issue, and copies the ticket's validation recipe verbatim from its## Validationsection —### Run per changeintovalidation,### Run at reviewintoreview_validation, never rewritten (ADR-0029). The dispatcher is the only route into any work the run does — it has a second entry point that sizes review findings into fix slices, so no agent anywhere in the run starts unsized (ADR-0004). - Implement — slices run sequentially on the ticket branch, each a fresh-context agent in its own worktree (in sequential order, the run's one chain worktree, below), the ticket cut from its blocker's branch as the tip named it, or, on the stack's bottom, from the pinned
__BASE_SHA__, never from a ref that moves under the run, tickets still starting as early as their dependencies allow. The implementer reads the ticket for its criteria's wording and the notes its brief names, never the spec, and designs the change itself with the code in front of it. It holds the acceptance contract: the ticket's criteria, the spec's decisions that bear on them, and any ADR the spec itself creates or amends — existing ADRs are guidance, never criteria (ADR-0008). Where running the code shows the ticket silent or contradicting itself or its spec, the implementer decides it itself when the fix stays inside what the ticket leaves open, and returns the decision indecided, which the publisher puts on the PR; when the fix would break something the ticket, its spec or an ADR explicitly says to keep, it stops that criterion, leaves no code for it, and returns it indecisions_needednaming the constraint and the evidence — never inunmet. In parallel order a worktree is per agent, not per ticket, and each agent names its own path on return so the lane can reclaim it later (see Worktree reclaim inCONTEXT.md). In sequential order every code agent works in the run's one chain worktree,<runId>-chain, one after another, picking up the build cache the one before left (ADR-0020); a doctor still gets a worktree of its own. Before a chain agent's result returns, the runner's leftover check compares the chain worktree'sgit status --porcelainwith what that agent was told was there: anything more is sent back once, in its own session, to commit or remove, and whatever stays is named to every later chain agent as files never to commit. Every slice runs its ticket's per-change commands on its final commit and returns one result per command,{command, passed, exit_code}, and the sha it passed on asvalidated_sha. A zero exit is green with no judgement; a non-zero one is green only when the slice judges every failure in it pre-existing against the run's baseline record — a waived check (ADR-0030). While iterating it runs the narrowest scope its build tool supports and runs them once, after its last edit (ADR-0009); every check runs in the foreground as written, never backgrounded, never polled, and no agent ever runs a build-cache clean. A slice that outgrows its context (roughly 70 tool calls) commits what works and returns the remainder — the dispatcher re-slices it for a fresh agent, up toMAX_DISPATCH_ROUNDS. Work not done is always a remainder, never a gap — a required test the slice did not write goes back to dispatch, not forward to the gate or to a human; only a contradiction a human must settle is handed forward, indecisions_needed, and it ends the ticket's slice rounds: its criteria are never re-dispatched, even when a slice also filed them inunmet. A decision needed, or a remainder that survives the cap as unmet, is neither gated nor published: it halts the run (see step 10). - Gate — each ticket is reviewed on its own still-unpublished branch, against the base it was cut from, before it becomes a PR. The reviewer's first act is readiness: the branch was validated green at a sha the implementer (or the last fixer) named, so the reviewer runs
git rev-parse HEADand inherits that result when the sha matches and it has edited nothing, re-running them only when the tree differs (ADR-0009) — except a waived check, which it always re-runs and judges itself (ADR-0030); a red there is a remainder sent back to dispatch (it spends one ofMAX_DISPATCH_ROUNDS), not a finding sent to a fixer. It also compares the per-change commands in its prompt with the ticket's### Run per changeand returns any the prompt omits inmissing_validation; the script adds them to the ticket's recipe, verbatim and once each, and sends the branch back to dispatch the same way, so the next round runs the whole recipe and a dropped line costs one round, not a run. It is never a finding: no fixer can change the recipe, so a reviewer would raise it again every round. Then review and fix alternate until a review returns nothing blocking (minors pass), a fresh reviewer each round, capped atGATE_MAX_ROUNDS; on the cap it publishes with what is unresolved and the log and summary name it, rather than stalling every ticket downstream. What may block is narrow: an unmet criterion, a red validation command, or a correctness bug (panic, crash, silent data loss in release). ADR fit, architecture and style areminorat the gate — they were settled when the spec was designed, and re-proving them per ticket cost an observed run most of its gate rounds (ADR-0008). Round 1 reviews the whole diff; later rounds verify the claimed fixes and the lines the fixer touched, so the round count measures repair, not fresh discovery. The fixing half of every round goes through the dispatcher: it sizes the blocking findings from theticket_briefand the named files' structure — never the issue — and briefs fresh fix slices, each owning named findings, running the ticket's per-change commands, and returning a verdict for each. The script then reconciles verdicts against findings; anything with no verdict is logged and handed to the next reviewer to check explicitly. A reviewer that dies is never counted as clean. - Publish lane — a serial lane turns the graph into a chain: exactly one agent at a time rebases its ticket branch onto the current stack tip and opens a draft PR based on the tip,
Closes #<ticket>in the body. Whether the tip moved is decided by sha, never by name: a ticket on the stack's bottom was cut from the pinned__BASE_SHA__, so its publisher runsgit rev-parse origin/<start ref>and, when that is no longer the pinned sha, replays the ticket onto it from the pinned sha before opening the PR on it (ADR-0030). In sequential order the run's tip never moves under a ticket, so no publisher replays onto another ticket, and the script refuses a publish whose tip moved (ADR-0020); only that bottom check can still rebase. A rebase yields a tree nobody has validated, so the publisher runs the ticket's per-change commands once on it; when the tip has not moved it inherits the gate's result by sha, except a waived check, which it re-runs and judges itself: a waived check is never inherited (ADR-0030). Either way the PR body carries one provenance line,Validated green at <sha> by <role>, with each waived check under it asWaived: <command> exited <code>(none, nothing extra), so a human and the whole-stack review can see which tree was proven and by whom. Until this moment the branch has lived only in the shared clone, so the rebase rewrites commits that never left it and the lane's push creates the branch on origin rather than overwriting it — publish-once: a branch reaches origin exactly once, no ref on origin is ever rewritten, and no agent is ever told to force-push. The publisher checks origin first (git ls-remote --exit-code --heads): aticket/<n>already there — an earlier run's — is a stop and a decision for the operator, never a fast-forward over it. And a branch that adds no commit to the tip it was cut from has nothing to publish: the publisher says so, the ticket is recorded subsumed rather than halting the run, the tip stays, its dependants go on, and finalize names the ticket as still open. Two agents publishing concurrently would base on the same tip, which is the failure the lane exists to prevent. The lane also reclaims the ticket's worktrees: once a ticket reaches the lane nothing else will touch its branch, so the publisher removes every worktree the ticket's agents named — plus the previous publisher's own, which it could not remove from inside. It does so before it rebases, because a rebase would orphan every slice worktree's HEAD from the branch it built, and the reclaim's one guard is exactly that HEAD sits on the branch. The lane also registers the stack as it grows — after opening its PR it runsgh stack linkover the whole stack bottom-to-top, so a real stack map exists from the second layer onward rather than only after the run (ADR-0006). Each PR body opens withLayer k of N planned, and every layer stays draft until finalize: together they say the run is still adding. - Frontier — no phase barriers. Each ticket is one memoised promise awaiting its dependencies' publishes, so a ticket starts the instant its blockers' PRs exist and the run costs the longest chain rather than the sum of the phases. That is parallel order. In sequential order (ADR-0020) the frontier is walked one ticket at a time: of the takeable tickets the run takes the one first in the spec's own map (the graph's
map_position), then the lowest issue number, and its publish returns before the next ticket is dispatched — so the tip never moves under a ticket, its publisher never rebases, and outside Explore no two agents are ever in flight at once. - Review — one agent reviews the whole stack (
base...tip) and runs, once on the stack tip, the deduplicated union of every published ticket's### Run at reviewcommands — the full suites no per-ticket role runs; each is told in its masked form where the at-review baseline holds one, returns a{command, passed, exit_code}check, and judges a red against the record as every role does: apassed: falsecheck is ablockerfinding, a waived one no finding; its findings go through the dispatcher like any other work, and the fixes land as one integration PR on top of the stack, touching nothing below. Its fixers change code across tickets, so their per-change recipe is the deduplicated union of every published ticket's### Run per changecommands plus the at-review union — never narrower than any ticket's, and never empty while any ticket names a command. A separate low-effort agent opens that PR and changes no code, listing in its body every waived check the last integration fixer returned, and every one the review returned that the last fixer did not run green, asWaived: <command> exited <exit_code>(the run's result names the review's asreview_waivedeither way) — publishing is the phase's one irreversible act and does not belong to whichever fixer ran last. The gate cannot replace this review: what a per-ticket review structurally cannot see is the interaction between tickets — two implementations of one helper, abstractions that contradict each other, a contract one ticket relies on that another quietly changed. - Finalize — in native mode, one
gh stack linkcall reconciles the registration the lane has been maintaining all along: it picks up the integration PR (which publishes outside the lane, so nothing there ever links it) and repairs any in-lane call that failed. Every published PR is then marked ready — drafts block a stack merge, and until this step the drafts are what tell the operator the run is still adding layers. Finalize is reached only when every automated ticket is implemented, gated and published (ADR-0016). A ticket that fails, is left unmet, or needs a decision halts the run: from then on no ticket starts, and a running ticket makes no new agent call unless its dispatch returned exactly one slice, which finishes that slice, its gate and its publish. A halted run has no whole-stack review, no finalize, and nothing more on GitHub than the PRs already published; it waits for an at-review baseline still running to settle first, so no session outlives it and a resume replays that result rather than measuring again; it returns{ halted: true, reason, tickets: [{ ticket, state, detail }], published, ... }, each unpublished ticketfailed,unmet,needs decision,stopped(halting reached it mid-way) ornot started, and it waits to be resumed. A complete run appendsCloses #<spec>to the top PR, so the whole-stack merge closes everything at once. With no integration PR to list them, finalize appends the whole-stack review's waived checks to the top PR's body asWaived: <command> exited <exit_code>, so each one is on GitHub either way. Complete fails closed: a deferred ticket, a gate finding nobody fixed, an integration finding with no verdict, or a whole-stack review that never ran each keep the spec open — published is not complete — and finalize then adds no spec-close anywhere, commenting on the top PR and the spec issue with what remains. The workflow never merges — merging is the operator's, once, from the top. - Cleanup — finalize reclaims only what the lane did not: the last publisher's own, and anything a dead reclaimer was holding — same rule. What survives comes back as
worktrees_keptwith a reason: a worktree whose HEAD is off its branch, a file lock, or the worktree of an agent that died before naming its path, which is never removed, only named. On the session runner the script reclaims nothing — not in the lane, not here: every agent keeps its tab and worktree for the whole run, the runner asks nothing at its end, and the operator reclaims from the run view when they choose (ADR-0012, the crew runner reference). A sequential run's chain worktree is the run's, never one agent's: reclaiming its agents leaves it, and only Reclaim All removes it, last, once no agent of the run is kept and none is live in it; a chain reclaimed while the run was halted is made again, from the run's base, when a resume needs it (ADR-0020). A halted run reclaims nothing: its worktrees are what a resume carries on. Branches, PRs and the user's own checkout are left alone.
The stack
Ticket by ticket the lane builds an ordinary chain — each PR based on the branch below — and registers it as a native GitHub stack as it goes, so the operator gets the stack map on every PR from the second layer onward and merges the whole thing in one atomic operation from the top PR (or PUT .../pulls/<top>/merge-async).
gh stack link is the only gh stack command the run uses: it is stateless, reconciling, and pushes without force — but it pushes every branch it names, by local ref, in one atomic push, which is the fact the rest of this section turns on. Three rules govern how the run calls it (ADR-0006): re-list the whole stack every time rather than using the stack-number shortcut, which would make the run carry state between agents; never name a branch whose PR does not already exist, because link would open one outside the run's control; and never pass --open, which would ready the drafts and destroy the in-progress signal. A fourth (ADR-0007) is what keeps the push from being rejected: every branch a link call names has its local ref mirrored from origin first. The lane's own branches are local == origin by construction; layer 0 is not — it reached origin before the run, and a stale local ref left by an earlier run shadows it — and an inherited branch can move on origin mid-run. A published layer is never rewritten by a run (publish-once), so origin is authoritative and the mirror can only drop a stale shadow; the one branch it leaves alone is one a worktree has checked out. Two layers is its minimum, so a single-PR stack is never registered — correctly, not as a failure. A link failure never fails a publish, but the run tells two failures apart: a transient one is repaired for free by the next publish's re-list, while a push rejected non-fast-forward is deterministic — it names a local ref that still disagrees with origin — and is logged as such and carried into the brief rather than waited out. Only a hard exit 9 latches, degrading the run to a plain chain that the brief names in its own field rather than in a footnote. The rest of the extension is unfit for a parallel run and stays banned — submit force-pushes every branch; init, add, sync, rebase and checkout keep their state per-worktree (.git/gh-stack resolves via --git-dir) or rewrite history the stack has already published.
What the operator should know before merging, and the brief restates:
- Every layer must pass on its own: required checks, required reviews and CODEOWNERS are evaluated for each PR in the stack against the stack's base branch — approving only the top PR is not enough.
- CI runs on every layer as if it targeted the base branch, so a deep stack multiplies CI usage.
- After a failed stack merge, re-read the actual state of the base branch — GitHub's own docs disagree on whether a failed merge rolls back fully, so assume nothing.
- In chain mode there is no stack object: merge bottom-up by hand, with merge commits, deleting each head branch after its merge so GitHub retargets the next PR — squash and rebase merges rewrite the shas and give every child PR a phantom diff.
The rejection ledger
The gate loop's hazard is not slow convergence, it is ping-pong: a fixer judges a finding wrong and leaves the code, the next reviewer raises it again, and the pair trade the same finding until the cap — every round costing two agents and changing nothing.
So rejection is a first-class outcome. Each fix slice returns one verdict per finding it owns, fixed or rejected with a specific checkable reason, and every rejection accumulates into a ledger carried into all later rounds. A later reviewer may raise a rejected finding again only by falsifying its stated reason — naming which part is false and why. Absent that, the loop would never terminate on any disputed call.
The ledger only works if every finding comes back with something, so the workflow reconciles verdicts against findings in the script, not in a prompt — exact match on location and issue, else a location only one verdict claims. A finding with no verdict is never assumed fixed: it is logged and named to the next reviewer, which checks it on the branch. Reconciling by asking an agent what it failed to do would re-introduce the failure ADR-0004 exists to remove.
The reviewer being fresh each round is what makes "clean" a verdict rather than one reviewer running out of patience.
Reviews
Both the gate and the final pass run the in-repo code-review skill, which reviews a diff since a fixed point on two axes — standards (does it follow this repo's documented standards) and spec (does it do what the originating issue asked for) — in parallel subagents.
They differ only in what they pin: the gate pins the base the ticket branch was cut from as the fixed point and the ticket as the spec, so a ticket is judged against its own acceptance criteria; the final pass pins the base ref and the spec issue, over the whole stack.
The gate additionally overrides the skill's severities: only an unmet criterion, a red validation command, or a correctness bug may block. The standards axis still runs and still reports — as minor, which passes — so ADR drift is visible on the PR without costing a round (ADR-0008). The whole-stack review keeps both axes at full weight.
Attribution
The nine-step shape — spec and tickets as a task graph, implementer subagents in their own worktrees, publication onto a shared line of work, review then ready — follows Anthropic's implement-spec skill. What this skill adds is the translation into one deterministic script (frontier scheduling, the serial publish lane), the stacked one-PR-per-ticket output registered as a native GitHub stack, the per-ticket gate loop with its rejection ledger, human-gated ticket exclusion, and the discipline of keeping the orchestrator's context empty.