SLOPSHOPPER

shipit

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

newpanebandguardcommandtoast
v2.0.2MITupdated 2026-10-07dphaener/flow/plugins/shipit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shipit
│ ┃ shipit-voyage ✕ › fix the failing auth test and add an audit log call │ ┃ No voyage in this session. │ ┃ ⏺ Read(src/auth.ts) │ ┃ This pane follows a shipit voyage: its plan, ⎿ Read 6 lines │ ┃ each task and what it waits on, the F1-F4 ⏺ Update(src/auth.ts) │ ┃ review verdicts and the evidence files. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Run /shipit:go to start a voyage. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /shipit │ ⎿ shipit: No shipit voyage is owned by this session. Start one wit │ ⎿ shipit: Enforcer: on · Guard: on │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · shipit-voyage
No voyage in this session. This pane follows a shipit voyage: its plan, each task and what it waits on, the F1-F4 review verdicts and the evidence files. Run /shipit:go to start a voyage.
README

shipit

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.

Requirements

  • Claude Code 2.1.287 or later. shipit's band, pane, write-guard and enforcer are a Claude Code mod, and mods need that version.
  • The 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.

Installation

# Add the flow marketplace
claude plugin marketplace add <flow-marketplace-url>

# Install shipit
claude plugin install shipit

Quick Start

/shipit:go Add user authentication to my API

That is the default way to use shipit. In one session it will:

  1. Plan — grill you on the design, record the glossary and decisions, and write a work plan
  2. Stop for your approval of the plan (gate 1)
  3. Execute — delegate every task to a Maker, verify each result, track progress
  4. Review — run four independent quality checks and route any fixes back through a Maker
  5. Stop for your ship sign-off (gate 2)

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.

Skills

/shipit:go

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

/shipit:plan

Planning only. The Cartographer will:

  • Classify the request, and ask before skipping planning for something trivial
  • Load the 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 own
  • Look facts up through The Scout (codebase), The Archivist (external docs) and The Sage (architecture) instead of guessing
  • Write the glossary to CONTEXT.md and decisions to docs/adr/ as domain-modeling directs
  • Generate the plan to .shipit/plans/{name}.md as a task dependency graph: every task carries a Depends on: line, and there are no hand-assigned waves
  • Offer three choices: approve, modify, or a high accuracy review by The Gatekeeper (optional; it loops until the plan passes)

Run 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

/shipit:execute

Execution only. The Captain will:

  • Read the plan and build the task graph from the Depends on: lines (older plans' Blocked By: lines are read the same way)
  • Refuse to start on a graph with a dangling dependency or a cycle
  • Run the ready frontier repeatedly: every unchecked task whose dependencies are all done is delegated to The Maker, in parallel where the graph allows
  • Verify every result itself before ticking the task's checkbox; failed work goes back to a Maker, never fixed by hand
  • Continue until every task is checked or it is truly blocked

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.

/shipit:review

Final verification. The Harbormaster runs four reviews in parallel:

  1. F1 Plan Compliance — Every "Must Have" implemented, every "Must NOT Have" absent
  2. F2 Code Quality — Build passes, tests pass, no anti-patterns
  3. F3 Real QA — Every QA scenario executed, integration and edge cases tested
  4. F4 Scope Fidelity — Implementation matches the plan 1:1, nothing missing or extra

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.

The Mod

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.

Voyage band

One row above the prompt while a voyage is under way:

Plan › Execute › Review  auth-plan  3/9  awaiting you  [ Voyage ]
  • the phase trail, with the current stage emphasised
  • the plan name
  • tasks done / total (?/? when the plan file cannot be read)
  • a paused marker while you have paused the mod, and awaiting you at either gate or when Claude has asked you a blocking question
  • a Voyage button that opens the pane

On 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.

Status line

A status line entry mirrors the band: shipit: execute 3/9, with (paused) and/or (awaiting you) after it when they apply.

Voyage pane

Open it with the band's Voyage button or /shipit pane. It shows:

  • the plan name, the phase and the task count
  • every task as done, ready or blocked, and for a blocked task the task ids it is waiting on
  • the F1-F4 review verdicts (APPROVE, REJECT or not run)
  • the names of the files in .shipit/evidence/ (names only; the pane does not open them)
  • a Pause / Resume button (hotkey p), the same switch as /shipit pause and /shipit resume

Write-guard

While this session owns a voyage, the main loop may write only:

PathWhen
.shipit/**Every phase of the voyage
CONTEXT.md, **/CONTEXT.mdWhile 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.

  • Subagents are never guarded. The Maker and every other subagent write freely.
  • Sessions that do not own the voyage are never guarded, including any session in a project with no voyage.
  • If the guard cannot judge a write, it refuses it.
  • Bash writes are NOT intercepted. A file written by a shell command (a redirect, 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.

Continuation enforcer

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:

  • only while executing or reviewing — never while planning or at either gate
  • only after a turn that ended normally — never after you interrupt, or after a refusal or an error
  • never while Claude is waiting on you for a blocker, and never while paused
  • never for a subagent's turn
  • it stands down after 3 nudges without progress and tells you so. Any progress (a task ticked, a verdict recorded, a phase change) or /shipit resume re-arms it

/shipit

A command registered by the mod. It is separate from the /shipit:... skills above.

SubcommandWhat it does
/shipit or /shipit statusThe voyage, its phase, task counts, the next task, verdicts, and whether the guard and enforcer are on
/shipit pauseSuspends both the guard and the enforcer for this session
/shipit resumeTurns both back on and resets the nudge count
/shipit endAsks Claude to close the voyage
/shipit paneOpens 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.

Options

OptionDefaultWhat it does
maker_modelemptyA 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
enforcertrueThe continuation enforcer. false turns it off
guardtrueThe write-guard. false turns it off

Where the mod draws

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.

Workflow Execution (optional)

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.

  • No (the default) — the Captain delegates each ready task to a Maker and verifies it.
  • Yes — the workflow runs the same task graph in the background, with a Maker and a separate verification agent for every task and a retry when verification fails. That means more agents and more tokens.

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.

The Cast

CharacterRoleUsed By
The CartographerStrategic planner — grills, records decisions, generates plans/shipit:plan (the main loop)
The CaptainMaster orchestrator — delegates, verifies, tracks progress/shipit:execute (the main loop)
The HarbormasterFinal reviewer — 4-dimension quality gate/shipit:review (the main loop)
The ScoutCodebase explorer — finds patterns, conventions, implementations. Read-onlyshipit:scout agent, spawned by Cartographer/Captain
The ArchivistExternal researcher — finds docs, examples, best practices. Read-onlyshipit:archivist agent, spawned by Cartographer/Captain
The SageArchitecture advisor — strategic consultation on design decisions. Read-onlyshipit:sage agent, spawned by Cartographer/Captain
The GatekeeperPlan validator — verifies plan quality, references and the task graph. Read-onlyshipit:gatekeeper agent, spawned by Cartographer on request
The MakerTask executor — implements a single task following its specificationshipit: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.

Workflow

/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

Voyage State

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.

Directory Structure

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.

Resuming Work

If a session is interrupted (context limit, crash, etc.):

  1. Run /shipit:go (or the phase skill you were in) again
  2. It reads voyage.json and the plan file, and asks before taking over a voyage that another session owns
  3. It resumes from the recorded phase: during execution, from the tasks that are ready
  4. All notepad wisdom from previous tasks is preserved

Limitations

  • Bash is not guarded. The write-guard covers the Write, 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.
  • The band and pane need a surface that draws mods (terminal or Claude Desktop). Elsewhere the guard and the enforcer still apply, without the visuals.
  • Resuming after an interruption is manual. When a session hits the context limit you re-run the skill; progress is preserved in the plan file and voyage.json.
  • One voyage per project. Starting another means ending the current one first.

License

MIT — see LICENSE

Source 8 files
hooks/register.tsx 149 lines
1import 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}
149
hooks/band.tsx 264 lines
1// 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}
264
hooks/enforcer.ts 293 lines
1// 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}
293
hooks/guard.ts 202 lines
1// 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}
202
hooks/pane.tsx 280 lines
1// 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}
280
hooks/state.ts 234 lines
1// 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()]
234
hooks/voyage.ts 179 lines
1// 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}
179
types/index.d.ts 53 lines
1/** 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