ship

Execute one fully planned Task to a reviewable PR with isolated writers, immutable independent review, fresh CI/review coverage, and durable accounting.

SKILL.md

GitHub

Execute one planned Task to a reviewable PR. Never merge automatically. Explicit merge authorization enters references/merge-accounting.md.

$ARGUMENTS

Invariants

  • Before classifying state or dispatching any worker, the parent runs the tracker preflight (${CLAUDE_PLUGIN_ROOT}/skills/shared/references/preflight.md); a failure stops here with its single ## Action needed block — no worker starts against an unusable tracker.
  • The highest # Execution Plan vN on the Task is the plan — enforced by the plan_file tool, which refuses when there is no plan comment, refuses by comment id rather than picking when two claim the same highest version, and refuses when a vN (continued) stub sits at or above the selected version rather than falling back to an older complete plan; otherwise stop for /sy:spec.
  • Check plan-base freshness before building: material drift between PLAN_BASE_SHA and the ship base returns to /sy:spec.
  • Resolve standards before code.
  • Resolve tracker blockers before branching; decomposed/superseded closure is not delivery.
  • Read small cohesive surfaces directly; delegate large/verbose work — standards resolution, verbose verification (test/lint/type runs), and CI triage all count as verbose, and every added delegate enters agents_used. At most the resolved limits.max_depth_agents cap depth agents in flight — resolve per ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/config-values.md.
  • START, BUILD, and GATE each run as disposable autonomous worker subagents (sy:ship-start/sy:ship-build/sy:ship-gate); the parent is a thin dispatcher owning durable state (state file, worktrees, and which Task/PR/branch this run is against), the HANDOFF retro/accounting, MERGE, and all user interaction. Owning that identity is not a ban on a worker writing to the tracker at all: START and GATE each hold the narrow status/assignment verbs their own phase names, pinned to an exact per-worker set by scripts/validate.py::check_agent_mcp_allowlists.
  • Workers never prompt the user; each returns done, needs-decision, bail-to-spec, or blocked per the worker contract, and BUILD additionally returns needs-trace, GATE handover. § Worker contract owns how the parent resolves each, including the two needs-decision returns — tagged reason: max_gate_rounds or reason: reviewer_findings — that go straight to AskUserQuestion instead of being resolved first.
  • Any phase return may carry MEMORY_REFUTE candidates — {title, evidence, correction}, an empty correction meaning tombstone — for a seeded memory anchor this phase's own direct observation contradicted, durably mirrored in ship-state.yaml's memory_refutations. No ship worker agent gains memory_add/memory_refute tool access: the parent holds every write against the user's global, cross-repo store (see ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/memory.md).
  • Checkpoints are slice/step-granular and idempotent: a continuation re-does no committed, pushed, or tracker-posted work and re-creates no recorded worktree. A phase exceeding the resolved ship.escalation.max_needs_decision threshold of needs-decision returns escalates to /sy:spec as underspecified; a phase exceeding the resolved ship.escalation.max_needs_trace threshold of needs-trace returns without reaching done escalates the same way, on its own separate count. A GATE phase whose fix-cycle rounds for the run reach the resolved ship.escalation.max_gate_rounds threshold is the third member of that family, and the one that asks you rather than escalating on its own (references/immutable-gate.md § Fix cycle). Resolve all three per ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/config-values.md.
  • A review or build finding that is small, adjacent, and low-risk is fixed in-branch as a recorded scope extension, not deferred to a follow-up (see ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/scope-discipline.md).
  • Every writable delegate gets a caller-created, recorded worktree.
  • Worktrees live under <resolved worktree root>/<branch>, where the root is the value get_config {"key": "worktree.root"} reports — by default the sibling directory beside the repo (e.g. /path/to/myrepo → /path/to/myrepo-worktrees/<branch>), overridable via worktree.root in config — never nested inside the working tree.
  • sy:gate reviews an isolated worktree pinned to exact base/head SHAs.
  • Current PR HEAD must equal CI-green SHA and reviewed SHA before handoff or merge.
  • When the plan declares a pre-gate checkpoint, current PR HEAD must also equal that checkpoint's cleared SHA before GATE's first dispatch against that head (§ Pre-gate checkpoint); once GATE has been dispatched for that head (pregate_checkpoint_gate_dispatched), its own fix-cycle commits and resumes never re-arm this check until BUILD's next done clears it. A plan that declares none — the default — never blocks here.
  • Model tier is a quality floor, and the parent wires it mechanically. The parent never infers a phase's model from a single word and never assumes a worker inherits the parent session's model, because a worker's own frontmatter model always wins over an omitted override. The profile's START and BUILD models are what those workers run at; the parent dispatches the GATE worker at the model and effort agent_model {"name": "ship-gate"} reports regardless of profile, and it is the GATE worker itself — not the parent — that consumes the profile's GATE tier via its own Resolve-gate-model procedure (references/immutable-gate.md § Resolve gate model) to set the model override for its nested sy:gate call. Each phase worker runs at least its declared tier (BUILD stays at least opus) and the ship profile may raise but never lower it: every floor lives in config/floors.json, and the resolver refuses by name a config that drops a worker below its own. The full dispatch rule is ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/model-dispatch.md.
  • Cost-scaling never touches the reviewer: the ship profile's effort may raise BUILD effort or lower START effort, within each worker's floor, but it never touches review: sy:gate keeps its frontier tier, max effort, and full coverage on every path — including the trivial-diff and bounded-fix paths. config/floors.json pins gate at min_model: frontier / min_effort: max, so this is enforced by the resolver, not only by this sentence.
  • After merge authorization, one small bounded fix may land through the authorized bounded-fix → focused-delta-gate → merge sub-flow in references/merge-accounting.md: the delta review is valid only when the prior reviewed head is the immutable base and the new head is the immutable head; anything broader re-enters GATE.
  • Resolve START's and BUILD's models explicitly from the plan's stated per-phase tiers and the profile's effort, and the gate model and effort likewise, passing each as its Agent invocation's actual model and effort parameters; record requested and transcript-observed models separately per phase (references/start-resume.md § Resolve start model, references/implementation.md § Resolve build model, references/immutable-gate.md § Resolve gate model).
  • Token accounting must aggregate the main ship transcript and all nested subagent transcripts.
  • Images stay out of the long-running context: figures/screenshots/plots are inspected only through short-lived sy:img-inspector subagents that return text verdicts, and no image Read appears in a BUILD or GATE transcript (see ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/image-inspection.md).
  • Tracker machine logs are small standalone JSON comments; never bury usage or metrics JSON inside retrospectives or plan comments.
  • Talking to you follows exactly one of three modes per turn — status update, AskUserQuestion, or an isolated ## Action needed block — never blended; see ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/user-interaction.md.
  • Mandated external writes obey write integrity: a posted record later overruled or found wrong is corrected on its own surface, and a denied write is never rerouted through another tool/path to force it through — surfaced loudly instead; see ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/write-integrity.md.

Compression boundary

Agent-to-agent returns use each agent's compact return contract, and a return backs its load-bearing claims with checkable evidence pointers. The parent trusts an evidence-backed brief and spot-checks decisive spans rather than re-deriving ground truth; it re-verifies in full only a load-bearing claim the brief leaves unbacked. Seed every agent prompt with known anchors — paths, symbols, plan step, keys — and name ground already covered. Human-facing tracker/PR artifacts remain clear prose; machine logs remain compact JSON.

Worker contract

The parent dispatches each of START, BUILD, and GATE (and any resumed segment) to its worker agent (sy:ship-start/sy:ship-build/sy:ship-gate), seeded with the phase procedure reference and the compact state brief; it never runs those procedures inline. Immediately before each of those dispatches — fresh or a continuation, and whatever the reason for it — the parent stamps that phase's name into phase_active in ship-state.yaml, and it clears the field back to null as part of processing whatever that worker returns (below), so it is non-null only while a dispatch is genuinely in flight (§ State router). The one exception is a fresh run's own START dispatch: ship-state.yaml does not exist yet to stamp into, so START's own seed step writes phase_active: null as part of creating the file for the first time (references/start-resume.md § Fresh run); every dispatch from that file's creation onward stamps and clears normally. HANDOFF and MERGE run in the parent, with their verbose reads delegated. A worker runs autonomously and never blocks on long external state (CI, deploys) by polling in-context or self-resuming a monitor: it waits with a token-free background poller or returns blocked with a checkpoint. It returns exactly one:

  • done — phase complete; updated state brief (SHAs, PR, agents_used, accepted_deviations, memory_refutations) with checkable evidence backing every load-bearing claim.
  • needs-decision — an idempotent checkpoint plus a question brief: what was attempted, why blocked, the options, and the plan/standards spans bearing on it.
  • needs-trace (BUILD only) — an idempotent checkpoint plus a bounded empirical question: what is still unresolved, its seed anchors, and why it crossed the spot-check bound (the same bound as /sy:spec §3) instead of staying a direct read. No candidate options — only a question a trace can resolve.
  • handover (GATE only) — the round is complete, the loop has not converged, redispatch me; the same idempotent checkpoint needs-decision carries. The parent's handling is unconditionally mechanical — drain, clear phase_active, persist the checkpoint, reconcile recorded state against ground truth (§ State router), dispatch sy:ship-gate again from it — and never AskUserQuestion, never an ## Action needed block, never counted toward ship.escalation.max_needs_decision or max_needs_trace, and it neither increments nor rebases gate_rounds_total/gate_rounds_budget_base. Those stay the round bound's alone (references/immutable-gate.md § Fix cycle), which is what bounds the handovers too.
  • bail-to-spec — the plan's contract or architecture is invalidated; reason and offending anchors. Also the escape hatch when a phase keeps returning needs-trace without converging.
  • blocked — external cause (merge authorization, infrastructure); what is required.

On any return — done, needs-decision, needs-trace, handover, bail-to-spec, or blocked alike — the parent first applies every pending MEMORY_REFUTE candidate via memory_refute, rather than waiting for the HANDOFF retro, which a bail or a crash never reaches. Applying a candidate removes it from memory_refutations in state, and each worker emits only the candidates its own segment newly observed — never an accumulated history — so a continuation worker cannot re-apply an already-applied candidate over a newer correction that superseded it. Next the parent clears phase_active to null — for any of the six values alike, ordered here rather than after the branching below so that no branch can be the one that forgets. Then the parent advances on done; dispatches a GATE continuation from the checkpoint on handover; resolves needs-decision from plan/standards/code and dispatches a continuation worker from the checkpoint, asking via AskUserQuestion only when the choice is genuinely ambiguous; resolves needs-trace by dispatching a bounded, foreground sy:trace itself — mechanical, not a judgment call, so it never reaches you as a question — and resuming the worker from the checkpoint with the findings folded in as a new anchor; stops for /sy:spec on bail-to-spec; and surfaces blocked to you as an ## Action needed block naming the external cause. Parent-owned throughout (§ Invariants), including the START profile guard.

A phase_checkpoint is written twice, and only the second write is the parent's. The worker makes its own checkpoint durable before the return (hand-back rules: ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/agent-returns.md), so a refused or lost hand-back still leaves one on disk; that value is a durability floor and is already stale by the time the round ends, because GATE's write lands before that round's fixes are pushed (references/immutable-gate.md § Fix cycle). The checkpoint the return carries is re-authored at return time against the post-push SHA (CHECKPOINT: <dispositioned vs pending + pushed SHA>), so the parent's persist of it updates the durable value rather than re-affirming it, and § State router's reconcile compares against that persisted post-push value, never the floor.

A needs-decision tagged reason: reviewer_findings (references/immutable-gate.md § Fix cycle) is the second member of that family: the parent skips the resolve-it-yourself step and puts the round's declined reviewer findings to you via AskUserQuestion — act on the ones you name (a GATE continuation from the checkpoint fixes them), or accept the worker's rejections as dispositioned, plus Other; never a plain "continue". Accepting them is what lets the stopping rule converge, so this can never deadlock the loop. It touches neither gate_rounds_total nor gate_rounds_budget_base, which stay the round bound's alone.

A needs-decision tagged reason: max_gate_rounds (references/immutable-gate.md § Fix cycle) is the one that skips the resolve-it-yourself step entirely and asks you directly, offering exactly two dispositions via AskUserQuestion — never a plain "continue", which is what the bound exists to stop. Raise the budget for this run: the parent stamps gate_rounds_budget_base to the current value of gate_rounds_total and dispatches a GATE continuation from the checkpoint, counting fresh against the same cap from that base. It never resets gate_rounds_total itself, which stays monotonic for the life of the run (references/handoff-accounting.md §3). That is a state change and never a config edit, so it cannot trip the mid-run config-fingerprint stop the way editing ship.escalation.max_gate_rounds under a running ship would. Or stop and return to /sy:spec, handled exactly as any bail-to-spec. Either way the two gate_rounds_* fields, never a checkpoint's descriptive count, remain the sole authority (references/immutable-gate.md § Fix cycle).

Pre-gate checkpoint

Honouring the plan's declared pre-gate checkpoint field is parent-owned and never a worker's, resolved the way the ship profile already is (references/start-resume.md § Resolve start model). START stamps the declared channel into pregate_checkpoint_channel once at dispatch; pregate_checkpoint_cleared_sha starts null, pregate_checkpoint_changes_requested starts 0, pregate_checkpoint_gate_dispatched starts false, and pregate_checkpoint_request_text starts null. A plan that declares nothing leaves the channel null, and a null channel skips this section entirely — including on a resume from an older state file that predates these fields, where an absent channel reads the same as a declared-none one, an absent pregate_checkpoint_gate_dispatched reads the same as false, and an absent pregate_checkpoint_request_text reads the same as null.

Before GATE's first dispatch against the current head_sha — fresh after BUILD's done, or on a resume that has not yet dispatched GATE for that head — the parent checks that the channel is set and that pregate_checkpoint_cleared_sha equals the current head_sha. Set and equal means this checkpoint is already cleared on this exact commit, so GATE goes out unchanged. (A null cleared_sha never counts as equal, even when head_sha is also still null — that pairing means the checkpoint has not been reached yet, not that it is cleared.) (pregate_checkpoint_gate_dispatched is stamped true at that dispatch, same as any other.) Set and unequal is the one case that pauses: the parent presents the draft PR URL (and, for a running-preview channel, a reminder that this is a local look you take yourself — this step never launches anything) and asks via AskUserQuestion per ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/user-interaction.md, framed as whether this looks like the right thing to have built — product and UX judgment, offering proceed to GATE / request changes / other. It is never framed as a correctness review and never invites one: sy:gate alone owns correctness. Once pregate_checkpoint_gate_dispatched is true, GATE owns this head exclusively: a further resume that lands back on GATE — its own needs-decision checkpoint, a retry, anything short of a fresh BUILD done — skips this whole check and dispatches GATE directly, regardless of how far head_sha has since moved from pregate_checkpoint_cleared_sha; § State router carries the identical guard for the same reason.

Proceed records the current head_sha as pregate_checkpoint_cleared_sha, stamps pregate_checkpoint_gate_dispatched true, and dispatches GATE.

Request changes first collects what to change, since neither named option carries it by itself: an Other answer's own custom text is the request verbatim; the plain Request changes label carries no text, so the parent's very next turn asks one more AskUserQuestion dedicated to collecting it, offering a real way back out ("proceed to GATE after all", "this needs a fresh /sy:spec revision instead") alongside Other for the description itself — either path converges on one non-empty string. Picking the first escape ("proceed to GATE after all") re-routes exactly as Proceed does above, and none of the fold-in below ever runs. Picking the second ("this needs a fresh /sy:spec revision instead") stops here rather than dispatching BUILD at all — the same stop-for-/sy:spec handling the parent already gives any bail-to-spec (§ Worker contract). Both escapes are matched by intent, not literal label text: a free-text Other answer whose own words plainly mean the same thing as an escape routes identically to it, never as a change request. For any answer that is a real change request (not one of the two escapes above), the parent stamps that string into pregate_checkpoint_request_text, increments pregate_checkpoint_changes_requested by one, and dispatches a fresh BUILD continuation from BUILD's own phase_checkpoint — the identical continuation-from-checkpoint mechanism needs-decision already uses, so the worker contract stays closed at six: no worker signals, prompts for, or can even observe this checkpoint. That continuation's checkpoint holds no pending plan slice by construction — BUILD had integrated them all before returning done — so pregate_checkpoint_request_text is what gives it work, folded into BUILD's own manifest as one new pending slice (references/implementation.md § Delegated slice protocol). phase_checkpoint stays entirely worker-authored — the parent never writes a slice, only the field it hands off.

Whatever BUILD returns next from that continuation — done, needs-decision, needs-trace, bail-to-spec, or blocked — the parent's handling of that return is where pregate_checkpoint_request_text is cleared back to null, in the same write that persists whatever phase_checkpoint the return carries, never as a separate later step a crash could land between: a session that dies before BUILD ever returns can leave the text set against a manifest that already carries it, which is why folding it in is guarded by the dedup rule in ${CLAUDE_PLUGIN_ROOT}/skills/ship/references/implementation.md § Delegated slice protocol rather than by write ordering; and a blocked return (external cause, per § Worker contract) clears it too, since the fold-in already happened before whatever blocked BUILD did, and leaving it set would refold the same text on the eventual resume. Processing BUILD's eventual done is also where the parent — never BUILD, which is barred from naming the field at all — resets pregate_checkpoint_gate_dispatched back to false for the new head, in that same write; pregate_checkpoint_cleared_sha needs no matching reset, since it goes stale the moment head_sha changes.

GATE itself never re-enters this section once dispatched; pregate_checkpoint_gate_dispatched is what makes that mechanical. Its fix-cycle commits are review remediations bounded by ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/scope-discipline.md, not the product/UX surface this checkpoint exists to catch.

State router

Preflight (above) runs once, first, ahead of this classification — including on resume, since a checkpoint can route straight to BUILD or GATE without ever passing through START.

The plan is materialised in that same step, for the same reason, on every session alike — fresh and resume: the parent calls plan_file on the Task, which resolves the highest plan version and writes that comment's ## For /sy:ship half to a file, and the parent then names that file's absolute path in whichever worker's dispatch prompt it goes on to send. It belongs here rather than in any phase procedure because no worker after the parent holds a tracker read at all. The parent's own context never takes on the plan text: the tool returns a path and the pin, never the body.

The pin comparison against recorded state is the resume-only half of that same call. On a fresh run there is nothing recorded to compare against and the returned path/comment_id/version are simply stamped into state as plan_path/plan_comment_id/plan_version (references/start-resume.md). On a resume the parent compares what plan_file just returned against what state already carries: unchanged, dispatch as the classification below decides; changed, the plan was revised mid-run and it is handled exactly as any bail-to-spec (§ Worker contract) — never resumed against a phase_checkpoint built from the plan that got superseded. State carrying none of the three fields is a file written before they existed and reads as null, which is nothing recorded rather than a mismatch; this step supplies them.

On resume the parent also loads ship-state.yaml here and drains it before dispatching whichever phase the classification lands on: any memory_refutations still listed are pending, not history, so the parent applies each via memory_refute and clears the list from state — the same drain rule as an in-flight worker return (§ Worker contract). It belongs in this pre-dispatch step for the reason the materialisation above does, and the HANDOFF retro deliberately does not backstop it; an undrained list means the refuted anchor is still read back as if it held.

phase_active is checked in that same pre-dispatch step and for the identical reason. A non-null value here means the last thing that happened was a dispatch whose return was never processed (§ Worker contract clears it on every return, including blocked), so the parent surfaces it in the state brief — the prior session ended without confirming its last dispatched phase finished cleanly, worth a quick sanity check of the recorded worktree before trusting the checkpoint — and then clears it to null unconditionally, because a stale flag left set reports the same crash again after a clean resume. This changes nothing about classification: it is a note attached to whatever phase the classification lands on, and an absent or null field means nothing was in flight.

The same reasoning covers a declared but unresolved pre-gate checkpoint. Before GATE's first dispatch against the current head — fresh after BUILD's done, or a resume classifying straight to GATE that finds pregate_checkpoint_gate_dispatched still false — the parent checks pregate_checkpoint_channel against pregate_checkpoint_cleared_sha == head_sha and, on a mismatch, routes into § Pre-gate checkpoint's same AskUserQuestion rather than dispatching GATE directly; a null/absent channel, or a true pregate_checkpoint_gate_dispatched, both fall straight through (§ Pre-gate checkpoint). A set pregate_checkpoint_request_text overrides all three: it means a BUILD continuation was dispatched to fold it in but has not yet returned, so any resume classifies to BUILD, not GATE, until that continuation's own return clears the field (§ Pre-gate checkpoint) — regardless of what channel, cleared_sha, or gate_dispatched say.

Before every GATE dispatch — the fresh one after BUILD's done, every handover continuation (§ Worker contract), and a resume that classifies to GATE — and on every resume whatever phase it lands on, the parent reconciles recorded state against ground truth. It runs after the pregate_checkpoint_cleared_sha == head_sha comparison above and never before it: stamping a moved head_sha first would re-raise a checkpoint already cleared for that head. Three reads: git log <recorded head>..origin/<branch> for commits the state file never saw; gh pr view --json headRefOid,isDraft, whose isDraft catches a PR promoted by a round whose return was refused, so the parent does not re-promote it (${CLAUDE_PLUGIN_ROOT}/skills/ship/references/handoff-accounting.md establishes that command shape only — its own field is mergedAt); and the worker_handbacks tool (tool names resolve per ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/config-values.md) passed state's ship_session_id, for a return this session recorded as refused. A null or unresolvable id — references/start-resume.md seeds it only "if available" — skips the hand-back check and says so in the state brief, never an error. A non-empty warnings list on that report means the subagent tree was not fully read, so refused is a floor rather than an exact count, and it is surfaced in the state brief the same way.

One delta is expected rather than a mismatch: GATE writes the round's state before that round's fixes are dispatched (references/immutable-gate.md § Fix cycle), so at every handover the recorded head legitimately lags the pushed head by exactly that round's fix commits. A delta the handover checkpoint's own pushed SHA accounts for is normal and is not a mismatch; without this exemption the check fires on every converging round and the coverage it nulls was fine. For anything else, ground truth wins: stamp the live head into head_sha, null reviewed_sha/ci_green_sha that no longer cover it and record that a delta review is owed, and surface any mismatch, any refused hand-back, and any warnings in the state brief exactly as a stale phase_active already is. It only nulls coverage or restates the head — it never promotes, merges, or marks a round complete.

Classify first, then load only the needed procedure:

START     initialize/resume ownership        → ship-start worker · references/start-resume.md
BUILD     implement/integrate plan, draft PR → ship-build worker · references/implementation.md
GATE      CI, immutable review, promote      → ship-gate  worker · references/immutable-gate.md
HANDOFF   retro, usage, metrics              → parent · references/handoff-accounting.md
MERGE     direct user merge authorization    → parent · references/merge-accounting.md

Do not preload mutually exclusive procedures; a worker loads only its own.

Completion bar

Normal completion is a reviewable PR with current acceptance evidence, green CI and independent review covering the same head, the automated reviewer explicitly requested with the request confirmed (when ship.request_ci_reviewer resolves true — see ${CLAUDE_PLUGIN_ROOT}/skills/shared/references/config-values.md), and every review thread that exists when GATE converges — bot and human — reconciled and answered rather than left for you, a doc-accuracy self-check over the shipped documentation diff, Task in-review, human retrospective, and standalone JSON usage/metrics comments. A review requested since promotion and still pending, or a thread opened since GATE's last pass and still unanswered, at handoff is a residual the handoff status names. Then stop at handoff.