Plan it, execute it, ship it. One /shipit:go runs the whole voyage in a single session.

Plan it. Execute it. Ship it.
A structured Plan → Execute → Review workflow for Claude Code. One skill runs the whole voyage in a single session and stops twice: for your approval of the plan, and for your sign-off before shipping. A mod draws the voyage in Claude Code, keeps the orchestrator from writing code itself, and keeps the work moving.
mattpocock-skills plugin. Planning runs on its grilling and domain-modeling skills. shipit declares it as a plugin dependency, so installing shipit from the flow marketplace brings it along. To install it yourself: claude plugin install mattpocock-skills@claude-plugins-official
If the two skills are missing, planning says so and stops; it does not fall back to an improvised interview.
# Add the flow marketplace
claude plugin marketplace add <flow-marketplace-url>
# Install shipit
claude plugin install shipit
/shipit:go Add user authentication to my API
That is the default way to use shipit. In one session it will:
Between the two gates it does not ask "should I continue". The only other questions are the planning questions themselves, a one-time choice of execution mode when workflows are available, and genuine blockers.
/shipit:go also picks up where a voyage left off: run it again and it resumes from the recorded phase (asking first when the voyage belongs to another session). Name an existing plan and it asks whether to execute that plan or plan anew.
The whole voyage: plan → plan gate → execute → review → ship gate. It composes the three phase skills below; each of them also works on its own.
Usage:
/shipit:go Add rate limiting to the API
/shipit:go Refactor the payment module
/shipit:go auth-plan
Planning only. The Cartographer will:
grilling and domain-modeling skills and grill the design tree until no open decision is left, a round of questions at a time. Questions arrive as selectable options (the AskUserQuestion prompt) with the recommended answer first; pick one or type your ownCONTEXT.md and decisions to docs/adr/ as domain-modeling directs.shipit/plans/{name}.md as a task dependency graph: every task carries a Depends on: line, and there are no hand-assigned wavesRun standalone, it ends at the plan gate and tells you to run /shipit:execute.
Usage:
/shipit:plan Add rate limiting to the API
/shipit:plan
Execution only. The Captain will:
Depends on: lines (older plans' Blocked By: lines are read the same way)Usage:
/shipit:execute # Start or resume the active plan
/shipit:execute auth-plan # Execute a specific plan
Run standalone, it ends when the tasks are done and points you to /shipit:review.
Final verification. The Harbormaster runs four reviews in parallel:
The review runs in the same session as the work. Its independence comes from the reviewers: each is a fresh-context subagent that sees only the plan path and the repository. A rejection does not end the review: the fix goes to a Maker, the dimensions it invalidates run again, and this repeats until all four approve. Then you give the final sign-off.
Usage:
/shipit:review
/shipit:review auth-plan
/shipit:execute and /shipit:review are run by you (or by /shipit:go); Claude does not invoke them on its own.
shipit ships a Claude Code mod that follows the voyage this session owns. It reads .shipit/voyage.json and the plan; it never writes or deletes either.
One row above the prompt while a voyage is under way:
Plan › Execute › Review auth-plan 3/9 awaiting you [ Voyage ]
?/? when the plan file cannot be read)paused marker while you have paused the mod, and awaiting you at either gate or when Claude has asked you a blocking questionOn a narrow terminal the row gives ground in order: the plan name is shortened, the trail collapses to the current stage, then the name, the button, the markers and the count drop out. Nothing is drawn when the session owns no voyage.
A status line entry mirrors the band: shipit: execute 3/9, with (paused) and/or (awaiting you) after it when they apply.
Open it with the band's Voyage button or /shipit pane. It shows:
done, ready or blocked, and for a blocked task the task ids it is waiting onAPPROVE, REJECT or not run).shipit/evidence/ (names only; the pane does not open them)p), the same switch as /shipit pause and /shipit resumeWhile this session owns a voyage, the main loop may write only:
| Path | When |
|---|---|
.shipit/** | Every phase of the voyage |
CONTEXT.md, **/CONTEXT.md | While planning (including the plan gate) |
CONTEXT-MAP.md (project root) | While planning (including the plan gate) |
docs/adr/, /docs/adr/** | While planning (including the plan gate) |
Any other Write, Edit or NotebookEdit call from the main loop is denied, with a message naming the path and the way forward. Paths are checked after symlinks are resolved, against the project root. This is what makes the orchestrator orchestrate: source changes go through The Maker.
sed -i, git apply, a generator) passes straight through. The skills instruct the main loop not to do that, but nothing enforces it.A standalone /shipit:plan leaves the voyage at the plan gate, so the guard stays armed until you run /shipit:execute, pause, or end the voyage.
If the main loop ends a turn while work remains, the mod submits a short prompt telling it to continue with the next unchecked task (or the review dimensions not yet approved). It is bounded:
/shipit resume re-arms itA command registered by the mod. It is separate from the /shipit:... skills above.
| Subcommand | What it does |
|---|---|
/shipit or /shipit status | The voyage, its phase, task counts, the next task, verdicts, and whether the guard and enforcer are on |
/shipit pause | Suspends both the guard and the enforcer for this session |
/shipit resume | Turns both back on and resets the nudge count |
/shipit end | Asks Claude to close the voyage |
/shipit pane | Opens the voyage pane |
/shipit pause exists for the moment you want to make a change by hand mid-voyage. The pause is held by the mod for this session only: it is not written to voyage.json and it ends with the voyage.
/shipit end deletes nothing itself. It asks Claude to remove .shipit/voyage.json; the plan, evidence and notepads stay. If the request cannot be delivered you are told to ask Claude directly.
| Option | Default | What it does |
|---|---|---|
maker_model | empty | A model alias or id for The Maker's subagents, e.g. <model-alias>. Empty means Makers run on the session's model. It applies to The Maker only |
enforcer | true | The continuation enforcer. false turns it off |
guard | true | The write-guard. false turns it off |
The band and the pane draw in the terminal and in the Claude Desktop app. Everywhere else Claude Code runs, the mod still runs but draws nothing: the write-guard and the enforcer apply exactly as above, but there is no band and no pane.
When the session has the Workflow tool, execute asks you once, before the first delegation, whether to run the tasks as the saved shipit:voyage workflow instead of delegating them one by one.
The workflow is never used without that answer, and the answer covers that run only. Where the Workflow tool is not available the question is not asked. Either way the Captain still verifies each result and is the only one to tick the plan's checkboxes.
| Character | Role | Used By |
|---|---|---|
| The Cartographer | Strategic planner — grills, records decisions, generates plans | /shipit:plan (the main loop) |
| The Captain | Master orchestrator — delegates, verifies, tracks progress | /shipit:execute (the main loop) |
| The Harbormaster | Final reviewer — 4-dimension quality gate | /shipit:review (the main loop) |
| The Scout | Codebase explorer — finds patterns, conventions, implementations. Read-only | shipit:scout agent, spawned by Cartographer/Captain |
| The Archivist | External researcher — finds docs, examples, best practices. Read-only | shipit:archivist agent, spawned by Cartographer/Captain |
| The Sage | Architecture advisor — strategic consultation on design decisions. Read-only | shipit:sage agent, spawned by Cartographer/Captain |
| The Gatekeeper | Plan validator — verifies plan quality, references and the task graph. Read-only | shipit:gatekeeper agent, spawned by Cartographer on request |
| The Maker | Task executor — implements a single task following its specification | shipit:maker agent, spawned by Captain/Harbormaster |
The four reviewers of /shipit:review are general-purpose subagents with a fresh context, not members of the cast.
/shipit:go (one session)
┌───────────────────────────────────────────────┐
│ plan (/shipit:plan) │
│ │
│ Grill → Glossary + ADRs → Task graph → Plan │
│ │
│ Skills: grilling, domain-modeling │
│ Agents: Scout, Archivist, Sage, Gatekeeper │
└───────────────────────┬───────────────────────┘
│ GATE 1: you approve the plan
▼
┌───────────────────────────────────────────────┐
│ execute (/shipit:execute) │
│ │
│ Ready frontier → Delegate → Verify → Tick │
│ │
│ Agents: Maker, Scout, Archivist, Sage │
│ Optional: the shipit:voyage workflow │
└───────────────────────┬───────────────────────┘
│ no stop
▼
┌───────────────────────────────────────────────┐
│ review (/shipit:review) │
│ │
│ Compliance · Quality · QA · Scope (F1-F4) │
│ Fix through a Maker → re-run → all APPROVE │
│ │
│ Agents: 4 fresh-context reviewers, Maker │
└───────────────────────┬───────────────────────┘
│ GATE 2: you sign off
▼
shipped
A voyage is one run of a plan through shipit. Its state is a single file, .shipit/voyage.json: the plan it follows, the session that owns it, its phase (planning, plan_gate, executing, reviewing, ship_gate, done), whether Claude is waiting on you, and the four review verdicts. Task progress is not stored there: it is read from the checkboxes in the plan file.
Only the main loop of the owning session writes that file. The mod and the workflow read it. A project has one voyage at a time, and a session that finds a voyage owned by another session asks before taking it over. The file is removed when you sign off, or when the voyage is ended early.
The full contract (schema, phases, transitions, ownership, the guard's allow-list) is in skills/VOYAGE.md.
shipit uses a .shipit/ directory in your project root:
.shipit/
├── plans/ # Work plans (commit these)
├── drafts/ # Planning notes (gitignore)
├── evidence/ # QA evidence (gitignore)
├── notepads/ # Accumulated wisdom per plan (gitignore)
└── voyage.json # Active voyage state (gitignore)
Add to your .gitignore:
.shipit/drafts/
.shipit/evidence/
.shipit/notepads/
.shipit/voyage.json
Planning also writes outside .shipit/: the glossary (CONTEXT.md, and CONTEXT-MAP.md with per-context CONTEXT.md files in larger projects) and ADRs under docs/adr/. Those are project documentation: commit them.
If a session is interrupted (context limit, crash, etc.):
/shipit:go (or the phase skill you were in) againvoyage.json and the plan file, and asks before taking over a voyage that another session ownsWrite, Edit and NotebookEdit tools. Shell commands that write files are not intercepted./shipit end does not delete the voyage file itself. It asks Claude to; until Claude does, the voyage is still active.voyage.json.MIT — see LICENSE
hooks/register.tsx 149 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { registerBand } from './band'
4import { registerEnforcer } from './enforcer'
5import { registerGuard } from './guard'
6import { registerPane } from './pane'
7import {
8 EMPTY,
9 EVIDENCE_DIR,
10 NO_VOYAGE,
11 VOYAGE_FILE,
12 changedKeys,
13 evidenceNames,
14 hasStatusFormatter,
15 isShipitPath,
16 makerModelOf,
17 pathUnder,
18 resetSeams,
19 settle,
20 snapshotOf,
21 statusText,
22} from './state'
23import type { Disk, Snapshot } from './state'
24import { isOwned, parseProgress, parseVoyage } from './voyage'
25
26// The shipit mod's entry. This file keeps the `$.state` snapshot of the voyage
27// this session owns current and applies the `maker_model` option; guard,
28// enforcer, band and pane each register from their own file. The mod reads
29// `.shipit/`; it never writes there.
30
31const VOYAGE = { plugin: 'shipit', key: 'voyage' } as const
32const PROGRESS = { plugin: 'shipit', key: 'progress' } as const
33const EVIDENCE = { plugin: 'shipit', key: 'evidence' } as const
34const PAUSED = { plugin: 'shipit', key: 'paused' } as const
35const NUDGES = { plugin: 'shipit', key: 'nudges' } as const
36
37/**
38 * The mod's state as it stands; never throws. Every `$.state.get` of one
39 * dispatch reads one moment, so a value this dispatch wrote is not seen here.
40 */
41const readSnapshot = async ($: EngineInterface): Promise<Snapshot> => {
42 try {
43 return snapshotOf({
44 voyage: (await $.state.get(VOYAGE)).value,
45 progress: (await $.state.get(PROGRESS)).value,
46 evidence: (await $.state.get(EVIDENCE)).value,
47 paused: (await $.state.get(PAUSED)).value,
48 nudges: (await $.state.get(NUDGES)).value,
49 })
50 } catch {
51 return EMPTY
52 }
53}
54
55/** What `.shipit/voyage.json`, the plan it names and the evidence folder say now; never throws. */
56const readDisk = async ($: EngineInterface): Promise<Disk> => {
57 try {
58 const given = await $.session.root()
59 const root = (await $.fs.stat(given, { resolve: true }).catch(() => undefined))?.realPath ?? given
60 const voyage = parseVoyage(await $.fs.read(pathUnder(root, VOYAGE_FILE)).catch(() => undefined))
61 if (voyage === undefined || !isOwned(voyage, await $.session.id())) return NO_VOYAGE
62
63 // An unreadable plan is progress unknown (null), never zero.
64 const plan = await $.fs.read(pathUnder(root, voyage.activePlan)).catch(() => undefined)
65 const entries = await $.fs.list(pathUnder(root, EVIDENCE_DIR)).catch(() => [])
66 return { voyage, progress: parseProgress(plan) ?? null, evidence: evidenceNames(entries) }
67 } catch {
68 return NO_VOYAGE
69 }
70}
71
72/**
73 * Reads the disk again and writes what changed to `$.state`: the one writer of
74 * `voyage`, `progress` and `evidence`. Never throws, and is never called while
75 * a `ui.render` hook draws. `voyage` is written last, so a reader it wakes
76 * finds the rest current.
77 */
78const refresh = async ($: EngineInterface): Promise<Snapshot> => {
79 try {
80 const before = await readSnapshot($)
81 const now = settle(before, await readDisk($))
82 for (const key of changedKeys(before, now)) {
83 if (key === 'progress') await $.state.set(PROGRESS, now.progress)
84 else if (key === 'evidence') await $.state.set(EVIDENCE, now.evidence)
85 else if (key === 'paused') await $.state.set(PAUSED, now.paused)
86 else if (key === 'nudges') await $.state.set(NUDGES, now.nudges)
87 else await $.state.set(VOYAGE, now.voyage)
88 }
89 if (hasStatusFormatter()) $.ui.status(statusText(now))
90 return now
91 } catch {
92 return EMPTY
93 }
94}
95
96export const register: Register = (on, options) => {
97 resetSeams()
98
99 on('session.start', async ($, e, next) => {
100 await refresh($)
101 return next(e)
102 })
103
104 // A shipit skill is about to run: it may be starting, resuming or taking
105 // over a voyage. Literals (state.ts SKILLS), so `claude plugin validate` lists them.
106 on('skill.prompt', { skill: ['shipit:go', 'shipit:plan', 'shipit:execute', 'shipit:review'] }, async ($, e, next) => {
107 await refresh($)
108 return next(e)
109 })
110
111 // The guard registers BEFORE the observer below. Both hook `tool.call` for
112 // Write and Edit, and when one plugin has two hooks on an event, a failure
113 // in either (a throw, a timeout, a re-entry) is answered by the `.catch` of
114 // the one registered first. That must be the guard's deny, never the
115 // observer's pass-through, or an armed guard would fail open.
116 registerGuard(on, options)
117
118 // An observer, never a gate: the main loop wrote under `.shipit/` (the
119 // voyage record, a plan checkbox), so the snapshot is read again once the
120 // write has run. The call and its result pass through as they were; a write
121 // the guard denied never ran, and nothing is read again for it.
122 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
123 const ran = await next(e)
124 if (e.agentId === undefined && isShipitPath(e.file_path)) await refresh($)
125 return ran
126 }).catch(($, e, next) => next(e))
127
128 // What no Write or Edit showed (a voyage.json removed through the shell,
129 // evidence a Maker wrote) is picked up as each main-loop turn ends. The
130 // refresh comes BEFORE `next`, so enforcer.ts, reading after its own `next`
131 // settles, sees it wherever the two hooks stand.
132 on('turn.complete', async ($, e, next) => {
133 if (e.agentId === undefined) await refresh($)
134 return next(e)
135 })
136
137 // The `maker_model` option decides what shipit:maker runs on. A plugin's
138 // agent arrives as `<plugin>:<name>` (verified), matched exactly.
139 on('agent.spawn', { subagentType: 'shipit:maker' }, ($, e, next) => {
140 const model = makerModelOf(options)
141 return next(model === undefined ? e : { ...e, model })
142 }).catch(($, e, next) => next(e))
143
144 // Enforcer, band, pane: each fills its seams (state.ts) synchronously here.
145 registerEnforcer(on, options)
146 registerBand(on, options)
147 registerPane(on, options)
148}
149hooks/band.tsx 264 lines1// The voyage band and the status line entry.
2//
3// Contract:
4// - `ui.render` on `{ component: 'AbovePrompt' }`: `await readSnapshot($)`
5// (the read subscribes the band, so every state write redraws it; no
6// `$.ui.invalidate`). With `snapshot.voyage` non-null and its phase not
7// `done`, draw one row sized to `e.props.bodyColumns`: phase trail, plan
8// name, `done/total` (`progress` may be null: unknown, not 0/0), paused /
9// awaiting-you markers, and one Button per `listBandActions()` entry
10// (`key`, `label`, `onPress: () => $.ui.open(action.open)`; Task 14
11// contributes the pane's). Otherwise `return next(e)`.
12// - Status line: call `setStatusFormatter(snapshot => text | undefined)`
13// synchronously in `registerBand` with a PURE function (`shipit: execute
14// 3/9`; undefined without an owned voyage or in `done`). register.tsx then
15// calls `$.ui.status(statusText(snapshot))` after every refresh, and the
16// pause writers in enforcer.ts / pane.tsx do after theirs. Do not call
17// `$.ui.status` from the render hook.
18// - `$` stays in this file: `claude plugin validate` refuses a `$` handed to
19// an imported function, and a `$.state` reference that is not a const here.
20// - From './state': snapshotOf, EMPTY, setStatusFormatter, listBandActions.
21// From './voyage': isActivePhase, isGatePhase and the types.
22// - Must NOT: write state while drawing, or draw anything without an owned
23// voyage.
24
25import type { EngineInterface, On, PluginOptions } from 'claude-code'
26
27import { EMPTY, listBandActions, setStatusFormatter, snapshotOf } from './state'
28import type { BandAction, Snapshot } from './state'
29import { isActivePhase, isGatePhase } from './voyage'
30import type { Phase } from './voyage'
31
32// --- provided by Task 10 (state.ts explains why this cannot be imported) ----
33
34const VOYAGE = { plugin: 'shipit', key: 'voyage' } as const
35const PROGRESS = { plugin: 'shipit', key: 'progress' } as const
36const EVIDENCE = { plugin: 'shipit', key: 'evidence' } as const
37const PAUSED = { plugin: 'shipit', key: 'paused' } as const
38const NUDGES = { plugin: 'shipit', key: 'nudges' } as const
39
40/**
41 * The mod's state as it stands; never throws. Every `$.state.get` of one
42 * dispatch reads one moment, so a value this dispatch wrote is not seen here.
43 */
44const readSnapshot = async ($: EngineInterface): Promise<Snapshot> => {
45 try {
46 return snapshotOf({
47 voyage: (await $.state.get(VOYAGE)).value,
48 progress: (await $.state.get(PROGRESS)).value,
49 evidence: (await $.state.get(EVIDENCE)).value,
50 paused: (await $.state.get(PAUSED)).value,
51 nudges: (await $.state.get(NUDGES)).value,
52 })
53 } catch {
54 return EMPTY
55 }
56}
57
58// --- what the band and the status line say (pure) ---------------------------
59
60export type Stage = 'plan' | 'execute' | 'review'
61
62const STAGES = ['plan', 'execute', 'review'] as const
63const STAGE_LABEL: Record<Stage, string> = { plan: 'Plan', execute: 'Execute', review: 'Review' }
64
65export const SEPARATOR = '›'
66export const ELLIPSIS = '…'
67export const UNKNOWN_COUNT = '?/?'
68/** Cells between two parts of the row: the row Box's `gap`. */
69export const GAP = 1
70/** Cells a Button takes beside its label: the terminal's `[ label ]`, the widest form. */
71export const BUTTON_CHROME = 4
72/** A plan name is shown in at least this many cells (ellipsis included), or not at all. */
73export const MIN_NAME = 4
74
75/** The trail stage a phase belongs to; undefined for `done`. */
76export const stageOf = (phase: Phase): Stage | undefined => {
77 switch (phase) {
78 case 'planning':
79 case 'plan_gate':
80 return 'plan'
81 case 'executing':
82 return 'execute'
83 case 'reviewing':
84 case 'ship_gate':
85 return 'review'
86 default:
87 return undefined
88 }
89}
90
91/** What there is to say about a snapshot; undefined without an owned voyage or in `done`. */
92export type BandFacts = {
93 stage: Stage
94 planName: string
95 /** `done/total`; undefined when progress is unknown (never `0/0` for unknown). */
96 count: string | undefined
97 isPaused: boolean
98 isAwaiting: boolean
99}
100
101export const bandFacts = (snapshot: Snapshot): BandFacts | undefined => {
102 const { voyage, progress } = snapshot
103 if (voyage === null || !isActivePhase(voyage.phase)) return undefined
104 const stage = stageOf(voyage.phase)
105 if (stage === undefined) return undefined
106 return {
107 stage,
108 planName: voyage.planName,
109 count: progress === null ? undefined : `${progress.done}/${progress.total}`,
110 isPaused: snapshot.paused,
111 isAwaiting: isGatePhase(voyage.phase) || voyage.awaitingUser,
112 }
113}
114
115/**
116 * The status line entry: `shipit: execute 3/9`, with `(paused)`, `(awaiting
117 * you)` or both after it; the count is left out when progress is unknown.
118 * Undefined (which clears the entry) without an owned voyage or in `done`.
119 */
120export const formatStatus = (snapshot: Snapshot): string | undefined => {
121 const facts = bandFacts(snapshot)
122 if (facts === undefined) return undefined
123 const markers = [facts.isPaused ? 'paused' : '', facts.isAwaiting ? 'awaiting you' : ''].filter(marker => marker !== '')
124 return ['shipit:', facts.stage, facts.count ?? '', markers.length === 0 ? '' : `(${markers.join(', ')})`]
125 .filter(word => word !== '')
126 .join(' ')
127}
128
129// --- the row's layout (pure) ------------------------------------------------
130
131export type Emphasis = 'strong' | 'dim' | 'plain'
132
133/** One cell-run of the row, left to right; `id` says what it is. */
134export type BandPart =
135 | { kind: 'text'; id: string; text: string; emphasis: Emphasis }
136 | { kind: 'button'; id: string; text: string; action: BandAction }
137
138const cells = (text: string): number => [...text].length
139
140export const partWidth = (part: BandPart): number => cells(part.text) + (part.kind === 'button' ? BUTTON_CHROME : 0)
141
142/** Cells the parts take as one row, gaps included. */
143export const rowWidth = (parts: readonly BandPart[]): number =>
144 parts.reduce((sum, part) => sum + partWidth(part), 0) + Math.max(0, parts.length - 1) * GAP
145
146/** `text` in at most `width` cells, its end replaced by an ellipsis when cut. */
147export const truncate = (text: string, width: number): string => {
148 const chars = [...text]
149 if (chars.length <= width) return text
150 if (width <= 0) return ''
151 return width === 1 ? ELLIPSIS : `${chars.slice(0, width - 1).join('')}${ELLIPSIS}`
152}
153
154type Shape = {
155 trail: 'full' | 'current'
156 hasName: boolean
157 markers: 'long' | 'short' | 'none'
158 hasButtons: boolean
159 hasCount: boolean
160}
161
162// What gives way as the row narrows, first to last: the plan name is cut with
163// an ellipsis (down to MIN_NAME cells), the trail collapses to the current
164// stage (the name gets its room back), the name goes, the markers shorten,
165// the Buttons go, the markers go, the count goes. Under that the stage word
166// itself is cut to the width.
167const SHAPES: readonly Shape[] = [
168 { trail: 'full', hasName: true, markers: 'long', hasButtons: true, hasCount: true },
169 { trail: 'current', hasName: true, markers: 'long', hasButtons: true, hasCount: true },
170 { trail: 'current', hasName: false, markers: 'long', hasButtons: true, hasCount: true },
171 { trail: 'current', hasName: false, markers: 'short', hasButtons: true, hasCount: true },
172 { trail: 'current', hasName: false, markers: 'short', hasButtons: false, hasCount: true },
173 { trail: 'current', hasName: false, markers: 'none', hasButtons: false, hasCount: true },
174 { trail: 'current', hasName: false, markers: 'none', hasButtons: false, hasCount: false },
175]
176
177const text = (id: string, value: string, emphasis: Emphasis): BandPart => ({ kind: 'text', id, text: value, emphasis })
178
179const trailParts = (facts: BandFacts, trail: Shape['trail']): BandPart[] =>
180 trail === 'current'
181 ? [text(`stage-${facts.stage}`, STAGE_LABEL[facts.stage], 'strong')]
182 : STAGES.flatMap((stage, index) => [
183 ...(index === 0 ? [] : [text(`separator-${index}`, SEPARATOR, 'dim')]),
184 text(`stage-${stage}`, STAGE_LABEL[stage], stage === facts.stage ? 'strong' : 'dim'),
185 ])
186
187const markerParts = (facts: BandFacts, markers: Shape['markers']): BandPart[] =>
188 markers === 'none'
189 ? []
190 : [
191 ...(facts.isPaused ? [text('paused', markers === 'long' ? 'paused' : '||', 'strong')] : []),
192 ...(facts.isAwaiting ? [text('awaiting', markers === 'long' ? 'awaiting you' : 'you?', 'strong')] : []),
193 ]
194
195const shaped = (facts: BandFacts, shape: Shape, actions: readonly BandAction[], nameWidth: number): BandPart[] => [
196 ...trailParts(facts, shape.trail),
197 ...(nameWidth > 0 ? [text('name', truncate(facts.planName, nameWidth), 'plain')] : []),
198 ...(shape.hasCount ? [text('count', facts.count ?? UNKNOWN_COUNT, 'plain')] : []),
199 ...markerParts(facts, shape.markers),
200 ...(shape.hasButtons
201 ? actions.map((action): BandPart => ({ kind: 'button', id: action.key, text: action.label, action }))
202 : []),
203]
204
205/**
206 * The band's one row for `snapshot` in `columns` cells: its parts left to
207 * right, never wider than `columns` (`rowWidth`). Empty when there is nothing
208 * to draw: no owned voyage, `done`, or no room at all.
209 */
210export const layoutBand = (snapshot: Snapshot, columns: number, actions: readonly BandAction[] = []): BandPart[] => {
211 const facts = bandFacts(snapshot)
212 const room = Number.isFinite(columns) ? Math.floor(columns) : 0
213 if (facts === undefined || room < 1) return []
214 const nameCells = cells(facts.planName)
215
216 for (const shape of SHAPES) {
217 const bare = shaped(facts, shape, actions, 0)
218 if (!shape.hasName || nameCells === 0) {
219 if (rowWidth(bare) <= room) return bare
220 continue
221 }
222 const nameRoom = Math.min(nameCells, room - rowWidth(bare) - GAP)
223 if (nameRoom >= Math.min(nameCells, MIN_NAME)) return shaped(facts, shape, actions, nameRoom)
224 }
225
226 return [text(`stage-${facts.stage}`, truncate(STAGE_LABEL[facts.stage], room), 'strong')]
227}
228
229// --- the hooks --------------------------------------------------------------
230
231export const registerBand = (on: On, _options: PluginOptions): void => {
232 setStatusFormatter(formatStatus)
233
234 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
235 if (e.props.hasSurvey) return next(e)
236
237 // Read at render time: the pane registers its Button after this function ran.
238 const parts = layoutBand(await readSnapshot($), e.props.bodyColumns, listBandActions())
239 if (parts.length === 0) return next(e)
240
241 const { Box, Button, Text } = $.ui.resolve(e)
242
243 return (
244 <Box flexDirection="row" flexWrap="nowrap" gap={GAP} overflow="hidden">
245 {parts.map(part =>
246 part.kind === 'button' ? (
247 <Button
248 key={part.id}
249 label={part.text}
250 onPress={() => {
251 void $.ui.open(part.action.open)
252 }}
253 />
254 ) : (
255 <Text bold={part.emphasis === 'strong'} dimColor={part.emphasis === 'dim'} wrap="truncate-end">
256 {part.text}
257 </Text>
258 ),
259 )}
260 </Box>
261 )
262 })
263}
264hooks/enforcer.ts 293 lines1// The continuation enforcer and the `/shipit` command.
2//
3// Contract (VOYAGE.md sections 6 and 8):
4// - `turn.complete`: `const ran = await next(e)` FIRST, then judge, then
5// `return ran`. register.tsx refreshes the snapshot in its own
6// `turn.complete` hook before it calls `next`, so whichever of the two hooks
7// is outermost, the snapshot read after `next` settles is current.
8// - Nudge only when: `e.agentId` is absent, `e.reason === 'answer'`,
9// `isEnforcerEnabled(options)`, `snapshot.voyage` is non-null (owned),
10// `isEnforcerPhase(phase)`, `!voyage.awaitingUser`, `!snapshot.paused`,
11// `workRemains(voyage, progress)` and `snapshot.nudges < MAX_NUDGES`. The
12// nudge is `void $.prompt.submit({ text })` (never awaited, never `asUser`)
13// naming `nextTask(progress)` or the `pendingVerdicts(voyage.verdicts)`,
14// then `setNudges($, snapshot.nudges + 1)`. On reaching MAX_NUDGES, toast
15// once that the enforcer stood down.
16// - Resetting the count is NOT yours: register.tsx's refresh zeroes `nudges`
17// whenever the phase, done count or a verdict moved, and when the voyage
18// ends; `setPaused($, false)` zeroes it on resume.
19// - Register the `COMMAND` (`shipit`) command in `session.start`; answer
20// `command.run` for status (default) | pause | resume | end. pause / resume
21// are `setPaused` below. `end` asks the model through `$.prompt.submit` to
22// close the voyage per VOYAGE.md: the mod deletes nothing. Any other first
23// word: `findSubcommand(word)?.run(rest, snapshot, e)`; when the answer
24// carries `open`, `await $.ui.open(answer.open)`, then return its `text`
25// (Task 14 contributes `pane` this way). List `listSubcommands()` in the
26// usage text.
27// - `$` stays in this file: `claude plugin validate` refuses a `$` handed to
28// an imported function, and a `$.state` reference that is not a const here.
29// - From './state': snapshotOf, EMPTY, MAX_NUDGES, COMMAND, isEnforcerEnabled,
30// findSubcommand, listSubcommands, hasStatusFormatter, statusText.
31// From './voyage': isEnforcerPhase, workRemains, nextTask, pendingVerdicts.
32// - Must NOT: nudge in planning, plan_gate, ship_gate or done, or after
33// `aborted` / `refusal` / `error`; write `voyage`, `progress` or `evidence`
34// state; write or delete anything under `.shipit/`.
35
36import type { CommandRunInput, EngineInterface, On, PluginOptions } from 'claude-code'
37
38import {
39 COMMAND,
40 EMPTY,
41 MAX_NUDGES,
42 VOYAGE_FILE,
43 findSubcommand,
44 hasStatusFormatter,
45 isEnforcerEnabled,
46 isGuardEnabled,
47 listSubcommands,
48 snapshotOf,
49 statusText,
50} from './state'
51import type { Snapshot, Subcommand, SubcommandAnswer } from './state'
52import { VERDICT_KEYS, isEnforcerPhase, nextTask, pendingVerdicts, readyTasks, workRemains } from './voyage'
53
54// --- provided by Task 10 (state.ts explains why this cannot be imported) ----
55
56const VOYAGE = { plugin: 'shipit', key: 'voyage' } as const
57const PROGRESS = { plugin: 'shipit', key: 'progress' } as const
58const EVIDENCE = { plugin: 'shipit', key: 'evidence' } as const
59const PAUSED = { plugin: 'shipit', key: 'paused' } as const
60const NUDGES = { plugin: 'shipit', key: 'nudges' } as const
61
62/**
63 * The mod's state as it stands; never throws. Every `$.state.get` of one
64 * dispatch reads one moment, so a value this dispatch wrote is not seen here.
65 */
66const readSnapshot = async ($: EngineInterface): Promise<Snapshot> => {
67 try {
68 return snapshotOf({
69 voyage: (await $.state.get(VOYAGE)).value,
70 progress: (await $.state.get(PROGRESS)).value,
71 evidence: (await $.state.get(EVIDENCE)).value,
72 paused: (await $.state.get(PAUSED)).value,
73 nudges: (await $.state.get(NUDGES)).value,
74 })
75 } catch {
76 return EMPTY
77 }
78}
79
80/**
81 * `/shipit pause` and `/shipit resume`: writes the flag, zeroes the nudge
82 * count on resume (VOYAGE.md section 6), brings the status line up to date and
83 * answers the snapshot as it now stands.
84 */
85const setPaused = async ($: EngineInterface, paused: boolean): Promise<Snapshot> => {
86 const before = await readSnapshot($)
87 await $.state.set(PAUSED, paused)
88 if (!paused) await $.state.set(NUDGES, 0)
89 const now: Snapshot = { ...before, paused, nudges: paused ? before.nudges : 0 }
90 if (hasStatusFormatter()) $.ui.status(statusText(now))
91 return now
92}
93
94/** Sets the nudge count: a whole number, zero or more. */
95const setNudges = async ($: EngineInterface, nudges: number): Promise<void> => {
96 await $.state.set(NUDGES, Math.max(0, Math.trunc(nudges)))
97}
98
99// --- the enforcer (pure) ------------------------------------------------------
100
101/** What of a `turn.complete` input the enforcer judges by. */
102export type TurnEnd = { reason: string; agentId?: string | undefined; isAborted?: boolean | undefined }
103
104/**
105 * VOYAGE.md section 8, plus the bound: a main-loop turn that ended on an
106 * answer, the enforcer on, an owned voyage in `executing` or `reviewing` that
107 * is neither waiting for the user nor paused, work left, and fewer than
108 * MAX_NUDGES nudges since anything last moved.
109 */
110export const shouldNudge = (turn: TurnEnd, isEnabled: boolean, snapshot: Snapshot): boolean => {
111 const { voyage, progress, paused, nudges } = snapshot
112 return (
113 turn.agentId === undefined &&
114 turn.reason === 'answer' &&
115 turn.isAborted !== true &&
116 isEnabled &&
117 voyage !== null &&
118 isEnforcerPhase(voyage.phase) &&
119 !voyage.awaitingUser &&
120 !paused &&
121 workRemains(voyage, progress) &&
122 nudges < MAX_NUDGES
123 )
124}
125
126const WAY_OUT = `If you are genuinely blocked on the user, set "awaiting_user": true in ${VOYAGE_FILE} and ask them; otherwise keep going without asking.`
127
128/** The continue message for a snapshot `shouldNudge` passed; `count` is this nudge's number. */
129export const nudgeText = (snapshot: Snapshot, count: number): string => {
130 const { voyage, progress } = snapshot
131 if (voyage === null) return ''
132 const tail = `${WAY_OUT} (Automatic shipit nudge ${count} of ${MAX_NUDGES}.)`
133
134 if (voyage.phase === 'reviewing') {
135 const pending = pendingVerdicts(voyage.verdicts).map(key => `${key} (${voyage.verdicts[key] ?? 'not run'})`)
136 return `The shipit voyage "${voyage.planName}" is still in review and the turn ended. Verdicts not yet APPROVE: ${pending.join(', ')}. Continue the review per shipit:review: run the dimensions not yet run, fix what a REJECT named and run those again. ${tail}`
137 }
138
139 const task = nextTask(progress)
140 const counts = progress === null ? '' : ` (${progress.done}/${progress.total} tasks checked)`
141 const next = task === undefined ? 'the next unchecked task' : `the next unchecked task: ${task.id}. ${task.title}`
142 return `The shipit voyage "${voyage.planName}" is still executing${counts} and the turn ended. Continue with ${next}. ${tail}`
143}
144
145export const STAND_DOWN = `shipit: ${MAX_NUDGES} nudges without progress, so the enforcer stood down. /${COMMAND} resume (or any progress) re-arms it.`
146
147// --- the /shipit command (pure) -------------------------------------------------
148
149export const COMMAND_DESCRIPTION = 'Show, pause, resume or end the shipit voyage'
150export const COMMAND_HINT = '[status|pause|resume|end]'
151
152/** The first word of a command's arguments, lower case (`status` when there is none), and what follows it. */
153export const parseArgs = (args: string): { word: string; rest: string } => {
154 const text = args.trim()
155 const cut = text.search(/\s/)
156 const word = (cut < 0 ? text : text.slice(0, cut)).toLowerCase()
157 return { word: word === '' ? 'status' : word, rest: cut < 0 ? '' : text.slice(cut).trim() }
158}
159
160const NO_VOYAGE_TEXT = 'No shipit voyage is owned by this session.'
161
162const onOff = (isOn: boolean): string => (isOn ? 'on' : 'off')
163
164/** `/shipit status`: the voyage as the mod last read it. */
165export const statusReport = (snapshot: Snapshot, options: PluginOptions): string => {
166 const { voyage, progress, paused, nudges } = snapshot
167 const switches = `Enforcer: ${onOff(isEnforcerEnabled(options))} · Guard: ${onOff(isGuardEnabled(options))}`
168 if (voyage === null) return `${NO_VOYAGE_TEXT} Start one with /shipit:go.\n${switches}`
169
170 const lines = [`Voyage: ${voyage.planName} (${voyage.activePlan})`, `Phase: ${voyage.phase}`]
171 if (progress === null) {
172 lines.push('Tasks: unknown (the plan could not be read)')
173 } else {
174 const ready = readyTasks(progress).length
175 const task = nextTask(progress)
176 const next = task === undefined ? 'none left' : `${task.id}. ${task.title}`
177 lines.push(
178 `Tasks: ${progress.done}/${progress.total} checked · ${ready} ready · ${progress.total - progress.done - ready} blocked · next: ${next}`,
179 )
180 }
181 if (voyage.phase === 'reviewing' || voyage.phase === 'ship_gate') {
182 lines.push(`Verdicts: ${VERDICT_KEYS.map(key => `${key} ${voyage.verdicts[key] ?? 'not run'}`).join(' · ')}`)
183 }
184 lines.push(
185 `Paused: ${paused ? 'yes (guard and enforcer suspended)' : 'no'} · Awaiting user: ${voyage.awaitingUser ? 'yes' : 'no'} · Nudges: ${nudges}/${MAX_NUDGES}${nudges >= MAX_NUDGES ? ' (enforcer stood down)' : ''}`,
186 switches,
187 )
188 return lines.join('\n')
189}
190
191/** What `/shipit pause` and `/shipit resume` answer; `wasPaused` is the flag before the command. */
192export const pauseText = (snapshot: Snapshot, wasPaused: boolean): string => {
193 const name = snapshot.voyage?.planName ?? ''
194 if (snapshot.paused) {
195 return `${wasPaused ? 'Already paused' : 'Paused'}: the guard and the enforcer are suspended for voyage "${name}". /${COMMAND} resume turns them back on.`
196 }
197 return `${wasPaused ? 'Resumed' : 'Not paused'}: the guard and the enforcer are active for voyage "${name}" and the nudge count is 0.`
198}
199
200/** The prompt `/shipit end` hands the model: the model closes the voyage, the mod deletes nothing. */
201export const endPrompt = (snapshot: Snapshot): string => {
202 const { voyage, progress } = snapshot
203 if (voyage === null) return ''
204 const checked = progress === null ? 'how many tasks are checked in it' : `the tasks checked (${progress.done}/${progress.total} when last read)`
205 return `The user ran /${COMMAND} end. Close the shipit voyage "${voyage.planName}" now, as VOYAGE.md's abandon transition says: remove ${VOYAGE_FILE} and nothing else (the plan, evidence and notepads stay), then report the plan path (${voyage.activePlan}) and ${checked}. Start no further tasks.`
206}
207
208/** How long after `/shipit end` answers its request reaches the model, in milliseconds. */
209export const END_DELAY_MS = 50
210
211export const END_FAILED = `shipit: the request to end the voyage could not be sent. Ask Claude to remove ${VOYAGE_FILE}.`
212
213/** A contributed subcommand's answer (state.ts `addSubcommand`), or undefined when nothing answers to `word`. */
214export const runSubcommand = (
215 word: string,
216 rest: string,
217 snapshot: Snapshot,
218 e: CommandRunInput,
219): SubcommandAnswer | undefined => findSubcommand(word)?.run(rest, snapshot, e)
220
221export const usageText = (word: string, contributed: readonly Pick<Subcommand, 'name' | 'summary'>[]): string =>
222 [
223 `Unknown subcommand "${word}". Usage: /${COMMAND} [${['status', 'pause', 'resume', 'end', ...contributed.map(one => one.name)].join('|')}]`,
224 ' status the voyage, its progress and switches (the default)',
225 ' pause suspend the guard and the enforcer',
226 ' resume turn them back on and re-arm the enforcer',
227 ' end ask Claude to close the voyage',
228 ...contributed.map(one => ` ${one.name} ${one.summary}`),
229 ].join('\n')
230
231// --- hooks ----------------------------------------------------------------------
232
233export const registerEnforcer = (on: On, options: PluginOptions): void => {
234 // The matchers are there because register.tsx hooks both events bare, and a
235 // module may hook an event without a matcher only once: every session has a
236 // `cwd`, and the enforcer wants the turns that ended on an answer anyway.
237 on('session.start', { cwd: /^/ }, async ($, e, next) => {
238 // The name is a literal (state.ts COMMAND), so `claude plugin validate` reads
239 // the `command.run` hook below as answering this module's own command.
240 await $.command
241 .register({ name: 'shipit', description: COMMAND_DESCRIPTION, argumentHint: COMMAND_HINT })
242 .catch(() => undefined)
243 return next(e)
244 })
245
246 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
247 const ran = await next(e)
248 try {
249 const snapshot = await readSnapshot($)
250 if (shouldNudge(e, isEnforcerEnabled(options), snapshot)) {
251 const count = snapshot.nudges + 1
252 await setNudges($, count)
253 // Never awaited: it resolves as the nudged turn starts, after this one ends.
254 void $.prompt.submit({ text: nudgeText(snapshot, count) }).catch(() => undefined)
255 if (count >= MAX_NUDGES) $.ui.toast(STAND_DOWN)
256 }
257 } catch {
258 // The turn's own result stands whatever the enforcer met.
259 }
260 return ran
261 })
262
263 on('command.run', { command: 'shipit' }, async ($, e) => {
264 const { word, rest } = parseArgs(e.args)
265 const snapshot = await readSnapshot($)
266
267 if (word === 'status') return { text: statusReport(snapshot, options) }
268
269 if (word === 'pause' || word === 'resume') {
270 if (snapshot.voyage === null) return { text: `${NO_VOYAGE_TEXT} Nothing to ${word}.` }
271 return { text: pauseText(await setPaused($, word === 'pause'), snapshot.paused) }
272 }
273
274 if (word === 'end') {
275 if (snapshot.voyage === null) return { text: `${NO_VOYAGE_TEXT} Nothing to end.` }
276 // `$.prompt.submit` is refused inside a `command.run` hook (its turn
277 // would wait on the command this hook is holding), so the request goes
278 // out from a timer, once the command has answered.
279 const text = endPrompt(snapshot)
280 $.clock.after(END_DELAY_MS, () => {
281 void $.prompt.submit({ text }).catch(() => $.ui.toast(END_FAILED))
282 })
283 return { text: `Asking Claude to close voyage "${snapshot.voyage.planName}" (it removes ${VOYAGE_FILE}; the plan stays).` }
284 }
285
286 const answer = runSubcommand(word, rest, snapshot, e)
287 if (answer === undefined) return { text: usageText(word, listSubcommands()) }
288 const { open, ...result } = answer
289 if (open !== undefined) await $.ui.open(open)
290 return result
291 })
292}
293hooks/guard.ts 202 lines1// The write-guard: while this session owns a voyage in an active phase and is
2// not paused, the main loop may write only inside the project's `.shipit/`
3// (and, while planning, the glossary and ADR files). Source changes go through
4// a `shipit:maker` subagent, which is never guarded.
5//
6// Contract (VOYAGE.md section 7):
7// - Hook `tool.call` for Write, Edit, NotebookEdit (this build has no
8// MultiEdit), registered with
9// `.catch(($, e, next) => next.called ? next(e) : { deny })`.
10// - Pass through (`next(e)`) when `e.agentId` is set, `!isGuardEnabled(options)`,
11// or `readSnapshot($)` is paused, has no `voyage`, or its phase is not
12// `isActivePhase`. `snapshot.voyage` is non-null only for a voyage this
13// session owns, so ownership needs no further check.
14// - Otherwise allow only real paths under the resolved project root's
15// `.shipit/` (`$.fs.stat(path, { resolve: true }).realPath`, the root
16// resolved the same way; a path that does not exist yet through its nearest
17// existing ancestor, as plugins/zap/hooks/register.ts does), plus the
18// glossary / ADR paths while `isPlanningPhase(phase)`; deny the rest with a
19// message naming the phase, the path and the remedy (`shipit:maker`, or
20// `/shipit pause`).
21// - `$` stays in this file: `claude plugin validate` refuses a `$` handed to
22// an imported function, and a `$.state` reference that is not a const here.
23// - From './state': snapshotOf, EMPTY, isGuardEnabled, SHIPIT_DIR, COMMAND,
24// MAKER_AGENT. From './voyage': isActivePhase, isPlanningPhase.
25// - Must NOT: write state, re-read voyage.json itself (register.tsx keeps the
26// snapshot current after `.shipit/` writes and at each turn's end),
27// intercept Bash, match paths by substring, or write under `.shipit/`.
28
29import type { EngineInterface, On, PluginOptions } from 'claude-code'
30
31import { COMMAND, EMPTY, MAKER_AGENT, SHIPIT_DIR, isGuardEnabled, snapshotOf } from './state'
32import type { Snapshot } from './state'
33import { isActivePhase, isPlanningPhase } from './voyage'
34import type { Phase } from './voyage'
35
36// --- provided by Task 10 (state.ts explains why this cannot be imported) ----
37
38const VOYAGE = { plugin: 'shipit', key: 'voyage' } as const
39const PROGRESS = { plugin: 'shipit', key: 'progress' } as const
40const EVIDENCE = { plugin: 'shipit', key: 'evidence' } as const
41const PAUSED = { plugin: 'shipit', key: 'paused' } as const
42const NUDGES = { plugin: 'shipit', key: 'nudges' } as const
43
44/**
45 * The mod's state as it stands; never throws. Every `$.state.get` of one
46 * dispatch reads one moment, so a value this dispatch wrote is not seen here.
47 */
48const readSnapshot = async ($: EngineInterface): Promise<Snapshot> => {
49 try {
50 return snapshotOf({
51 voyage: (await $.state.get(VOYAGE)).value,
52 progress: (await $.state.get(PROGRESS)).value,
53 evidence: (await $.state.get(EVIDENCE)).value,
54 paused: (await $.state.get(PAUSED)).value,
55 nudges: (await $.state.get(NUDGES)).value,
56 })
57 } catch {
58 return EMPTY
59 }
60}
61
62// --- Task 11 ----------------------------------------------------------------
63
64const GLOSSARY = 'CONTEXT.md'
65const CONTEXT_MAP = 'CONTEXT-MAP.md'
66const ADR_PARENT = 'docs'
67const ADR_DIR = 'adr'
68const MAX_DEPTH = 256
69
70const remedy = `Delegate the change to a ${MAKER_AGENT} subagent, or run /${COMMAND} pause to suspend the guard.`
71
72const allowedIn = (phase: Phase): string =>
73 isPlanningPhase(phase)
74 ? `${SHIPIT_DIR}/, ${GLOSSARY} files, ${CONTEXT_MAP} and ${ADR_PARENT}/${ADR_DIR}/`
75 : `${SHIPIT_DIR}/`
76
77const refusal = (path: string, phase: Phase, why: string): string =>
78 `shipit: ${path} was not written (${why}). The voyage is in phase "${phase}", ` +
79 `where the main loop may write only ${allowedIn(phase)} under the project root. ${remedy}`
80
81const separatorOf = (root: string): '/' | '\\' => (root.includes('/') ? '/' : '\\')
82
83const segmentsOf = (path: string, sep: string): string[] => path.split(sep === '\\' ? /[\\/]/ : /\//)
84
85const lastCut = (path: string, sep: string): number =>
86 sep === '\\' ? Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) : path.lastIndexOf('/')
87
88const trimEnd = (path: string, sep: string): string => path.replace(sep === '\\' ? /[\\/]+$/ : /\/+$/, '')
89
90const isAbsolute = (path: string): boolean => /^([\\/]|[A-Za-z]:[\\/])/.test(path)
91
92const realOf = async ($: EngineInterface, path: string) => $.fs.stat(path, { resolve: true }).catch(() => undefined)
93
94/**
95 * Where `path` lands once every symbolic link is followed, or undefined when
96 * that cannot be said: the path itself if it exists, else its nearest existing
97 * ancestor with the missing names appended. Never placed: a relative path, a
98 * `..` segment (the tool and the file system fold it differently around a
99 * link), a network or drive-relative spelling, a link that leads nowhere.
100 */
101const place = async ($: EngineInterface, path: string, sep: string): Promise<string | undefined> => {
102 const isPlaceable =
103 isAbsolute(path) && !/^[\\/][\\/]/.test(path) && !segmentsOf(path, sep).includes('..')
104 if (!isPlaceable) return undefined
105
106 const missing: string[] = []
107 let head = path
108 for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
109 const stat = await realOf($, head)
110 if (stat !== undefined) {
111 if (stat.realPath === undefined) return undefined
112 return [trimEnd(stat.realPath, sep), ...missing].join(sep)
113 }
114 const trimmed = trimEnd(head, sep)
115 const cut = lastCut(trimmed, sep)
116 if (cut < 0) return undefined
117 const name = trimmed.slice(cut + 1)
118 if (name === '' || /^[A-Za-z]:/.test(name)) return undefined
119 if (name !== '.') missing.unshift(name)
120 head = trimmed.slice(0, cut + 1)
121 }
122 return undefined
123}
124
125/**
126 * The segments of `real` beneath `root` (both resolved), or undefined when it
127 * is not beneath it. Whole segments only: `/proj-evil/x` is not under `/proj`.
128 */
129const segmentsUnder = (real: string, root: string, sep: string): string[] | undefined => {
130 const base = trimEnd(root, sep)
131 if (!real.startsWith(base + sep)) return undefined
132 const segments = segmentsOf(real.slice(base.length + 1), sep)
133 const isClean = segments.every(segment => segment !== '' && segment !== '.' && segment !== '..')
134 return isClean ? segments : undefined
135}
136
137/** The allow-list of VOYAGE.md section 7, on whole path segments beneath the root. */
138const isAllowed = (segments: readonly string[], phase: Phase): boolean => {
139 // `.shipit/**`: the root's own `.shipit`, and something inside it.
140 if (segments[0] === SHIPIT_DIR && segments.length > 1) return true
141 if (!isPlanningPhase(phase)) return false
142
143 const name = segments[segments.length - 1]
144 // `CONTEXT.md` and `**/CONTEXT.md`.
145 if (name === GLOSSARY) return true
146 // `CONTEXT-MAP.md`, at the root only.
147 if (name === CONTEXT_MAP && segments.length === 1) return true
148 // `docs/adr/**` and `**/docs/adr/**`: a `docs` then `adr` pair above the file.
149 return segments.some(
150 (segment, at) => segment === ADR_PARENT && segments[at + 1] === ADR_DIR && at + 2 < segments.length,
151 )
152}
153
154/** The path a guarded call writes: `notebook_path` for NotebookEdit, else `file_path`. */
155const pathOf = (e: { tool: string; file_path?: unknown; notebook_path?: unknown }): unknown =>
156 e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
157
158/**
159 * Why the main loop may not write `path` in `phase`, or undefined when it may.
160 * Throws when the root cannot be resolved: the caller refuses on that too.
161 */
162const judge = async ($: EngineInterface, path: unknown, phase: Phase): Promise<string | undefined> => {
163 if (typeof path !== 'string') throw new Error('shipit: the call names no path')
164
165 const root = (await $.fs.stat(await $.session.root(), { resolve: true })).realPath
166 if (root === undefined) throw new Error('shipit: the project root does not resolve')
167 const sep = separatorOf(root)
168
169 const real = await place($, path, sep)
170 if (real === undefined) return refusal(path, phase, 'its location cannot be resolved')
171
172 const segments = segmentsUnder(real, root, sep)
173 if (segments === undefined) return refusal(path, phase, 'it is outside the project root')
174 return isAllowed(segments, phase) ? undefined : refusal(path, phase, 'it is not on the allow-list')
175}
176
177const unjudged = (path: unknown): string =>
178 `shipit: ${String(path)} was not written (the write guard could not judge it, so it was refused). ${remedy}`
179
180export const registerGuard = (on: On, options: PluginOptions): void => {
181 on('tool.call', { tool: ['Write', 'Edit', 'NotebookEdit'] }, async ($, e, next) => {
182 // Decided before anything can throw: none of these is an armed guard.
183 if (e.agentId !== undefined) return next(e)
184 if (!isGuardEnabled(options)) return next(e)
185 const { voyage, paused } = await readSnapshot($)
186 if (paused || voyage === null || !isActivePhase(voyage.phase)) return next(e)
187
188 // Armed from here on, and judged before `next`. A judgment that fails is
189 // refused HERE, not left to `.catch`: register.tsx's observer hooks this
190 // event too, with a handler that passes through, and of one plugin's
191 // handlers on an event the first registered answers a throw (verified).
192 const path = pathOf(e)
193 const why = await judge($, path, voyage.phase).catch(() => unjudged(path))
194 return why === undefined ? next(e) : { deny: why }
195 }).catch(($, e, next) =>
196 // Reached with the hook unjudged (it ran out of time, or the call rose
197 // beneath one of its own `$` calls, where `$` rejects): decided from `e`
198 // and `options` alone. What is never guarded passes; the rest is refused.
199 next.called || e.agentId !== undefined || !isGuardEnabled(options) ? next(e) : { deny: unjudged(pathOf(e)) },
200 )
201}
202hooks/pane.tsx 280 lines1// The voyage pane: the plan, its tasks, the F1-F4 verdicts and the evidence
2// file names of the voyage this session owns, with a Pause / Resume button.
3//
4// Contract:
5// - Open only on a user action, through the two seams, both filled
6// synchronously in `registerPane`:
7// `addSubcommand({ name: 'pane', summary, run: () => ({ text, open: { id: PANE_ID, title } }) })`
8// (Task 12's `/shipit` dispatcher calls `$.ui.open(answer.open)`) and
9// `addBandAction({ key: 'pane', label, open: { id: PANE_ID, title } })`
10// (Task 13's band draws the Button and opens on press). Never open from
11// `session.start`.
12// - `ui.render` on `{ component: 'Pane', requestId: PANE_ID }`:
13// `await readSnapshot($)` and draw the header (plan, phase), each task with
14// its state and `waitingOn(task, progress)`, the F1-F4 `voyage.verdicts`,
15// and `snapshot.evidence` (the file names in `.shipit/evidence/`, listed by
16// register.tsx's refresh, never while drawing). No owned voyage: the empty
17// state naming `/shipit:go`.
18// - Pause / Resume button: `onPress: () => togglePaused($)` below, the same
19// state `/shipit pause|resume` writes.
20// - `$` stays in this file: `claude plugin validate` refuses a `$` handed to
21// an imported function, and a `$.state` reference that is not a const here.
22// - From './state': snapshotOf, EMPTY, PANE_ID, addSubcommand, addBandAction,
23// hasStatusFormatter, statusText. From './voyage': waitingOn, VERDICT_KEYS
24// and the types.
25// - Must NOT: write state while drawing, read or render file contents, call
26// `$.fs` from the render hook, or write `voyage` / `progress` / `evidence`.
27
28import type { EngineInterface, On, PluginOptions } from 'claude-code'
29
30import { EMPTY, PANE_ID, addBandAction, addSubcommand, hasStatusFormatter, snapshotOf, statusText } from './state'
31import type { Snapshot } from './state'
32import { VERDICT_KEYS, isGatePhase, waitingOn } from './voyage'
33import type { Phase, TaskState, VerdictKey } from './voyage'
34
35// --- provided by Task 10 (state.ts explains why this cannot be imported) ----
36
37const VOYAGE = { plugin: 'shipit', key: 'voyage' } as const
38const PROGRESS = { plugin: 'shipit', key: 'progress' } as const
39const EVIDENCE = { plugin: 'shipit', key: 'evidence' } as const
40const PAUSED = { plugin: 'shipit', key: 'paused' } as const
41const NUDGES = { plugin: 'shipit', key: 'nudges' } as const
42
43/**
44 * The mod's state as it stands; never throws. Every `$.state.get` of one
45 * dispatch reads one moment, so a value this dispatch wrote is not seen here.
46 */
47const readSnapshot = async ($: EngineInterface): Promise<Snapshot> => {
48 try {
49 return snapshotOf({
50 voyage: (await $.state.get(VOYAGE)).value,
51 progress: (await $.state.get(PROGRESS)).value,
52 evidence: (await $.state.get(EVIDENCE)).value,
53 paused: (await $.state.get(PAUSED)).value,
54 nudges: (await $.state.get(NUDGES)).value,
55 })
56 } catch {
57 return EMPTY
58 }
59}
60
61/**
62 * The Pause / Resume button's handler: flips the flag from the value held now
63 * (never the one drawn), zeroes the nudge count on resume and brings the
64 * status line up to date. A press that lost a race changes nothing.
65 */
66const togglePaused = async ($: EngineInterface): Promise<Snapshot> => {
67 const before = await readSnapshot($)
68 const held = await $.state.get(PAUSED)
69 const paused = held.value !== true
70 const { isSet } = await $.state.set(PAUSED, paused, { ifVersion: held.version })
71 if (!isSet) return before
72 if (!paused) await $.state.set(NUDGES, 0)
73 const now: Snapshot = { ...before, paused, nudges: paused ? before.nudges : 0 }
74 if (hasStatusFormatter()) $.ui.status(statusText(now))
75 return now
76}
77
78// --- the view model (pure: no `$`) -------------------------------------------
79
80export const PANE_TITLE = 'Voyage'
81/** Evidence names drawn at most; the rest are counted in one closing line. */
82export const MAX_EVIDENCE_ROWS = 200
83
84const PHASE_LABELS: Record<Phase, string> = {
85 planning: 'Planning',
86 plan_gate: 'Plan gate',
87 executing: 'Executing',
88 reviewing: 'Reviewing',
89 ship_gate: 'Ship gate',
90 done: 'Done',
91}
92
93/** VOYAGE.md section 2: what each verdict key reviews. */
94const DIMENSIONS: Record<VerdictKey, string> = {
95 F1: 'Plan Compliance Audit',
96 F2: 'Code Quality Review',
97 F3: 'Real QA Execution',
98 F4: 'Scope Fidelity Check',
99}
100
101export type TaskRow = {
102 id: number
103 title: string
104 state: TaskState
105 /** `waiting on 2, 5` for a blocked task; empty otherwise. */
106 waiting: string
107}
108
109export type VerdictRow = {
110 key: VerdictKey
111 dimension: string
112 verdict: 'APPROVE' | 'REJECT' | 'not run'
113}
114
115export type PaneView =
116 | { kind: 'empty'; lines: string[] }
117 | {
118 kind: 'voyage'
119 planName: string
120 phase: string
121 /** `paused`, `awaiting you`: what holds the voyage still. */
122 markers: string[]
123 /** `3/9 tasks done`, or that progress is unknown. */
124 summary: string
125 /** Null when progress is unknown (the plan cannot be read): no rows. */
126 tasks: TaskRow[] | null
127 verdicts: VerdictRow[]
128 evidence: string[]
129 /** Evidence names beyond the ones listed. */
130 evidenceHidden: number
131 isPaused: boolean
132 buttonLabel: 'Pause' | 'Resume'
133 }
134
135/** What the pane shows for a snapshot. Pure: reads nothing but its argument. */
136export const paneView = (snapshot: Snapshot): PaneView => {
137 const { voyage, progress, evidence, paused } = snapshot
138 if (voyage === null) {
139 return {
140 kind: 'empty',
141 lines: [
142 'No voyage in this session.',
143 'This pane follows a shipit voyage: its plan, each task and what it waits on, the F1-F4 review verdicts and the evidence files.',
144 'Run /shipit:go to start a voyage.',
145 ],
146 }
147 }
148
149 const markers = [
150 ...(paused ? ['paused'] : []),
151 ...(voyage.awaitingUser || isGatePhase(voyage.phase) ? ['awaiting you'] : []),
152 ]
153 return {
154 kind: 'voyage',
155 planName: voyage.planName,
156 phase: PHASE_LABELS[voyage.phase],
157 markers,
158 summary:
159 progress === null
160 ? 'Progress unknown: the plan file could not be read.'
161 : `${progress.done}/${progress.total} tasks done`,
162 tasks:
163 progress === null
164 ? null
165 : progress.tasks.map(task => {
166 const waits = task.state === 'blocked' ? waitingOn(task, progress) : []
167 return {
168 id: task.id,
169 title: task.title,
170 state: task.state,
171 waiting: waits.length === 0 ? '' : `waiting on ${waits.join(', ')}`,
172 }
173 }),
174 verdicts: VERDICT_KEYS.map(key => ({
175 key,
176 dimension: DIMENSIONS[key],
177 verdict: voyage.verdicts[key] ?? 'not run',
178 })),
179 evidence: evidence.slice(0, MAX_EVIDENCE_ROWS),
180 evidenceHidden: Math.max(0, evidence.length - MAX_EVIDENCE_ROWS),
181 isPaused: paused,
182 buttonLabel: paused ? 'Resume' : 'Pause',
183 }
184}
185
186// --- the pane ----------------------------------------------------------------
187
188export const registerPane = (on: On, _options: PluginOptions): void => {
189 // Opened only by something the person did: `/shipit pane` (enforcer.ts's
190 // dispatcher opens what `run` answers) and the band's Button (band.tsx).
191 addSubcommand({
192 name: 'pane',
193 summary: 'open the voyage pane (tasks, verdicts, evidence)',
194 run: () => ({ text: 'Voyage pane opened.', open: { id: PANE_ID, title: PANE_TITLE } }),
195 })
196 addBandAction({ key: 'pane', label: 'Voyage', open: { id: PANE_ID, title: PANE_TITLE } })
197
198 // The pane's body scrolls under the engine (`e.props.scroll`), so every task
199 // is drawn, one row each (two when blocked), long text cut at the row's end.
200 // The read subscribes the pane: a state write draws it again. Nothing here
201 // writes; the one write is the button's press.
202 on('ui.render', { component: 'Pane', requestId: 'shipit-voyage' }, async ($, e) => {
203 const { Box, Text, Button } = $.ui.resolve(e)
204 const view = paneView(await readSnapshot($))
205
206 if (view.kind === 'empty') {
207 return (
208 <Box key="empty" flexDirection="column" rowGap={1}>
209 {view.lines.map((line, index) => (
210 <Text bold={index === 0} dimColor={index === 1}>
211 {line}
212 </Text>
213 ))}
214 </Box>
215 )
216 }
217
218 return (
219 <Box key="voyage" flexDirection="column" rowGap={1}>
220 <Box key="header" flexDirection="column">
221 <Text bold wrap="truncate-end">
222 {view.planName}
223 </Text>
224 <Text wrap="truncate-end">
225 {[view.phase, ...view.markers].join(' · ')}
226 </Text>
227 <Text dimColor>{view.summary}</Text>
228 <Box>
229 <Button key="pause" hotkey="p" variant={view.isPaused ? 'primary' : 'secondary'} onPress={() => togglePaused($)}>
230 {view.buttonLabel}
231 </Button>
232 </Box>
233 </Box>
234
235 <Box key="tasks" flexDirection="column">
236 <Text bold>Tasks</Text>
237 {view.tasks === null && <Text dimColor>Unknown: the plan file could not be read.</Text>}
238 {view.tasks !== null && view.tasks.length === 0 && <Text dimColor>The plan has no tasks.</Text>}
239 {(view.tasks ?? []).map(task => (
240 <Box key={`task-${task.id}`} flexDirection="column">
241 <Text dimColor={task.state === 'done'} bold={task.state === 'ready'} wrap="truncate-end">
242 {`${task.state.padEnd(8)}${task.id}. ${task.title}`}
243 </Text>
244 {task.waiting !== '' && (
245 <Text color="warning" wrap="truncate-end">
246 {`${' '.repeat(8)}${task.waiting}`}
247 </Text>
248 )}
249 </Box>
250 ))}
251 </Box>
252
253 <Box key="verdicts" flexDirection="column">
254 <Text bold>Review verdicts</Text>
255 {view.verdicts.map(row => (
256 <Box key={`verdict-${row.key}`} flexDirection="row" columnGap={1}>
257 <Text wrap="truncate-end">{`${row.key} ${row.dimension}:`}</Text>
258 <Text
259 color={row.verdict === 'APPROVE' ? 'success' : row.verdict === 'REJECT' ? 'error' : undefined}
260 dimColor={row.verdict === 'not run'}
261 >
262 {row.verdict}
263 </Text>
264 </Box>
265 ))}
266 </Box>
267
268 <Box key="evidence" flexDirection="column">
269 <Text bold>{`Evidence (${view.evidence.length + view.evidenceHidden})`}</Text>
270 {view.evidence.length === 0 && <Text dimColor>No evidence files yet.</Text>}
271 {view.evidence.map(name => (
272 <Text wrap="truncate-end">{name}</Text>
273 ))}
274 {view.evidenceHidden > 0 && <Text dimColor>{`and ${view.evidenceHidden} more`}</Text>}
275 </Box>
276 </Box>
277 )
278 })
279}
280hooks/state.ts 234 lines1// What the shipit mod's files share: the snapshot's shape and rules, the
2// userConfig readers, path helpers, and the seams guard.ts, enforcer.ts,
3// band.tsx and pane.tsx meet at. Everything here is PURE: no `$`.
4//
5// Why no `$` here: `claude plugin validate` refuses a `$` handed to an
6// imported function ("$ is followed only into a function declared in this
7// same file") and a `$.state` reference that is not a const of the calling
8// file. So every file that touches `$.state` declares the five references
9// itself and keeps its own top-level `readSnapshot($)`:
10//
11// const VOYAGE = { plugin: 'shipit', key: 'voyage' } as const
12// const PROGRESS = { plugin: 'shipit', key: 'progress' } as const
13// const EVIDENCE = { plugin: 'shipit', key: 'evidence' } as const
14// const PAUSED = { plugin: 'shipit', key: 'paused' } as const
15// const NUDGES = { plugin: 'shipit', key: 'nudges' } as const
16//
17// const readSnapshot = async ($: EngineInterface): Promise<Snapshot> => {
18// try {
19// return snapshotOf({
20// voyage: (await $.state.get(VOYAGE)).value,
21// progress: (await $.state.get(PROGRESS)).value,
22// evidence: (await $.state.get(EVIDENCE)).value,
23// paused: (await $.state.get(PAUSED)).value,
24// nudges: (await $.state.get(NUDGES)).value,
25// })
26// } catch {
27// return EMPTY
28// }
29// }
30//
31// Who writes what: register.tsx's `refresh` is the ONLY writer of `voyage`,
32// `progress` and `evidence` (it also clears `paused` / `nudges` when the
33// voyage ends and zeroes `nudges` when progress moves). `paused` is written by
34// `/shipit pause|resume` (enforcer.ts) and the pane's button (pane.tsx);
35// `nudges` by enforcer.ts. Nobody writes while a `ui.render` hook draws, and
36// nobody sets a key to `undefined`. The mod never writes `.shipit/`.
37
38import type { CommandRunInput, CommandRunResult, PluginOptions } from 'claude-code'
39
40import type { ShipitProgress, ShipitVoyage } from '../types'
41
42export const SHIPIT_DIR = '.shipit'
43export const VOYAGE_FILE = '.shipit/voyage.json'
44export const EVIDENCE_DIR = '.shipit/evidence'
45/** The `/shipit` command (spike: COMMAND_NAME). Task 12 registers it. */
46export const COMMAND = 'shipit'
47/** The voyage pane's `$.ui.open` id and its `ui.render` `requestId`. Task 14 opens it. */
48export const PANE_ID = 'shipit-voyage'
49/** The skills whose expansion refreshes the snapshot (spike: SKILL_EVENT_NAME). */
50export const SKILLS = ['shipit:go', 'shipit:plan', 'shipit:execute', 'shipit:review'] as const
51/** A plugin agent's type as `agent.spawn` carries it (verified: `<plugin>:<name>`). */
52export const MAKER_AGENT = 'shipit:maker'
53/** The enforcer stands down after this many nudges without progress. */
54export const MAX_NUDGES = 3
55const MAX_EVIDENCE = 500
56
57// --- the snapshot ----------------------------------------------------------
58
59/**
60 * One reading of the mod's state. `voyage` is non-null ONLY for a valid record
61 * this session owns, so "owned" is `snapshot.voyage !== null`; `progress` is
62 * null without one, or when its plan cannot be read (unknown, not zero).
63 */
64export type Snapshot = {
65 voyage: ShipitVoyage | null
66 progress: ShipitProgress | null
67 evidence: string[]
68 paused: boolean
69 nudges: number
70}
71
72/** The five `$.state` values as read: each undefined until first written. */
73export type RawSnapshot = {
74 voyage?: ShipitVoyage | null | undefined
75 progress?: ShipitProgress | null | undefined
76 evidence?: string[] | undefined
77 paused?: boolean | undefined
78 nudges?: number | undefined
79}
80
81export const EMPTY: Snapshot = { voyage: null, progress: null, evidence: [], paused: false, nudges: 0 }
82
83/** The snapshot the values read from `$.state` come to, defaults filled in. */
84export const snapshotOf = (raw: RawSnapshot): Snapshot => ({
85 voyage: raw.voyage ?? null,
86 progress: raw.progress ?? null,
87 evidence: Array.isArray(raw.evidence) ? raw.evidence : [],
88 paused: raw.paused === true,
89 nudges: typeof raw.nudges === 'number' && raw.nudges > 0 ? Math.trunc(raw.nudges) : 0,
90})
91
92/** What the files on disk say; `voyage` already checked for validity and ownership. */
93export type Disk = Pick<Snapshot, 'voyage' | 'progress' | 'evidence'>
94
95export const NO_VOYAGE: Disk = { voyage: null, progress: null, evidence: [] }
96
97/** The evidence file names of a `$.fs.list` answer: files only, sorted, capped. */
98export const evidenceNames = (entries: readonly { name: string; kind: string }[]): string[] =>
99 entries
100 .filter(entry => entry.kind === 'file')
101 .map(entry => entry.name)
102 .sort()
103 .slice(0, MAX_EVIDENCE)
104
105const progressMark = (snapshot: Disk): string =>
106 JSON.stringify([snapshot.voyage?.phase, snapshot.progress?.done, snapshot.voyage?.verdicts])
107
108/**
109 * The snapshot a refresh settles on. Without an owned voyage the pause flag
110 * and nudge count are cleared (a pause ends with its voyage); with one, the
111 * nudge count goes back to zero whenever the phase, the done count or a
112 * verdict moved since `before`.
113 */
114export const settle = (before: Snapshot, disk: Disk): Snapshot => {
115 if (disk.voyage === null) return { ...EMPTY }
116 const hasMoved = progressMark(disk) !== progressMark(before)
117 return { ...disk, paused: before.paused, nudges: hasMoved ? 0 : before.nudges }
118}
119
120/** The keys whose value differs between two snapshots, in the order a refresh writes them. */
121export const changedKeys = (before: Snapshot, now: Snapshot): (keyof Snapshot)[] =>
122 (['progress', 'evidence', 'paused', 'nudges', 'voyage'] as const).filter(
123 key => JSON.stringify(before[key]) !== JSON.stringify(now[key]),
124 )
125
126// --- userConfig ------------------------------------------------------------
127
128/** The `guard` option: on unless set to false. */
129export const isGuardEnabled = (options: PluginOptions): boolean => options.guard !== false
130
131/** The `enforcer` option: on unless set to false. */
132export const isEnforcerEnabled = (options: PluginOptions): boolean => options.enforcer !== false
133
134/** The `maker_model` option, or undefined when unset or blank. */
135export const makerModelOf = (options: PluginOptions): string | undefined => {
136 const model = options.maker_model
137 return typeof model === 'string' && model.trim() !== '' ? model.trim() : undefined
138}
139
140// --- paths -----------------------------------------------------------------
141
142const isAbsolute = (path: string): boolean => /^([\\/]|[A-Za-z]:[\\/])/.test(path)
143
144/** `rel` under `root` (as given when already absolute), joined with the root's own separator. */
145export const pathUnder = (root: string, rel: string): string => {
146 if (isAbsolute(rel)) return rel
147 const sep = root.includes('/') || !root.includes('\\') ? '/' : '\\'
148 return `${root.replace(/[\\/]+$/, '')}${sep}${rel}`
149}
150
151/** True when a tool's path argument has a `.shipit` segment: the observer's cue to refresh. Not a gate. */
152export const isShipitPath = (path: unknown): boolean =>
153 typeof path === 'string' && path.split(/[\\/]/).includes(SHIPIT_DIR)
154
155// --- seams -----------------------------------------------------------------
156// Registries the four feature files fill synchronously inside their own
157// `registerX(on, options)`. No entry ever receives `$` from another file: a
158// subcommand answers with data, and the file that holds `$` acts on it.
159
160/** The status line text for a snapshot; undefined clears the entry. Task 13 supplies it. */
161export type StatusFormatter = (snapshot: Snapshot) => string | undefined
162
163/** What a contributed `/shipit <name>` subcommand asks the dispatcher to do. */
164export type SubcommandAnswer = CommandRunResult & {
165 /** Open this pane with `$.ui.open({ id, title })` before answering. */
166 open?: { id: string; title: string }
167}
168
169/** A `/shipit <name> ...` subcommand another file contributes to Task 12's dispatcher. */
170export type Subcommand = {
171 /** The first word after `/shipit`, lower case. */
172 name: string
173 /** One line for the usage listing. */
174 summary: string
175 /** Pure: `args` is what followed the name, trimmed; `snapshot` is current. */
176 run: (args: string, snapshot: Snapshot, e: CommandRunInput) => SubcommandAnswer
177}
178
179/** A Button another file contributes to the band: pressing it opens a pane. */
180export type BandAction = {
181 /** The Button's `key`, unique on the band. */
182 key: string
183 label: string
184 /** The band's `onPress` calls `$.ui.open(action.open)`. */
185 open: { id: string; title: string }
186}
187
188let statusFormatter: StatusFormatter | undefined
189const subcommands = new Map<string, Subcommand>()
190const bandActions = new Map<string, BandAction>()
191
192/** Empties the registries. register.tsx calls it first, so a reload starts clean. */
193export const resetSeams = (): void => {
194 statusFormatter = undefined
195 subcommands.clear()
196 bandActions.clear()
197}
198
199/**
200 * Task 13: supplies the status line's text. Once set, register.tsx calls
201 * `$.ui.status(statusText(snapshot))` after every refresh; a file that changes
202 * `paused` itself does the same after its write.
203 */
204export const setStatusFormatter = (formatter: StatusFormatter): void => {
205 statusFormatter = formatter
206}
207
208export const hasStatusFormatter = (): boolean => statusFormatter !== undefined
209
210/** The status line text for `snapshot`: undefined with no formatter or when it says to clear. */
211export const statusText = (snapshot: Snapshot): string | undefined => {
212 try {
213 return statusFormatter?.(snapshot)
214 } catch {
215 return undefined
216 }
217}
218
219/** Contributes a `/shipit` subcommand; the same name again replaces it. */
220export const addSubcommand = (subcommand: Subcommand): void => {
221 subcommands.set(subcommand.name.toLowerCase(), subcommand)
222}
223
224export const findSubcommand = (name: string): Subcommand | undefined => subcommands.get(name.toLowerCase())
225
226export const listSubcommands = (): Subcommand[] => [...subcommands.values()]
227
228/** Contributes a band Button; the same key again replaces it. */
229export const addBandAction = (action: BandAction): void => {
230 bandActions.set(action.key, action)
231}
232
233export const listBandActions = (): BandAction[] => [...bandActions.values()]
234hooks/voyage.ts 179 lines1// The voyage contract (skills/VOYAGE.md sections 2, 3 and 5) as pure functions:
2// no `$`, no I/O, and nothing here throws on bad input.
3
4import type {
5 ShipitPhase,
6 ShipitProgress,
7 ShipitTask,
8 ShipitVerdict,
9 ShipitVerdictKey,
10 ShipitVerdicts,
11 ShipitVoyage,
12} from '../types'
13
14export type Phase = ShipitPhase
15export type Verdict = ShipitVerdict
16export type VerdictKey = ShipitVerdictKey
17export type Verdicts = ShipitVerdicts
18export type Voyage = ShipitVoyage
19export type Task = ShipitTask
20export type TaskState = ShipitTask['state']
21export type Progress = ShipitProgress
22
23export const PHASES = ['planning', 'plan_gate', 'executing', 'reviewing', 'ship_gate', 'done'] as const
24export const VERDICT_KEYS = ['F1', 'F2', 'F3', 'F4'] as const
25
26const TASK_LINE = /^- \[( |x|X)\] (\d+)\. (.*)$/
27const DEPENDS_ON = /^\s*(?:[-*]\s+)?\*\*Depends on\*\*:\s*(.*)$/
28const BLOCKED_BY = /^\s*(?:[-*]\s+)?\*\*Blocked By\*\*:\s*(.*)$/
29
30const isRecord = (value: unknown): value is Record<string, unknown> =>
31 typeof value === 'object' && value !== null && !Array.isArray(value)
32
33const isFilled = (value: unknown): value is string => typeof value === 'string' && value !== ''
34
35export const isPhase = (value: unknown): value is Phase =>
36 typeof value === 'string' && (PHASES as readonly string[]).includes(value)
37
38/** Guard phases: every phase except `done`. */
39export const isActivePhase = (phase: Phase): boolean => phase !== 'done'
40
41/** Enforcer phases: `executing` and `reviewing`. */
42export const isEnforcerPhase = (phase: Phase): boolean => phase === 'executing' || phase === 'reviewing'
43
44/** The phases whose allow-list includes the glossary and ADR paths. */
45export const isPlanningPhase = (phase: Phase): boolean => phase === 'planning' || phase === 'plan_gate'
46
47/** `plan_gate` and `ship_gate`: waiting for the user whatever `awaitingUser` says. */
48export const isGatePhase = (phase: Phase): boolean => phase === 'plan_gate' || phase === 'ship_gate'
49
50/** The plan file's basename without `.md`. */
51export const planNameOf = (activePlan: string): string =>
52 (activePlan.split(/[\\/]/).pop() ?? activePlan).replace(/\.md$/, '')
53
54const verdictOf = (value: unknown): Verdict => (value === 'APPROVE' || value === 'REJECT' ? value : null)
55
56/**
57 * The record in `text` (the content of `.shipit/voyage.json`), normalised, or
58 * undefined when it is not a voyage: not a string, unparseable, not an object,
59 * an unknown `phase`, or no `active_plan` / `session_id` (the legacy shape).
60 */
61export const parseVoyage = (text: unknown): Voyage | undefined => {
62 if (typeof text !== 'string') return undefined
63 let raw: unknown
64 try {
65 raw = JSON.parse(text)
66 } catch {
67 return undefined
68 }
69 if (!isRecord(raw)) return undefined
70 const { phase, active_plan: activePlan, session_id: sessionId } = raw
71 if (!isPhase(phase) || !isFilled(activePlan) || !isFilled(sessionId)) return undefined
72
73 const verdicts = isRecord(raw.verdicts) ? raw.verdicts : {}
74 return {
75 planName: isFilled(raw.plan_name) ? raw.plan_name : planNameOf(activePlan),
76 activePlan,
77 startedAt: isFilled(raw.started_at) ? raw.started_at : null,
78 sessionId,
79 phase,
80 awaitingUser: raw.awaiting_user === true,
81 verdicts: {
82 F1: verdictOf(verdicts.F1),
83 F2: verdictOf(verdicts.F2),
84 F3: verdictOf(verdicts.F3),
85 F4: verdictOf(verdicts.F4),
86 },
87 }
88}
89
90/** True when `voyage` is a valid record whose `sessionId` is this session's. */
91export const isOwned = (voyage: Voyage | null | undefined, sessionId: unknown): voyage is Voyage =>
92 voyage !== null && voyage !== undefined && isFilled(sessionId) && voyage.sessionId === sessionId
93
94/** The lines of the `## TODOs` section, or every line when the plan has no such heading. */
95const todoLines = (planText: string): string[] => {
96 const lines = planText.split(/\r?\n/)
97 const start = lines.findIndex(line => line.trimEnd() === '## TODOs')
98 if (start < 0) return lines
99 const rest = lines.slice(start + 1)
100 const end = rest.findIndex(line => line.startsWith('## '))
101 return end < 0 ? rest : rest.slice(0, end)
102}
103
104/** The ids a dependency value names: cut at the first `(`, then every integer. */
105export const parseDependencyIds = (value: string): number[] => {
106 const cut = value.indexOf('(')
107 const ids = ((cut < 0 ? value : value.slice(0, cut)).match(/\d+/g) ?? []).map(Number)
108 return [...new Set(ids)]
109}
110
111const dependenciesOf = (block: readonly string[]): number[] => {
112 for (const pattern of [DEPENDS_ON, BLOCKED_BY]) {
113 for (const line of block) {
114 const value = pattern.exec(line)?.[1]
115 if (value !== undefined) return parseDependencyIds(value)
116 }
117 }
118 return []
119}
120
121/**
122 * Progress read from a plan's text (VOYAGE.md section 3), or undefined when
123 * `planText` is not a string. A dangling dependency id or a cycle leaves the
124 * tasks concerned `blocked`; neither fails the read.
125 */
126export const parseProgress = (planText: unknown): Progress | undefined => {
127 if (typeof planText !== 'string') return undefined
128
129 const found: { id: number; title: string; checked: boolean; block: string[] }[] = []
130 for (const line of todoLines(planText)) {
131 const hit = TASK_LINE.exec(line)
132 if (hit !== null) {
133 found.push({ id: Number(hit[2]), title: (hit[3] ?? '').trim(), checked: hit[1] !== ' ', block: [] })
134 } else {
135 found.at(-1)?.block.push(line)
136 }
137 }
138
139 const doneIds = new Set(found.filter(task => task.checked).map(task => task.id))
140 const tasks = found.map(({ id, title, checked, block }): Task => {
141 const dependsOn = dependenciesOf(block)
142 const isReady = dependsOn.every(dep => dep !== id && doneIds.has(dep))
143 return { id, title, checked, dependsOn, state: checked ? 'done' : isReady ? 'ready' : 'blocked' }
144 })
145
146 return { done: tasks.filter(task => task.checked).length, total: tasks.length, tasks }
147}
148
149/** The unchecked tasks whose dependencies are all done, in plan order. */
150export const readyTasks = (progress: Progress | null | undefined): Task[] =>
151 (progress?.tasks ?? []).filter(task => task.state === 'ready')
152
153/** The task to name in a nudge: the first ready one, else the first unchecked one. */
154export const nextTask = (progress: Progress | null | undefined): Task | undefined =>
155 readyTasks(progress)[0] ?? (progress?.tasks ?? []).find(task => !task.checked)
156
157/** The dependency ids `task` still waits on: not done, or naming no task. */
158export const waitingOn = (task: Task, progress: Progress | null | undefined): number[] => {
159 const doneIds = new Set((progress?.tasks ?? []).filter(one => one.checked).map(one => one.id))
160 return task.checked ? [] : task.dependsOn.filter(dep => dep === task.id || !doneIds.has(dep))
161}
162
163/** The verdict keys that are not `APPROVE` (null or `REJECT`). */
164export const pendingVerdicts = (verdicts: Verdicts): VerdictKey[] =>
165 VERDICT_KEYS.filter(key => verdicts[key] !== 'APPROVE')
166
167/**
168 * VOYAGE.md section 8's "work remains": in `executing` a task that is not
169 * done, in `reviewing` a verdict that is not `APPROVE`; false in every other
170 * phase, and false in `executing` while progress is unknown (an unreadable
171 * plan is not known to have work left).
172 */
173export const workRemains = (voyage: Voyage | null | undefined, progress: Progress | null | undefined): boolean => {
174 if (voyage === null || voyage === undefined) return false
175 if (voyage.phase === 'executing') return progress !== null && progress !== undefined && progress.done < progress.total
176 if (voyage.phase === 'reviewing') return pendingVerdicts(voyage.verdicts).length > 0
177 return false
178}
179types/index.d.ts 53 lines1/** The six voyage phases of VOYAGE.md section 2. */
2export type ShipitPhase = 'planning' | 'plan_gate' | 'executing' | 'reviewing' | 'ship_gate' | 'done'
3
4/** One review verdict; null is "not run yet, or invalidated and due again". */
5export type ShipitVerdict = 'APPROVE' | 'REJECT' | null
6
7export type ShipitVerdictKey = 'F1' | 'F2' | 'F3' | 'F4'
8
9export type ShipitVerdicts = Record<ShipitVerdictKey, ShipitVerdict>
10
11/** A valid `.shipit/voyage.json`, normalised: camelCase, optional fields defaulted. */
12export type ShipitVoyage = {
13 planName: string
14 /** As stored: relative to the project root. */
15 activePlan: string
16 /** Display only; null when the record carries none. */
17 startedAt: string | null
18 sessionId: string
19 phase: ShipitPhase
20 awaitingUser: boolean
21 verdicts: ShipitVerdicts
22}
23
24export type ShipitTaskState = 'done' | 'ready' | 'blocked'
25
26export type ShipitTask = {
27 id: number
28 title: string
29 checked: boolean
30 dependsOn: number[]
31 state: ShipitTaskState
32}
33
34/** Task progress derived from the plan's `## TODOs` checkboxes. */
35export type ShipitProgress = { done: number; total: number; tasks: ShipitTask[] }
36
37declare module 'claude-code' {
38 interface PluginState {
39 shipit: {
40 /** The voyage this session owns; null when there is none (missing, corrupt, legacy or foreign). */
41 voyage: ShipitVoyage | null
42 /** Progress of the owned voyage's plan; null when there is no owned voyage or the plan cannot be read. */
43 progress: ShipitProgress | null
44 /** File names in `.shipit/evidence/`, sorted; empty without an owned voyage. */
45 evidence: string[]
46 /** `/shipit pause`: suspends guard and enforcer for this session. */
47 paused: boolean
48 /** Consecutive enforcer nudges without progress. */
49 nudges: number
50 }
51 }
52}
53