A development verification board: shows which tests, builds and type checks really ran, how they ended, and whether the result still matches your code.

A Claude Code marketplace with one mod: Ship Check, a development verification board.
When Claude changes your code, Ship Check shows which checks really ran, how they ended, and whether each result still matches the code on disk. It never takes Claude's word for it. A check is Passed only when the tool that ran it said so.
Tests ✓ | Types ↻ Stale | Build ○ Not run
Tests ✓ | Types ↻ Stale | Build ○ Not run. When the same kind of check has results in several folders (for example the tests of two packages), the line shows the worst one, in the order Failed, Stale, Unknown, Running, Passed, so a failure is never hidden behind a newer pass elsewhere. Open the panel to see every folder. It updates quietly and never pops up a notification./ship-check, to open Ship Check. For each check it shows the result, the time, the command, the project location, and why a result is stale.| Status | Meaning |
|---|---|
| Not run | No result has been recorded for this project yet. |
| Running | The check has started and has not reported back. |
| Passed | The tool reported a clean finish. |
| Failed | The tool reported a non-zero exit status. |
| Stale | The check finished, but files changed afterwards (or while it ran). |
| Unknown | The check ran, but the tool gave no reliable way to tell how it ended. |
test, build, and type-check scripts (typecheck, type-check, check-types, tsc), including npm run <script>, pnpm -C dir, yarn workspace <name> <script>, npm --prefix dir, --workspace / --filter, and a leading cd dir &&.tsc (--noEmit counts as a type check), vitest run, jest, npx/bunx forms of those.Ship Check reads the Bash tool and the PowerShell tool (Windows), and looks through cmd /c "..." and bash -c "..." wrappers.
The same check in two different project locations, or two workspaces, is recorded separately.
Only the tool result counts. A check is Passed or Failed only when the exit status the tool reported can be tied to that check:
Exit code N line → Failed, and N is shown.What each tool reports, and so what Ship Check trusts:
| Shell | The tool reports | Trusted forms | Unknown | ||||
|---|---|---|---|---|---|---|---|
| Bash | The exit status of the last statement | npm test, cd app && npm test, cd app; npm test, a check that is the last command of a ; list, and npm test; echo "EXIT: $?" (the printed status is read) | A pipe (`npm test \ | tail), \ | \ | , background jobs, ; followed by another command, a failure in an && chain that could belong to an earlier command. After a pipe, echo "${PIPESTATUS[0]}"` is read. | |
| PowerShell | $LASTEXITCODE: the exit status of the last native program | Set-Location x; npm test, a check followed by cmdlets, strings, Write-Output, or if ($?) {...} / if ($LASTEXITCODE -ne 0) {...} blocks that run no program, a pipeline into cmdlets (`npm test \ | Select-Object -Last 1), PowerShell 7 &&` chains | A later native program (npm test; npm run build — the first check), a pipeline into a program (`npm test \ | findstr x), | , an assignment like $x = npm test` | |
| cmd.exe | The exit status of the last command | cmd /c "npm test", cmd /c "cd app && npm test", cmd /c "a & b" (the last command) | The same forms as above |
Also Unknown:
--watch, plain vitest), which never finishes by itselfcd or Set-Location that failed, because the check may then have run in another directoryTest counts are not shown. Test runners print summaries in too many formats to parse reliably, so Ship Check shows the real output instead of a guessed number.
A run that passes extra arguments (for example npm test -- -t login) is marked in the panel, because it may not cover everything.
A finished result becomes Stale when any tracked file under the project changes afterwards. A change that happens while the check is running also makes the result stale, because the result may not match the latest code.
Ship Check notices:
| Change | How it is noticed |
|---|---|
| Claude's Edit, Write, MultiEdit and NotebookEdit tools | The tool call, immediately. |
| Files changed by a Bash command | The file list the Bash tool reports. If the tool cannot report one, every finished result in that project is marked stale to be safe. |
| Edits you make yourself | A fingerprint comparison when a turn ends, and when you submit a prompt. |
| Git commit or branch changes | The fingerprint includes HEAD. |
The fingerprint is HEAD plus the modified and untracked files that Git reports, with each file's size and modified time. Outside a Git repository it walks the folder with caps (depth 5, 1,500 entries); if a cap is hit the fingerprint is marked partial and is never used to claim that something changed.
Never counted: node_modules, .git, dist, build, out, .next, .nuxt, .svelte-kit, .turbo, .cache, coverage, .nyc_output, target, __pycache__, .venv, .claude, *.log, *.tsbuildinfo, and similar generated output.
The stale reason names the kind of change: source files, configuration files (tsconfig*, *.config.*, .env*), or dependency files (package.json, lockfiles).
claude --version. The desktop app bundles its own copy of Claude Code; Ship Check also loaded in a Windows desktop app whose built-in version was 2.1.286, so if it does not appear, update the app first.claude -p, cloud sessions) run a mod's hooks but do not draw its interface.Both settings are optional, and Ship Check works without them. To change them, open /plugin in a terminal session, select ship-check, and choose Configure options, or edit pluginConfigs in your settings file.
kind=command. The kind becomes the label in the status line. `` lint=npm run lint e2e=npx playwright test ``generated.Both are optional.
If the status line does not appear, start Claude Code with SHIP_CHECK_DEBUG set to a file path (for example $env:SHIP_CHECK_DEBUG = "C:\temp\ship.json" in PowerShell). Ship Check then writes the size the band was given and the number of recorded checks to that file. The status line appears once at least one check has run.
From a clone of this repository:
claude --plugin-dir ./plugins/ship-check
Then ask Claude to run your tests, and open the panel with /ship-check. Edits to the mod reload while the session runs.
Run the checks for the mod itself:
claude plugin validate .
claude plugin validate ./plugins/ship-check --strict
cd plugins/ship-check && claude plugin test
Use a recent Claude desktop app. Ship Check was tested in the Code tab of the Windows app. If it does not show up after the steps below, update the app and try again.
panyuti-ai/ship-check-mods and confirm./ship-check and press Enter. Below the prompt you may then see an orange line, /ship-check isn't a command here. It is only a notice from the app, which does not know commands that a mod adds: the panel still opens.To turn it off or remove it later, use Settings → Plugins → Yours.
/plugin marketplace add panyuti-ai/ship-check-mods
/plugin install ship-check@ship-check-mods
/reload-plugins
The terminal and the desktop app's Code tab read the same plugin settings on one computer, so a plugin installed in one is available in the other.
Desktop app: open Settings → Plugins → Yours and use the menu (⋮) next to Ship Check or its marketplace to update. The menu wording can differ between app versions. Start a new session afterwards, because a running session keeps the version it loaded.
Terminal: marketplace update takes the marketplace's name (ship-check-mods), not the GitHub path, so these commands are the same for everyone:
/plugin marketplace update ship-check-mods
/plugin update ship-check@ship-check-mods
/reload-plugins
Claude Code caches an installed plugin by version, so a new release must raise version in both plugins/ship-check/.claude-plugin/plugin.json and .claude-plugin/marketplace.json.
&& and || are covered by unit tests only, and the cmd /c handling rests on the PowerShell tool reporting the wrapped command's status. If a Claude Code release changes what the tools report, results may become Unknown or, in the worst case, wrong; please open an issue.bashEditDiff is an internal field. Ship Check uses the file list the Bash tool reports when it is there and falls back to the fingerprint when it is not. If a future Claude Code version removes it, stale detection still works, just a little later./clear, /resume and /branch.cd to a path that uses ~, a variable, or - cannot be resolved, so that check is not recorded.ship-check-mods/
├── .claude-plugin/marketplace.json
├── plugins/ship-check/
│ ├── .claude-plugin/plugin.json
│ ├── hooks/
│ │ ├── hooks.json
│ │ ├── register.js # wires events to the board
│ │ └── lib/ # command parsing, shell analysis, statuses, views (no Claude Code API calls)
│ ├── types/index.d.ts # the state the mod keeps
│ └── tests/
├── README.md
└── LICENSE
MIT. See LICENSE.
hooks/register.js 492 lines1// ship-check: a development verification board for Claude Code.
2// It watches the test, build and type-check commands that actually ran, records what the tools
3// reported, and marks a result Stale when the project changes after it. See the README.
4
5import { atom, read, update } from 'claude-code'
6import { parseCustomChecks } from './lib/commands.js'
7import { analyzeCommand } from './lib/analyze.js'
8import { normalizePath, isUnder, relativeTo, resolveDir } from './lib/paths.js'
9import {
10 emptyLedger,
11 beginRecord,
12 restoreRecord,
13 finishRecord,
14 applyEdit,
15 applyUnnamedChange,
16 applyScan,
17 settleRunning,
18 activeRoots,
19 classifyOutcome,
20 tailOutput,
21 isTrackedPath,
22 categorizePath,
23 staleReasonFor,
24 recordKey,
25 preparePrompt,
26 displayStatus,
27 hasAnyRecord,
28 STATUS,
29} from './lib/model.js'
30import { buildStrip, buildPane } from './lib/view.js'
31
32const PANE_ID = 'ship-check'
33const MAX_SCAN_FILES = 600
34const MAX_WALK_ENTRIES = 1500
35const WALK_DEPTH = 5
36const SCAN_THROTTLE_MS = 1500
37const GIT_TIMEOUT_MS = 8000
38
39// State that survives a hot reload lives in `$.state`; the ledger is plain JSON.
40const ledgerAtom = atom({ plugin: 'ship-check', key: 'ledger' }, emptyLedger())
41const viewAtom = atom({ plugin: 'ship-check', key: 'view' }, { expanded: null })
42
43// Caches that can be rebuilt: lost on reload, which only costs an exact "what changed" list.
44const gitTops = new Map()
45const lastScans = new Map() // root -> { at, sig, map, head }
46
47// --- Fingerprints ---------------------------------------------------------------------------------
48
49function hashLines(lines) {
50 let a = 0x811c9dc5
51 let b = 0x01000193
52 for (const line of lines) {
53 for (let i = 0; i < line.length; i++) {
54 const c = line.charCodeAt(i)
55 a = Math.imul(a ^ c, 0x01000193) >>> 0
56 b = Math.imul(b + c, 0x85ebca6b) >>> 0
57 }
58 a = Math.imul(a ^ 10, 0x01000193) >>> 0
59 }
60 return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0')
61}
62
63async function gitTop($, dir) {
64 if (gitTops.has(dir)) return gitTops.get(dir)
65 let top = null
66 try {
67 const r = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'], { timeoutMs: GIT_TIMEOUT_MS })
68 if (r.exitCode === 0 && r.stdout.trim()) top = normalizePath(r.stdout.trim())
69 } catch {
70 top = null
71 }
72 gitTops.set(dir, top)
73 return top
74}
75
76async function statSig($, path) {
77 try {
78 const s = await $.fs.stat(path)
79 return s.mtimeMs + ':' + s.size
80 } catch {
81 return 'gone'
82 }
83}
84
85async function statMany($, root, rels) {
86 const map = {}
87 for (let i = 0; i < rels.length; i += 40) {
88 const batch = rels.slice(i, i + 40)
89 const sigs = await Promise.all(batch.map((rel) => statSig($, root + '/' + rel)))
90 batch.forEach((rel, j) => {
91 map[rel] = sigs[j]
92 })
93 }
94 return map
95}
96
97// Git route: HEAD plus the files that differ from it. Cheap, and ignores ignored files.
98async function scanGit($, root, extra) {
99 let head = ''
100 try {
101 const h = await $.process.run(['git', '-C', root, 'rev-parse', 'HEAD'], { timeoutMs: GIT_TIMEOUT_MS })
102 if (h.exitCode === 0) head = h.stdout.trim()
103 } catch {
104 head = ''
105 }
106 const st = await $.process.run(['git', '-C', root, 'status', '--porcelain=v1', '-z', '-uall', '--no-renames'], { timeoutMs: GIT_TIMEOUT_MS })
107 if (st.exitCode !== 0) return null
108 const rels = []
109 for (const entry of st.stdout.split('\0')) {
110 if (entry.length < 4) continue
111 const rel = entry.slice(3).replace(/\\/g, '/')
112 if (isTrackedPath(rel, extra)) rels.push(rel)
113 }
114 const partial = rels.length > MAX_SCAN_FILES || st.isStdoutTruncated
115 const map = await statMany($, root, rels.slice(0, MAX_SCAN_FILES))
116 const lines = [head, ...Object.keys(map).sort().map((k) => k + '\t' + map[k])]
117 return { sig: { hash: hashLines(lines), count: Object.keys(map).length, partial }, map, head }
118}
119
120// No Git: a bounded walk. When it hits a cap the fingerprint is marked partial and is never used
121// to claim that something changed.
122async function scanWalk($, root, extra) {
123 const files = []
124 let seen = 0
125 let partial = false
126 const queue = [{ rel: '', depth: 0 }]
127 while (queue.length) {
128 const { rel, depth } = queue.shift()
129 let entries
130 try {
131 entries = await $.fs.list(rel ? root + '/' + rel : root)
132 } catch {
133 continue
134 }
135 for (const entry of entries) {
136 if (++seen > MAX_WALK_ENTRIES) {
137 partial = true
138 break
139 }
140 const childRel = rel ? rel + '/' + entry.name : entry.name
141 if (entry.kind === 'dir') {
142 if (depth + 1 > WALK_DEPTH) partial = true
143 else if (isTrackedPath(childRel + '/x', extra)) queue.push({ rel: childRel, depth: depth + 1 })
144 } else if (entry.kind === 'file' && isTrackedPath(childRel, extra)) {
145 files.push({ rel: childRel, sig: entry.mtimeMs + ':' + entry.size })
146 }
147 }
148 if (partial && seen > MAX_WALK_ENTRIES) break
149 }
150 const map = {}
151 for (const f of files) map[f.rel] = f.sig
152 const lines = Object.keys(map).sort().map((k) => k + '\t' + map[k])
153 return { sig: { hash: hashLines(lines), count: files.length, partial }, map, head: '' }
154}
155
156async function scanRoot($, root, extra, force) {
157 const now = Date.now()
158 const last = lastScans.get(root)
159 if (!force && last && now - last.at < SCAN_THROTTLE_MS) return { ...last, reused: true }
160 const top = await gitTop($, root)
161 let scanned = null
162 try {
163 scanned = top ? await scanGit($, root, extra) : null
164 } catch {
165 scanned = null
166 }
167 if (!scanned) scanned = await scanWalk($, root, extra)
168 const next = { at: now, ...scanned }
169 lastScans.set(root, next)
170 return { ...next, previous: last || null }
171}
172
173function describeChange(previous, current) {
174 if (!previous) return null
175 const files = []
176 for (const rel of Object.keys(current.map)) if (previous.map[rel] !== current.map[rel]) files.push(rel)
177 for (const rel of Object.keys(previous.map)) if (!(rel in current.map)) files.push(rel)
178 if (files.length) {
179 return { hash: previous.sig.hash, reason: staleReasonFor(files.map(categorizePath)), files }
180 }
181 if (previous.head !== current.head) {
182 return { hash: previous.sig.hash, reason: 'The Git commit changed after this check completed.', files: [] }
183 }
184 return null
185}
186
187// Rescans the roots that hold a result worth protecting and marks changed ones Stale.
188async function scanActive($, extra, force) {
189 const ledger = await read($, ledgerAtom)
190 for (const root of activeRoots(ledger).slice(0, 5)) {
191 const scan = await scanRoot($, root, extra, force)
192 if (scan.reused || !scan.sig) continue
193 const detail = describeChange(scan.previous, scan)
194 await update($, ledgerAtom, (value) => applyScan(value, root, scan.sig, Date.now(), detail || { hash: '' }))
195 }
196}
197
198// --- Tool calls ------------------------------------------------------------------------------------
199
200// Claude Code runs commands with the Bash tool or, on Windows, the PowerShell tool. Their results
201// are understood. Any other tool that takes a "command" (a future shell tool, an MCP terminal) is
202// "untrusted": a check seen there is shown as Unknown with the reason, never silently ignored.
203const FILE_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
204function shellKind(e) {
205 if (e.tool === 'Bash' || e.tool === 'PowerShell') return 'trusted'
206 if (typeof e.command === 'string' && !FILE_TOOLS.includes(e.tool)) return 'untrusted'
207 return null
208}
209
210async function sessionCwd($) {
211 try {
212 return normalizePath(await $.session.cwd())
213 } catch {
214 return ''
215 }
216}
217
218async function beginTool($, e, custom, extra) {
219 const kind = shellKind(e)
220 if (!kind || typeof e.command !== 'string') return null
221 const cwd = await sessionCwd($)
222 const parsed = analyzeCommand(e.command, cwd, custom, { powershell: e.tool === 'PowerShell' })
223 if (kind === 'untrusted') parsed.untrustedTool = e.tool
224 const ctx = { cwd, parsed, checks: [], trusted: kind === 'trusted' }
225 if (!parsed.checks.length) return ctx
226
227 const before = await read($, ledgerAtom)
228 const roots = new Map()
229 for (const check of parsed.checks) {
230 const root = (await gitTop($, check.location)) || check.location
231 if (!roots.has(root)) roots.set(root, (await scanRoot($, root, extra, true)).sig)
232 ctx.checks.push({ check, root, key: recordKey(check.location, check.kind, check.scope), previous: before.records[recordKey(check.location, check.kind, check.scope)] || null, startedAt: Date.now() })
233 }
234 // Give simultaneous checks distinct start stamps so a newer run can be told from an older one.
235 ctx.checks.forEach((c, i) => {
236 c.startedAt += i
237 })
238 await update($, ledgerAtom, (value) => {
239 let next = value
240 for (const c of ctx.checks) next = beginRecord(next, { ...c.check, together: ctx.checks.length }, roots.get(c.root), c.root, c.startedAt)
241 return next
242 })
243 ctx.roots = roots
244 return ctx
245}
246
247async function abortTool($, ctx) {
248 if (!ctx || !ctx.checks.length) return
249 await update($, ledgerAtom, (value) => {
250 let next = value
251 for (const c of ctx.checks) next = finishRecord(next, c.key, c.startedAt, { status: 'unknown', reason: 'The tool call failed before it reported a result.', exitCode: null, summary: '', endSig: null, now: Date.now() })
252 return next
253 })
254}
255
256async function endTool($, e, ctx, result, extra) {
257 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'MultiEdit' || e.tool === 'NotebookEdit') {
258 if (!result || result.deny || result.isError) return
259 const raw = e.file_path || e.notebook_path
260 if (typeof raw !== 'string') return
261 const cwd = await sessionCwd($)
262 const path = resolveDir(cwd, raw) || normalizePath(raw)
263 await update($, ledgerAtom, (value) => applyEdit(value, path, Date.now(), extra))
264 return
265 }
266 if (!shellKind(e) || !ctx) return
267
268 const denied = !result || typeof result.deny === 'string'
269 if (ctx.checks.length) {
270 if (denied) {
271 await update($, ledgerAtom, (value) => {
272 let next = value
273 for (const c of ctx.checks) next = restoreRecord(next, c.key, c.previous)
274 return next
275 })
276 return
277 }
278 const r = result.result && typeof result.result === 'object' ? result.result : null
279 const summary = r ? tailOutput(r.stdout, r.stderr) : tailOutput(result.text, '')
280 const endSigs = new Map()
281 for (const c of ctx.checks) {
282 if (!endSigs.has(c.root)) endSigs.set(c.root, await scanRoot($, c.root, extra, true))
283 }
284 const now = Date.now()
285 await update($, ledgerAtom, (value) => {
286 let next = value
287 for (const c of ctx.checks) {
288 const verdict = classifyOutcome(ctx.parsed, c.check, result)
289 const scan = endSigs.get(c.root)
290 const start = value.records[c.key] && value.records[c.key].startSig
291 const duringFiles = []
292 if (start && scan && scan.sig && start.hash !== scan.sig.hash && scan.previous && scan.previous.map) {
293 for (const rel of Object.keys(scan.map)) if (scan.previous.map[rel] !== scan.map[rel]) duringFiles.push(rel)
294 }
295 next = finishRecord(next, c.key, c.startedAt, {
296 ...verdict,
297 summary,
298 endSig: scan ? scan.sig : null,
299 now,
300 duringFiles: duringFiles.slice(0, 5),
301 duringCategories: duringFiles.map(categorizePath),
302 })
303 }
304 return next
305 })
306 return
307 }
308
309 // A shell command that is not a check: it may have edited files. Only the tools we understand
310 // tell us anything about that.
311 if (denied || !ctx.trusted) return
312 const r = result.result && typeof result.result === 'object' ? result.result : null
313 const diff = r && r.bashEditDiff
314 const changed = diff && Array.isArray(diff.changedFiles) ? diff.changedFiles.slice(0, 200) : []
315 if (changed.length) {
316 await update($, ledgerAtom, (value) => {
317 let next = value
318 for (const p of changed) next = applyEdit(next, p, Date.now(), extra)
319 return next
320 })
321 } else if (diff && (diff.unavailable || diff.skipped) && result.isReadOnly !== true) {
322 const ledger = await read($, ledgerAtom)
323 const root = (await gitTop($, ctx.cwd)) || ctx.cwd
324 if (root && activeRoots(ledger).length) {
325 await update($, ledgerAtom, (value) => applyUnnamedChange(value, root, Date.now(), 'A shell command may have changed files after this check completed.'))
326 }
327 } else if (result.isReadOnly !== true) {
328 await scanActive($, extra, false)
329 }
330}
331
332// --- "Prepare checks" ---------------------------------------------------------------------------------
333
334async function detectScripts($, cwd) {
335 const out = []
336 try {
337 const pkg = JSON.parse(await $.fs.read(cwd + '/package.json'))
338 const scripts = pkg && typeof pkg.scripts === 'object' ? pkg.scripts : {}
339 let pm = 'npm'
340 if (await $.fs.exists(cwd + '/pnpm-lock.yaml')) pm = 'pnpm'
341 else if (await $.fs.exists(cwd + '/yarn.lock')) pm = 'yarn'
342 else if ((await $.fs.exists(cwd + '/bun.lockb')) || (await $.fs.exists(cwd + '/bun.lock'))) pm = 'bun'
343 const run = (name) => (pm === 'yarn' ? 'yarn ' + name : pm + ' run ' + name)
344 if (scripts.test) out.push({ kind: 'test', command: pm === 'npm' || pm === 'pnpm' || pm === 'bun' ? pm + ' test' : 'yarn test' })
345 const type = ['typecheck', 'type-check', 'check-types', 'tsc'].find((n) => scripts[n])
346 if (type) out.push({ kind: 'typecheck', command: run(type) })
347 if (scripts.build) out.push({ kind: 'build', command: run('build') })
348 } catch {
349 // no package.json, or not readable
350 }
351 return out
352}
353
354async function prepareChecks($) {
355 const cwd = await sessionCwd($)
356 const ledger = await read($, ledgerAtom)
357 const items = []
358 const seen = new Set()
359 const covered = new Set()
360 for (const rec of Object.values(ledger.records)) {
361 if (cwd && !(isUnder(rec.location, cwd) || isUnder(cwd, rec.location))) continue
362 const shown = displayStatus(rec)
363 if (shown.status === STATUS.PASSED) {
364 covered.add(rec.kind)
365 continue
366 }
367 const k = rec.location + '|' + rec.command
368 if (seen.has(k)) continue
369 seen.add(k)
370 items.push({ command: rec.command, location: relativeTo(rec.location, cwd) || '.' })
371 }
372 for (const s of await detectScripts($, cwd)) {
373 if (covered.has(s.kind) || items.some((i) => i.command === s.command)) continue
374 const has = Object.values(ledger.records).some((r) => r.kind === s.kind && (!cwd || isUnder(r.location, cwd) || isUnder(cwd, r.location)))
375 if (!has) items.push({ command: s.command, location: '.' })
376 }
377 const filled = await $.prompt.fill({ text: preparePrompt(items), mode: 'replace' })
378 if (filled && filled.isFilled) await $.ui.close({ id: PANE_ID })
379 else $.ui.toast('Could not fill the prompt box. Press Esc and try again.')
380}
381
382// Set SHIP_CHECK_DEBUG to a file path to write what the band was asked to draw (props, size, records).
383async function debugRender($, e, ledger) {
384 try {
385 const path = await $.env.get('SHIP_CHECK_DEBUG')
386 if (!path) return
387 const info = { at: new Date().toISOString(), surface: e.surface, props: e.props, viewport: e.viewport, records: Object.keys(ledger.records) }
388 await $.fs.write(path, JSON.stringify(info, null, 2))
389 } catch {
390 // debugging aid only
391 }
392}
393
394// --- Registration ----------------------------------------------------------------------------------
395
396export function register(on, options) {
397 const custom = parseCustomChecks(options && options.extra_checks)
398 const customKinds = custom.map((c) => c.kind)
399 const extra = options && Array.isArray(options.extra_ignored_paths) ? options.extra_ignored_paths : []
400
401 on('session.start', async ($, e, next) => {
402 try {
403 await $.command.register({ name: 'ship-check', description: 'Open the Ship Check panel', immediate: true })
404 await update($, ledgerAtom, (value) => settleRunning(value))
405 } catch {
406 // the board still works without the command
407 }
408 return next(e)
409 })
410
411 on('tool.call', async ($, e, next) => {
412 let ctx = null
413 try {
414 ctx = await beginTool($, e, custom, extra)
415 } catch {
416 ctx = null
417 }
418 let result
419 try {
420 result = await next(e)
421 } catch (err) {
422 try {
423 await abortTool($, ctx)
424 } catch {
425 // ignore
426 }
427 throw err
428 }
429 try {
430 await endTool($, e, ctx, result, extra)
431 } catch {
432 // a bookkeeping failure must never change the tool's result
433 }
434 return result
435 })
436
437 // A person may have edited files between turns.
438 on('prompt.submit', async ($, e, next) => {
439 try {
440 await scanActive($, extra, true)
441 } catch {
442 // ignore
443 }
444 return next(e)
445 })
446
447 on('session.measure', async ($, e, next) => {
448 const out = await next(e)
449 try {
450 await scanActive($, extra, false)
451 } catch {
452 // ignore
453 }
454 return out
455 })
456
457 on('command.run', { command: 'ship-check' }, async ($) => {
458 await $.ui.open({ id: PANE_ID, title: 'Ship Check', focus: true, closeOnEscape: true })
459 return {}
460 })
461
462 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
463 const ledger = await read($, ledgerAtom)
464 await debugRender($, e, ledger)
465 if (!hasAnyRecord(ledger)) return next(e)
466 const els = $.ui.resolve(e)
467 const cwd = await sessionCwd($)
468 const openPanel = () => $.ui.open({ id: PANE_ID, title: 'Ship Check', focus: true, closeOnEscape: true })
469 const strip = buildStrip(els, ledger, cwd, customKinds, openPanel)
470 // Other mods may draw here too. The band is one row tall, so sit side by side instead of stacking.
471 const theirs = await next(e)
472 if (!theirs) return strip
473 return els.Box({ flexDirection: 'row', columnGap: 3, children: [strip, theirs] })
474 })
475
476 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
477 if (e.requestId !== PANE_ID) return next(e)
478 const ledger = await read($, ledgerAtom)
479 const view = await read($, viewAtom)
480 const els = $.ui.resolve(e)
481 const cwd = await sessionCwd($)
482 return buildPane(els, {
483 ledger,
484 cwd,
485 expanded: view.expanded,
486 customKinds,
487 onToggle: (key) => update($, viewAtom, (v) => ({ expanded: v.expanded === key ? null : key })),
488 onPrepare: () => prepareChecks($),
489 })
490 })
491}
492hooks/lib/commands.js 309 lines1// Recognizes test, build and type-check commands. analyze.js builds on this to read whole command lines.
2// Pure functions: no Claude Code API is used here.
3
4import { resolveDir } from './paths.js'
5
6// Display names for the built-in kinds; custom kinds are capitalized from their id.
7export const KIND_LABELS = { test: 'Tests', typecheck: 'Types', build: 'Build' }
8export const DEFAULT_KINDS = ['test', 'typecheck', 'build']
9
10export function kindLabel(kind) {
11 if (KIND_LABELS[kind]) return KIND_LABELS[kind]
12 return kind.charAt(0).toUpperCase() + kind.slice(1)
13}
14
15// Splits a command line on `&&`. `simple` is false when anything else could change which command
16// decides the exit status: pipes, `;`, `||`, backgrounding, subshells, substitutions, newlines.
17export function tokenizeChain(cmd, ps = false) {
18 const segments = []
19 let tokens = []
20 let cur = ''
21 let has = false
22 let simple = true
23 let quote = null
24
25 const pushToken = () => {
26 if (has) tokens.push(cur)
27 cur = ''
28 has = false
29 }
30 const pushSegment = () => {
31 pushToken()
32 if (tokens.length) segments.push(tokens)
33 tokens = []
34 }
35
36 for (let i = 0; i < cmd.length; i++) {
37 const c = cmd[i]
38 if (quote) {
39 if (c === quote) quote = null
40 else if (c === '\\' && quote === '"' && i + 1 < cmd.length) cur += cmd[++i]
41 else cur += c
42 has = true
43 continue
44 }
45 if (c === "'" || c === '"') {
46 quote = c
47 has = true
48 continue
49 }
50 if (c === '\\' && i + 1 < cmd.length && /[\s"'\\$&|;]/.test(cmd[i + 1])) {
51 cur += cmd[++i]
52 has = true
53 continue
54 }
55 if (c === '\n' || c === '\r') {
56 simple = false
57 pushSegment()
58 continue
59 }
60 if (/\s/.test(c)) {
61 pushToken()
62 continue
63 }
64 if (c === '&') {
65 if (ps && !has && tokens.length === 0 && cmd[i + 1] !== '&') continue // the call operator: & "path"
66 if (cmd[i + 1] === '&') {
67 pushSegment()
68 i++
69 continue
70 }
71 if (cmd[i + 1] === '>' || cmd[i - 1] === '>') {
72 cur += c // a redirect such as 2>&1 or &>file
73 has = true
74 continue
75 }
76 simple = false // a single & runs the command in the background
77 pushSegment()
78 continue
79 }
80 if (c === '|') {
81 simple = false
82 if (cmd[i + 1] === '|') i++
83 pushSegment()
84 continue
85 }
86 if (c === ';') {
87 simple = false
88 pushSegment()
89 continue
90 }
91 if ((!ps && c === '`') || c === '(' || c === ')' || (c === '$' && cmd[i + 1] === '(')) {
92 simple = false
93 cur += c
94 has = true
95 continue
96 }
97 cur += c
98 has = true
99 }
100 if (quote) simple = false
101 pushSegment()
102 return { segments, simple }
103}
104
105// Drops leading `time`, `env`, VAR=value words, and redirections.
106function cleanTokens(tokens) {
107 const out = []
108 for (let i = 0; i < tokens.length; i++) {
109 const t = tokens[i]
110 if (/^\d*>>?(&\d+)?$/.test(t) || /^&>/.test(t)) {
111 i++ // the operator and its target
112 continue
113 }
114 if (/^\d*>>?\S/.test(t) || /^<\S/.test(t)) continue
115 out.push(t)
116 }
117 let start = 0
118 while (start < out.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(out[start]) || out[start] === 'time' || out[start] === 'env')) start++
119 return out.slice(start)
120}
121
122const WATCH_FLAG = /^--watch(All)?(=true)?$/
123const PM_NAMES = ['npm', 'pnpm', 'yarn', 'bun']
124
125export function kindForScript(name) {
126 if (!name) return null
127 if (/watch/i.test(name)) return null
128 if (/^(test|tests|unit|test:.+|tests:.+|unit:.+)$/i.test(name)) return 'test'
129 if (/^(typecheck|type-check|check-types|check:types|types|tsc|tsc:check|typecheck:.+|type-check:.+)$/i.test(name)) return 'typecheck'
130 if (/^(build|build:.+)$/i.test(name)) return 'build'
131 return null
132}
133
134function kindForTool(tool, args) {
135 if (tool === 'tsc') {
136 if (args.some((a) => /^-{1,2}noEmit$/i.test(a))) return { kind: 'typecheck' }
137 if (args.includes('--watch') || args.includes('-w')) return { kind: 'build', watch: true }
138 return { kind: 'build' }
139 }
140 if (tool === 'vitest') {
141 const run = args[0] === 'run' || args.includes('--run')
142 return { kind: 'test', watch: !run || args.some((a) => WATCH_FLAG.test(a)) }
143 }
144 if (tool === 'jest') return { kind: 'test', watch: args.some((a) => WATCH_FLAG.test(a)) }
145 return null
146}
147
148// Reads one package-manager invocation. Returns null when it is not a recognized check.
149function interpretPackageManager(pm, rest) {
150 const info = { pm, cwdArg: null, scope: '', watch: false, filtered: false, script: null, builtinTest: false }
151 let i = 0
152 let command = null
153 const take = () => rest[++i]
154
155 for (; i < rest.length; i++) {
156 const t = rest[i]
157 if (t === '--') break
158 if (t.startsWith('-')) {
159 let m
160 if ((m = /^--(prefix|cwd|dir)=(.*)$/.exec(t))) info.cwdArg = m[2]
161 else if (t === '--prefix' || t === '--cwd' || t === '--dir' || t === '-C') info.cwdArg = take()
162 else if ((m = /^--(workspace|filter)=(.*)$/.exec(t))) info.scope += (info.scope ? ',' : '') + m[2]
163 else if (t === '--workspace' || t === '-w' || t === '--filter' || t === '-F') {
164 const v = take()
165 if (v) info.scope += (info.scope ? ',' : '') + v
166 } else if (t === '-r' || t === '--recursive' || t === '--workspaces') info.scope += (info.scope ? ',' : '') + '*'
167 else if (WATCH_FLAG.test(t)) info.watch = true
168 continue
169 }
170 command = t
171 break
172 }
173 if (command === null) return null
174
175 let script = null
176 const after = rest.slice(i + 1)
177 const scriptArgs = []
178 let passthrough = false
179 const addScope = (v) => {
180 if (v) info.scope += (info.scope ? ',' : '') + v
181 }
182 const readScriptTokens = (list) => {
183 for (let k = 0; k < list.length; k++) {
184 const a = list[k]
185 if (a === '--') {
186 passthrough = true
187 continue
188 }
189 if (passthrough) {
190 info.filtered = true
191 if (WATCH_FLAG.test(a) || a === '-w') info.watch = true
192 continue
193 }
194 let m
195 if (WATCH_FLAG.test(a)) info.watch = true
196 else if ((m = /^--(workspace|filter)=(.*)$/.exec(a))) addScope(m[2])
197 else if (a === '--workspace' || a === '-w' || a === '--filter' || a === '-F') addScope(list[++k])
198 else if ((m = /^--(prefix|cwd|dir)=(.*)$/.exec(a))) info.cwdArg = m[2]
199 else if (a === '--prefix' || a === '--cwd' || a === '--dir' || a === '-C') info.cwdArg = list[++k]
200 else if (a === '-r' || a === '--recursive' || a === '--workspaces') addScope('*')
201 else if (!a.startsWith('-')) scriptArgs.push(a)
202 }
203 }
204
205 if (command === 'test' || command === 't' || command === 'tst') {
206 script = 'test'
207 if (pm === 'bun') info.builtinTest = true
208 readScriptTokens(after)
209 } else if (command === 'run' || command === 'run-script' || command === 'rum' || command === 'urn') {
210 // flags may sit between `run` and the script name
211 const idx = after.findIndex((a) => !a.startsWith('-'))
212 if (idx === -1) return null
213 for (const a of after.slice(0, idx)) {
214 if (WATCH_FLAG.test(a)) info.watch = true
215 }
216 script = after[idx]
217 readScriptTokens(after.slice(idx + 1))
218 } else if (command === 'workspace' && pm === 'yarn') {
219 info.scope += (info.scope ? ',' : '') + (after[0] || '')
220 const idx = after.slice(1).findIndex((a) => !a.startsWith('-'))
221 if (idx === -1) return null
222 script = after.slice(1)[idx]
223 readScriptTokens(after.slice(1 + idx + 1))
224 } else if (command === 'build' && pm === 'bun') {
225 script = 'build'
226 readScriptTokens(after)
227 } else if (pm !== 'npm' && kindForScript(command)) {
228 script = command // pnpm build, yarn typecheck, bun typecheck
229 readScriptTokens(after)
230 } else if (command === 'exec' || command === 'x' || command === 'dlx') {
231 const tool = after[0]
232 const toolInfo = tool ? kindForTool(tool, after.slice(1)) : null
233 if (!toolInfo) return null
234 return { ...info, kind: toolInfo.kind, watch: info.watch || !!toolInfo.watch, script: tool }
235 } else {
236 return null
237 }
238
239 const kind = kindForScript(script)
240 if (!kind) return null
241 if (scriptArgs.length) info.filtered = true // e.g. `pnpm test src/a.test.ts`
242 return { ...info, kind, script }
243}
244
245// Interprets one `&&` segment: a directory change, a check, or something else (null).
246export function interpretSegment(rawTokens, customChecks, ps = false) {
247 const tokens = cleanTokens(rawTokens)
248 if (tokens.length === 0) return null
249
250 if (/^(cd|chdir|sl|set-location)$/i.test(tokens[0]) || (ps && /^(push-location|pushd)$/i.test(tokens[0]))) {
251 // Drop flags: PowerShell's -Path style, and cmd's `cd /d dir`.
252 const args = tokens.slice(1).filter((t) => !/^\/[a-z]$/i.test(t) && !/^-(path|literalpath|passthru|stackname)$/i.test(t) && !(t.startsWith('-') && t.length > 1))
253 return { type: 'cd', dir: args[0] || null }
254 }
255 if (ps && /^(pop-location|popd)$/i.test(tokens[0])) return { type: 'cd', dir: null }
256 if (!ps && (tokens[0] === 'pushd' || tokens[0] === 'popd')) return { type: 'unsupported-cd' }
257
258 const text = tokens.join(' ')
259 for (const custom of customChecks || []) {
260 if (text === custom.command || text.startsWith(custom.command + ' ')) {
261 const rest = text.slice(custom.command.length).trim()
262 return {
263 type: 'check',
264 kind: custom.kind,
265 pm: 'custom',
266 cwdArg: null,
267 scope: '',
268 watch: /(^|\s)--watch(All)?(\s|$)/.test(rest),
269 filtered: false,
270 text,
271 }
272 }
273 }
274
275 let head = tokens[0]
276 let rest = tokens.slice(1)
277 if (head === 'npx' || head === 'bunx' || head === 'pnpx') {
278 const toolInfo = rest[0] ? kindForTool(rest[0], rest.slice(1)) : null
279 if (!toolInfo) return null
280 return { type: 'check', kind: toolInfo.kind, pm: head, cwdArg: null, scope: '', watch: !!toolInfo.watch, filtered: false, text }
281 }
282 if (head === 'tsc' || head === 'vitest' || head === 'jest') {
283 const toolInfo = kindForTool(head, rest)
284 return { type: 'check', kind: toolInfo.kind, pm: 'direct', cwdArg: null, scope: '', watch: !!toolInfo.watch, filtered: false, text }
285 }
286 if (!PM_NAMES.includes(head)) return null
287 const found = interpretPackageManager(head, rest)
288 if (!found) return null
289 return { type: 'check', ...found, text }
290}
291
292// Parses user config lines such as `lint=npm run lint` into custom checks.
293export function parseCustomChecks(lines) {
294 const out = []
295 for (const line of Array.isArray(lines) ? lines : []) {
296 if (typeof line !== 'string') continue
297 const eq = line.indexOf('=')
298 if (eq < 1) continue
299 const kind = line.slice(0, eq).trim().toLowerCase()
300 const command = line
301 .slice(eq + 1)
302 .trim()
303 .split(/\s+/)
304 .join(' ')
305 if (!/^[a-z0-9-]{1,16}$/.test(kind) || !command) continue
306 out.push({ kind, command })
307 }
308 return out
309}hooks/lib/analyze.js 306 lines1// Reads a whole shell command line and decides, for every check inside it, how far the tool's exit
2// status can be trusted. Pure functions: no Claude Code API is used here.
3//
4// What the tools report, measured against real sessions:
5// Bash the exit status of the last statement.
6// PowerShell `$LASTEXITCODE`: the exit status of the last native program that ran, however many
7// cmdlets, pipelines into cmdlets, or `if` blocks follow it.
8//
9// Each check gets a `mode`:
10// exact the tool's exit status is this check's exit status.
11// success-only a clean finish proves this check passed, but a failure could belong to another
12// command in the same `&&` chain.
13// echo the shell printed the status (`echo "EXIT: $?"`), so it is read from the output.
14// none the exit status cannot be tied to this check.
15
16import { interpretSegment, tokenizeChain } from './commands.js'
17import { resolveDir } from './paths.js'
18
19const OR_FLAG = /\|\|/
20const BACKGROUND = /(?<![&>])&(?![&>])/
21const SUBSHELL_SH = /\$\(|\(|\)|`/
22const SUBSHELL_PS = /\$\(|\(|\)/
23const CMDLET = /^[A-Za-z]+-[A-Za-z]+/
24const UNSAFE_CMDLET = /^(invoke-|start-process|start-job|new-object|add-type)/i
25const PS_ALIASES = new Set(['select', 'where', 'foreach', 'sort', 'group', 'measure', 'tee', 'ft', 'fl', 'more', 'oh', 'ogv'])
26
27// Replaces the inside of quoted strings with `_`, keeping every index the same.
28function stripQuoted(s) {
29 let out = ''
30 let q = null
31 for (let i = 0; i < s.length; i++) {
32 const c = s[i]
33 if (q) {
34 if (c === q) {
35 q = null
36 out += c
37 } else out += '_'
38 } else {
39 out += c
40 if (c === '"' || c === "'") q = c
41 }
42 }
43 return { text: out, unterminated: q !== null }
44}
45
46// Splits `orig` where `match(stripped, i)` returns a separator length, outside () {} [].
47function splitBy(orig, stripped, match) {
48 const out = []
49 let start = 0
50 let depth = 0
51 for (let i = 0; i < stripped.length; i++) {
52 const c = stripped[i]
53 if (c === '(' || c === '{' || c === '[') depth++
54 else if (c === ')' || c === '}' || c === ']') depth = Math.max(0, depth - 1)
55 if (depth === 0) {
56 const len = match(stripped, i)
57 if (len > 0) {
58 out.push(orig.slice(start, i))
59 start = i + len
60 i += len - 1
61 }
62 }
63 }
64 out.push(orig.slice(start))
65 return out.map((s) => s.trim()).filter(Boolean)
66}
67
68const matchStatementEnd = (s, i) => (s[i] === ';' || s[i] === '\n' || s[i] === '\r' ? 1 : 0)
69const matchAndAnd = (s, i) => (s[i] === '&' && s[i + 1] === '&' ? 2 : 0)
70const matchPipe = (s, i) => (s[i] === '|' && s[i + 1] !== '|' && s[i - 1] !== '|' ? 1 : 0)
71
72function isCmdletStage(stage) {
73 const first = stage.trim().split(/\s+/)[0] || ''
74 if (UNSAFE_CMDLET.test(first)) return false
75 return CMDLET.test(first) || PS_ALIASES.has(first.toLowerCase())
76}
77
78// One statement: its `&&` chain, each segment's pipeline, and flags for forms we cannot follow.
79function parseStatement(text, ps, customChecks) {
80 const { text: stripped, unterminated } = stripQuoted(text)
81 const s = ps ? stripped.replace(/^&\s+/, ' ') : stripped
82 const flags = {
83 or: OR_FLAG.test(s),
84 bg: BACKGROUND.test(s),
85 sub: (ps ? SUBSHELL_PS : SUBSHELL_SH).test(s),
86 unterminated,
87 }
88 const segTexts = splitBy(text, stripped, matchAndAnd)
89 const chain = segTexts.map((segText) => {
90 const segStripped = stripQuoted(segText).text
91 const stages = splitBy(segText, segStripped, matchPipe)
92 const head = stages[0] || ''
93 const tokens = tokenizeChain(head, ps).segments[0] || []
94 const interp = interpretSegment(tokens, customChecks, ps)
95 let kind = 'other'
96 if (interp && interp.type === 'cd') kind = 'cd'
97 else if (interp && interp.type === 'check') kind = 'check'
98 return {
99 text: segText,
100 stages,
101 pipe: stages.length > 1,
102 nativePipe: ps && stages.slice(1).some((st) => !isCmdletStage(st)),
103 interp,
104 kind,
105 }
106 })
107 return { text, flags, chain }
108}
109
110// --- Statements that cannot change the exit status ---------------------------------------------
111
112function braceBodies(s) {
113 const bodies = []
114 let depth = 0
115 let start = -1
116 for (let i = 0; i < s.length; i++) {
117 if (s[i] === '{') {
118 if (depth === 0) start = i + 1
119 depth++
120 } else if (s[i] === '}') {
121 depth = Math.max(0, depth - 1)
122 if (depth === 0 && start >= 0) {
123 bodies.push(s.slice(start, i))
124 start = -1
125 }
126 }
127 }
128 return bodies
129}
130
131// True when a PowerShell statement runs no native program, so `$LASTEXITCODE` stays as it was.
132export function psStatementNonNative(stmt, customChecks) {
133 const s = stmt.trim()
134 if (!s) return true
135 const { text: st } = stripQuoted(s)
136 if (/^["']/.test(s) && !/[|&]/.test(st)) return true
137 if (/^\$(\?|LASTEXITCODE)\s*$/i.test(s)) return true
138 if (/^\$\w+\s*=/.test(s)) return false // an assignment may hold a native program
139 if (/^exit\s+\$LASTEXITCODE\s*$/i.test(s)) return true
140 const first = (s.split(/\s+/)[0] || '').toLowerCase()
141
142 if (/^(if|elseif)\b/.test(first) || first === 'if(') {
143 const open = st.indexOf('(')
144 if (open < 0) return false
145 let depth = 0
146 let close = -1
147 for (let i = open; i < st.length; i++) {
148 if (st[i] === '(') depth++
149 else if (st[i] === ')') {
150 depth--
151 if (depth === 0) {
152 close = i
153 break
154 }
155 }
156 }
157 if (close < 0) return false
158 const condition = st.slice(open + 1, close)
159 if (!/^[\w\s$?!.\-='"<>]*$/.test(condition)) return false
160 const bodies = braceBodies(s.slice(close + 1))
161 if (!bodies.length) return false
162 return bodies.every((b) => splitBy(b, stripQuoted(b).text, matchStatementEnd).every((x) => psStatementNonNative(x, customChecks)))
163 }
164
165 const tokens = tokenizeChain(s, true).segments[0] || []
166 const interp = interpretSegment(tokens, customChecks, true)
167 if (interp && interp.type === 'cd') return true
168 if (interp && interp.type === 'check') return false
169 if (/\$\(|\(/.test(st)) return false
170 if (/^(write-host|write-output|write-information|write-warning|write-verbose|echo)\b/i.test(s)) return true
171 if (UNSAFE_CMDLET.test(first)) return false
172 if (CMDLET.test(first)) {
173 // every pipeline stage must be a cmdlet too
174 return splitBy(s, st, matchPipe).every(isCmdletStage)
175 }
176 return false
177}
178
179// `echo "EXIT: $?"` (or `${PIPESTATUS[0]}` after a pipeline): the shell printed the status itself.
180export function exitEchoSh(stmt, afterPipe) {
181 const s = stmt.trim()
182 if (!/^(echo|printf)\b/.test(s)) return false
183 const { text: st } = stripQuoted(s)
184 if (/[|&;]|\$\(|`/.test(st)) return false
185 if (/\$\{PIPESTATUS\[0\]\}/.test(s)) return true
186 return !afterPipe && /\$\?/.test(s)
187}
188
189// --- Wrappers: cmd /c "..." and sh -c "..." --------------------------------------------------------
190
191function unwrap(text) {
192 const t = text.trim()
193 let m = /^cmd(?:\.exe)?\s+((?:\/[a-z]\s+)*)\/c\s+([\s\S]+)$/i.exec(t)
194 if (m) {
195 let inner = m[2].trim()
196 if (inner.startsWith('"')) {
197 const close = inner.indexOf('"', 1)
198 if (close !== inner.length - 1) return null // text after the quoted command: not a plain wrapper
199 inner = inner.slice(1, -1)
200 }
201 return { inner, shell: 'cmd' }
202 }
203 m = /^(?:bash|sh|zsh)\s+-[a-z]*c\s+(['"])([\s\S]*)\1$/i.exec(t)
204 if (m) return { inner: m[2], shell: 'sh' }
205 return null
206}
207
208// In cmd, a lone `&` separates commands like `;` does elsewhere.
209function cmdAmpersands(text) {
210 const { text: st } = stripQuoted(text)
211 let out = ''
212 for (let i = 0; i < text.length; i++) {
213 if (st[i] === '&' && st[i + 1] !== '&' && st[i - 1] !== '&' && st[i - 1] !== '>' && st[i + 1] !== '>') out += ';'
214 else out += text[i]
215 }
216 return out
217}
218
219// --- The analysis ---------------------------------------------------------------------------------------
220
221function decideMode(check, statements, ps) {
222 const none = (reason) => ({ mode: 'none', reason })
223 const st = statements[check.si]
224 const seg = st.chain[check.ci]
225 if (st.flags.unterminated) return none('The command has an unterminated quote, so it cannot be read reliably.')
226 if (st.flags.or || st.flags.bg || st.flags.sub) return none('This compound command does not show which part decided the exit status.')
227
228 const later = statements.slice(check.si + 1)
229 const lastSegment = check.ci === st.chain.length - 1
230 const earlierOnlyCd = st.chain.slice(0, check.ci).every((s) => s.kind === 'cd')
231 const successOnly = (reason) => ({ mode: 'success-only', reason })
232
233 if (ps) {
234 if (seg.nativePipe) return none('The check is piped into another program, so the exit status may belong to that program.')
235 if (!later.every((s) => psStatementNonNative(s.text))) return none('Another command ran after this check, so the exit status may belong to it.')
236 if (!lastSegment) return successOnly('A later command in this && chain decided the exit status.')
237 if (!earlierOnlyCd) return successOnly('An earlier command in this && chain may have failed instead of this check.')
238 return { mode: 'exact', reason: null }
239 }
240
241 if (later.length) {
242 if (later.every((s) => exitEchoSh(s.text, seg.pipe))) {
243 if (!lastSegment || !earlierOnlyCd) return none('The printed exit status may belong to another command in this chain.')
244 return { mode: 'echo', reason: null }
245 }
246 return none('A later command decided the exit status.')
247 }
248 if (seg.pipe) return none('The check is piped into another command, so the exit status may belong to that command.')
249 if (!lastSegment) return successOnly('A later command in this && chain decided the exit status.')
250 if (!earlierOnlyCd) return successOnly('An earlier command in this && chain may have failed instead of this check.')
251 return { mode: 'exact', reason: null }
252}
253
254export function analyzeCommand(cmd, baseDir, customChecks, options = {}) {
255 let ps = options.powershell === true
256 let text = String(cmd)
257 const wrapped = unwrap(text)
258 if (wrapped) {
259 text = wrapped.shell === 'cmd' ? cmdAmpersands(wrapped.inner) : wrapped.inner
260 ps = false
261 }
262
263 const result = { checks: [], hasWatch: false, unresolvedDir: false, shell: ps ? 'powershell' : wrapped ? wrapped.shell : 'sh' }
264 const { text: stripped } = stripQuoted(text)
265 const statements = splitBy(text, stripped, matchStatementEnd).map((s) => parseStatement(s, ps, customChecks))
266
267 let dir = baseDir || null
268 let cdSeen = false
269 statements.forEach((st, si) => {
270 st.chain.forEach((seg, ci) => {
271 const info = seg.interp
272 if (!info) return
273 if (info.type === 'unsupported-cd') {
274 st.flags.sub = true
275 return
276 }
277 if (info.type === 'cd') {
278 dir = info.dir ? resolveDir(dir, info.dir) : null
279 cdSeen = true
280 return
281 }
282 let location = dir
283 if (info.cwdArg) location = resolveDir(dir, info.cwdArg)
284 if (!location) {
285 result.unresolvedDir = true
286 return
287 }
288 if (info.watch) result.hasWatch = true
289 result.checks.push({
290 si,
291 ci,
292 kind: info.kind,
293 location,
294 scope: info.scope || '',
295 watch: !!info.watch,
296 filtered: !!info.filtered,
297 command: info.text,
298 cdUsed: cdSeen,
299 })
300 })
301 })
302
303 for (const check of result.checks) Object.assign(check, decideMode(check, statements, ps))
304 return result
305}
306hooks/lib/paths.js 62 lines1// Path helpers. Everything is normalized to forward slashes with a lower-case drive letter,
2// so a Windows path and its Git Bash spelling (/c/Users/me) compare equal.
3
4export function normalizePath(p) {
5 if (typeof p !== 'string' || p === '') return ''
6 let s = p.replace(/\\/g, '/')
7 // Git Bash spells C:\Users as /c/Users
8 const bash = /^\/([a-zA-Z])(\/|$)/.exec(s)
9 if (bash) s = bash[1] + ':/' + s.slice(3)
10 const drive = /^([a-zA-Z]):(\/|$)/.exec(s)
11 let prefix = ''
12 if (drive) {
13 prefix = drive[1].toLowerCase() + ':'
14 s = s.slice(2)
15 }
16 const absolute = s.startsWith('/')
17 const out = []
18 for (const part of s.split('/')) {
19 if (part === '' || part === '.') continue
20 if (part === '..') {
21 if (out.length && out[out.length - 1] !== '..') out.pop()
22 else if (!absolute) out.push('..')
23 continue
24 }
25 out.push(part)
26 }
27 return prefix + (absolute || prefix ? '/' : '') + out.join('/')
28}
29
30export function isAbsolute(p) {
31 return typeof p === 'string' && (/^[a-zA-Z]:[\\/]/.test(p) || p.startsWith('/') || p.startsWith('\\'))
32}
33
34// Resolves `rel` against `base`. Returns null for spellings we cannot resolve (~, $VAR, -).
35export function resolveDir(base, rel) {
36 if (typeof rel !== 'string' || rel === '') return null
37 if (rel.startsWith('~') || rel.includes('$') || rel === '-') return null
38 if (isAbsolute(rel)) return normalizePath(rel)
39 if (!base) return null
40 return normalizePath(base + '/' + rel)
41}
42
43export function isUnder(child, parent) {
44 if (!child || !parent) return false
45 if (child === parent) return true
46 const p = parent.endsWith('/') ? parent : parent + '/'
47 return child.startsWith(p)
48}
49
50export function relativeTo(child, parent) {
51 if (child === parent) return ''
52 return child.startsWith(parent + '/') ? child.slice(parent.length + 1) : child
53}
54
55// A short spelling of a project location for the UI.
56export function displayLocation(location, cwd) {
57 if (!location) return 'unknown location'
58 if (cwd && location === cwd) return location.split('/').pop() || location
59 if (cwd && isUnder(location, cwd)) return './' + relativeTo(location, cwd)
60 return location
61}
62hooks/lib/model.js 438 lines1// The ledger of checks and the rules that turn tool results into statuses.
2// Pure functions over plain JSON: nothing here calls the Claude Code API.
3
4import { isUnder, relativeTo, normalizePath } from './paths.js'
5import { DEFAULT_KINDS, kindLabel } from './commands.js'
6
7export const MAX_RUNS = 40
8export const MAX_SUMMARY_CHARS = 1500
9export const MAX_STALE_FILES = 5
10
11export const STATUS = {
12 NOT_RUN: 'Not run',
13 RUNNING: 'Running',
14 PASSED: 'Passed',
15 FAILED: 'Failed',
16 STALE: 'Stale',
17 UNKNOWN: 'Unknown',
18}
19
20export function emptyLedger() {
21 return { records: {}, runs: [] }
22}
23
24export function recordKey(location, kind, scope) {
25 return location + '|' + kind + '|' + (scope || '')
26}
27
28// --- Which files count -------------------------------------------------------------------------
29
30const EXCLUDED_DIRS = new Set([
31 'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next', '.nuxt', '.svelte-kit',
32 '.turbo', '.cache', '.parcel-cache', '.vite', '.vitepress', '.yarn', '.pnpm-store', 'coverage',
33 '.nyc_output', 'target', '__pycache__', '.venv', 'venv', '.claude', '.idea', '.vscode',
34 '.gradle', 'storybook-static', 'playwright-report', 'test-results',
35])
36const EXCLUDED_FILE = /(\.log|\.tsbuildinfo|\.tmp|\.swp|\.swo|~)$|^\.DS_Store$|^Thumbs\.db$/
37const DEPENDENCY_FILE = /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|yarn\.lock|bun\.lockb?|\.npmrc|\.yarnrc(\.yml)?|\.nvmrc|\.node-version|\.tool-versions)$/
38const CONFIG_FILE = /^(tsconfig[\w.-]*\.json|jsconfig\.json|[\w.-]*\.config\.[cm]?[jt]s|\.eslintrc[\w.]*|\.prettierrc[\w.]*|babel\.config[\w.]*|\.babelrc[\w.]*|\.env[\w.]*|vite\.config[\w.]*|Makefile|Dockerfile)$/
39
40// `rel` uses forward slashes and is relative to the project root. `extra` is the user's ignore list.
41export function isTrackedPath(rel, extra) {
42 if (!rel || rel.startsWith('../')) return false
43 const parts = rel.split('/')
44 for (let i = 0; i < parts.length - 1; i++) if (EXCLUDED_DIRS.has(parts[i])) return false
45 if (EXCLUDED_DIRS.has(parts[parts.length - 1]) && parts.length === 1) return false
46 if (EXCLUDED_FILE.test(parts[parts.length - 1])) return false
47 for (const raw of extra || []) {
48 const pat = String(raw).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
49 if (!pat) continue
50 if (rel === pat || rel.startsWith(pat + '/') || parts.includes(pat)) return false
51 }
52 return true
53}
54
55export function categorizePath(rel) {
56 const name = rel.split('/').pop() || rel
57 if (DEPENDENCY_FILE.test(name)) return 'dependency'
58 if (CONFIG_FILE.test(name)) return 'config'
59 return 'source'
60}
61
62// "Source files changed after this check completed."
63export function staleReasonFor(categories, when = 'completed') {
64 const order = ['source', 'config', 'dependency']
65 const present = order.filter((c) => categories.includes(c))
66 if (present.length === 0) present.push('source')
67 const names = present.map((c) => (c === 'source' ? 'Source' : c === 'config' ? 'configuration' : 'dependency'))
68 let subject
69 if (names.length === 1) subject = names[0] + ' files'
70 else if (names.length === 2) subject = names[0] + ' and ' + names[1] + ' files'
71 else subject = names[0] + ', ' + names[1] + ', and ' + names[2] + ' files'
72 const phrase = when === 'running' ? 'while this check was running' : 'after this check completed'
73 return subject.charAt(0).toUpperCase() + subject.slice(1) + ' changed ' + phrase + '.'
74}
75
76// --- Turning a tool result into a status ------------------------------------------------------
77
78export function stripAnsi(s) {
79 // eslint-disable-next-line no-control-regex
80 return String(s).replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\r(?!\n)/g, '\n')
81}
82
83export function tailOutput(stdout, stderr, max = MAX_SUMMARY_CHARS) {
84 const parts = []
85 if (typeof stdout === 'string' && stdout) parts.push(stdout)
86 if (typeof stderr === 'string' && stderr) parts.push(stderr)
87 const text = stripAnsi(parts.join('\n')).trim()
88 if (text.length <= max) return text
89 return '…' + text.slice(text.length - max + 1)
90}
91
92export function parseExitCode(text) {
93 if (typeof text !== 'string') return null
94 const m = /(?:^|\n)Exit code:?\s+(-?\d+)/.exec(text)
95 return m ? Number(m[1]) : null
96}
97
98// Decides the status of one check from what the Bash tool actually reported.
99// `parsed` is analyzeCommand's output, `check` the check inside it, `outcome` the tool result.
100// The last number on the last output line, as in "EXIT: 2".
101export function parseTrailingCode(text) {
102 if (typeof text !== 'string') return null
103 const lines = stripAnsi(text).split('\n').map((l) => l.trim()).filter(Boolean)
104 const m = lines.length ? /(-?\d+)$/.exec(lines[lines.length - 1]) : null
105 return m ? Number(m[1]) : null
106}
107
108export function classifyOutcome(parsed, check, outcome) {
109 const unknown = (reason) => ({ status: 'unknown', reason, exitCode: null })
110
111 if (parsed.untrustedTool) {
112 return unknown('Ship Check cannot read results from the "' + parsed.untrustedTool + '" tool, so this run is not counted.')
113 }
114 if (check.watch || parsed.hasWatch) return unknown('Watch mode does not finish on its own, so no result was recorded.')
115 if (check.mode === 'none') return unknown(check.reason || 'The exit status cannot be tied to this check.')
116 if (!outcome || typeof outcome !== 'object') return unknown('The tool returned no result for this command.')
117
118 // A command that exits non-zero comes back as isError with the text 'Exit code N ...' and a
119 // string result; only a successful run carries the structured record.
120 const r = outcome.result && typeof outcome.result === 'object' ? outcome.result : null
121 if (r) {
122 if (r.backgroundTaskId || r.backgroundedByUser || r.backgroundedByTurnAbort || r.backgroundedToDeliverMessage || r.timedOutAfterMs) {
123 return unknown('The command moved to the background before it finished.')
124 }
125 if (r.interrupted === true) return unknown('The command was interrupted before it finished.')
126 }
127
128 if (check.mode === 'echo') return classifyEcho(outcome, r)
129 if (outcome.isError === true) return classifyFailure(check, outcome)
130
131 if (!r) return unknown('The tool returned no result for this command.')
132 if (typeof r.interrupted !== 'boolean') return unknown('The tool did not report whether the command finished.')
133 if (typeof r.returnCodeInterpretation === 'string' && r.returnCodeInterpretation) {
134 return unknown('The command exited with a status that the tool did not treat as a plain success.')
135 }
136 if (directoryChangeFailed(check, outcome)) return unknown(DIRECTORY_REASON)
137 // A clean finish: every command in an && chain succeeded, so the exit status was 0.
138 return { status: 'passed', reason: null, exitCode: 0 }
139}
140
141const DIRECTORY_REASON = 'The directory change may have failed, so the check may have run somewhere else.'
142
143// If `cd` or Set-Location failed, the check ran in the old directory: do not trust the location.
144function directoryChangeFailed(check, outcome) {
145 if (!check.cdUsed) return false
146 const text = String(outcome.text || (outcome.result && outcome.result.stderr) || '')
147 return /No such file or directory|Cannot find path|cannot find the path|cannot find the file|does not exist|The system cannot find/i.test(text)
148}
149
150// The shell printed the status itself: `npm test; echo "EXIT: $?"`.
151function classifyEcho(outcome, r) {
152 const unknown = (reason) => ({ status: 'unknown', reason, exitCode: null })
153 const code = parseTrailingCode(r ? r.stdout : outcome.text)
154 if (code === null) return unknown('The exit status line could not be read from the output.')
155 return code === 0 ? { status: 'passed', reason: null, exitCode: 0 } : { status: 'failed', reason: null, exitCode: code }
156}
157
158function classifyFailure(check, outcome) {
159 const unknown = (reason) => ({ status: 'unknown', reason, exitCode: null })
160 if (check.mode === 'success-only') return unknown(check.reason || 'Another command in this chain may have failed, so the result is not tied to this check.')
161 const exitCode = parseExitCode(outcome.text)
162 if (exitCode === null) return unknown('The tool reported an error but no exit status.')
163 if (directoryChangeFailed(check, outcome)) return unknown(DIRECTORY_REASON)
164 return { status: 'failed', reason: null, exitCode }
165}
166
167// --- Display -------------------------------------------------------------------------------------
168
169export function displayStatus(record) {
170 if (!record) return { status: STATUS.NOT_RUN, detail: null }
171 if (record.status === 'running') return { status: STATUS.RUNNING, detail: null }
172 if (record.status === 'unknown') return { status: STATUS.UNKNOWN, detail: record.reason || null }
173 if (record.staleAt) {
174 return {
175 status: STATUS.STALE,
176 detail: record.staleReason || 'Files changed after this check completed.',
177 last: record.status === 'passed' ? STATUS.PASSED : STATUS.FAILED,
178 }
179 }
180 return { status: record.status === 'passed' ? STATUS.PASSED : STATUS.FAILED, detail: null }
181}
182
183export const ICONS = {
184 [STATUS.NOT_RUN]: '○',
185 [STATUS.RUNNING]: '◌',
186 [STATUS.PASSED]: '✓',
187 [STATUS.FAILED]: '✗',
188 [STATUS.STALE]: '↻',
189 [STATUS.UNKNOWN]: '?',
190}
191
192export const STATUS_COLORS = {
193 [STATUS.NOT_RUN]: undefined,
194 [STATUS.RUNNING]: 'cyan',
195 [STATUS.PASSED]: 'green',
196 [STATUS.FAILED]: 'red',
197 [STATUS.STALE]: 'yellow',
198 [STATUS.UNKNOWN]: 'yellow',
199}
200
201export function relevantToCwd(record, cwd) {
202 if (!cwd) return true
203 return isUnder(record.location, cwd) || isUnder(cwd, record.location)
204}
205
206// One entry per kind for the status line: the most recent record near `cwd`.
207// Which status the status line shows when a kind has results in several locations, worst first.
208const SEVERITY = [STATUS.FAILED, STATUS.STALE, STATUS.UNKNOWN, STATUS.RUNNING, STATUS.PASSED]
209
210export function kindSummary(ledger, cwd, customKinds) {
211 const kinds = [...DEFAULT_KINDS]
212 for (const k of customKinds || []) if (!kinds.includes(k)) kinds.push(k)
213 const records = Object.values(ledger.records).filter((r) => relevantToCwd(r, cwd))
214 return kinds.map((kind) => {
215 // Several locations can hold the same kind of check. Show the worst of them, so a failure in
216 // one package is not hidden behind a newer pass in another. Ties go to the most recent run.
217 const mine = records
218 .filter((r) => r.kind === kind)
219 .map((record) => ({ record, shown: displayStatus(record) }))
220 .sort((a, b) => SEVERITY.indexOf(a.shown.status) - SEVERITY.indexOf(b.shown.status) || (b.record.startedAt || 0) - (a.record.startedAt || 0))
221 const top = mine[0]
222 return { kind, label: kindLabel(kind), record: top ? top.record : null, count: mine.length, ...(top ? top.shown : displayStatus(null)) }
223 })
224}
225
226export function hasAnyRecord(ledger) {
227 return Object.keys(ledger.records).length > 0
228}
229
230export function formatClock(ms) {
231 if (!ms) return ''
232 const d = new Date(ms)
233 const p = (n) => String(n).padStart(2, '0')
234 return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
235}
236
237export function formatDuration(ms) {
238 if (typeof ms !== 'number' || ms < 0) return ''
239 if (ms < 1000) return ms + 'ms'
240 const s = ms / 1000
241 if (s < 60) return s.toFixed(1) + 's'
242 const m = Math.floor(s / 60)
243 return m + 'm ' + Math.round(s - m * 60) + 's'
244}
245
246export function summaryLine(record) {
247 if (!record) return ''
248 const bits = []
249 if (record.endedAt) bits.push(formatClock(record.endedAt))
250 else if (record.startedAt) bits.push('started ' + formatClock(record.startedAt))
251 if (record.endedAt && record.startedAt) {
252 const took = formatDuration(record.endedAt - record.startedAt)
253 bits.push(record.together > 1 ? took + ' for the whole command' : took)
254 }
255 if (typeof record.exitCode === 'number') bits.push('exit ' + record.exitCode)
256 return bits.join(' · ')
257}
258
259// --- Ledger updates -------------------------------------------------------------------------------
260
261function pushRun(runs, record) {
262 const run = {
263 key: record.key,
264 kind: record.kind,
265 location: record.location,
266 command: record.command,
267 status: record.status,
268 startedAt: record.startedAt,
269 endedAt: record.endedAt || null,
270 }
271 return [...runs, run].slice(-MAX_RUNS)
272}
273
274export function beginRecord(ledger, check, startSig, root, now) {
275 const key = recordKey(check.location, check.kind, check.scope)
276 const record = {
277 key,
278 kind: check.kind,
279 location: check.location,
280 scope: check.scope,
281 root,
282 command: check.command,
283 together: check.together || 1,
284 filtered: check.filtered,
285 status: 'running',
286 startedAt: now,
287 endedAt: null,
288 exitCode: null,
289 reason: null,
290 summary: '',
291 startSig,
292 endSig: null,
293 dirtyDuringRun: false,
294 staleAt: null,
295 staleReason: null,
296 staleFiles: [],
297 }
298 return { ...ledger, records: { ...ledger.records, [key]: record } }
299}
300
301// Puts a record back the way it was before this run began (the tool call never ran).
302export function restoreRecord(ledger, key, previous) {
303 const records = { ...ledger.records }
304 if (previous) records[key] = previous
305 else delete records[key]
306 return { ...ledger, records }
307}
308
309export function finishRecord(ledger, key, startedAt, finish) {
310 const current = ledger.records[key]
311 if (!current || current.startedAt !== startedAt) return ledger // a newer run owns this key
312 let record = {
313 ...current,
314 status: finish.status,
315 endedAt: finish.now,
316 exitCode: finish.exitCode,
317 reason: finish.reason,
318 summary: finish.summary,
319 endSig: finish.endSig,
320 }
321 if (record.status === 'passed' || record.status === 'failed') {
322 const changedDuring = current.dirtyDuringRun || (current.startSig && finish.endSig && current.startSig.hash !== finish.endSig.hash)
323 if (changedDuring) {
324 record = {
325 ...record,
326 staleAt: finish.now,
327 staleReason: staleReasonFor(finish.duringCategories && finish.duringCategories.length ? finish.duringCategories : ['source'], 'running'),
328 staleFiles: finish.duringFiles || [],
329 }
330 }
331 }
332 return { ...ledger, records: { ...ledger.records, [key]: record }, runs: pushRun(ledger.runs, record) }
333}
334
335// A file changed through Claude's own tools or a shell command we could read.
336export function applyEdit(ledger, absPath, now, extra) {
337 const path = normalizePath(absPath)
338 let changed = false
339 const records = {}
340 for (const [key, rec] of Object.entries(ledger.records)) {
341 records[key] = rec
342 if (!rec.root || !isUnder(path, rec.root)) continue
343 const rel = relativeTo(path, rec.root)
344 if (!isTrackedPath(rel, extra)) continue
345 if (rec.status === 'running') {
346 if (!rec.dirtyDuringRun) {
347 records[key] = { ...rec, dirtyDuringRun: true }
348 changed = true
349 }
350 continue
351 }
352 if ((rec.status === 'passed' || rec.status === 'failed') && !rec.staleAt) {
353 records[key] = {
354 ...rec,
355 staleAt: now,
356 staleReason: staleReasonFor([categorizePath(rel)]),
357 staleFiles: [rel],
358 }
359 changed = true
360 }
361 }
362 return changed ? { ...ledger, records } : ledger
363}
364
365// A change we cannot name: mark every finished result under `root` as stale for `reason`.
366export function applyUnnamedChange(ledger, root, now, reason) {
367 let changed = false
368 const records = {}
369 for (const [key, rec] of Object.entries(ledger.records)) {
370 records[key] = rec
371 if (!rec.root || !(isUnder(root, rec.root) || isUnder(rec.root, root))) continue
372 if (rec.status === 'running') {
373 if (!rec.dirtyDuringRun) records[key] = { ...rec, dirtyDuringRun: true }
374 changed = true
375 } else if ((rec.status === 'passed' || rec.status === 'failed') && !rec.staleAt) {
376 records[key] = { ...rec, staleAt: now, staleReason: reason, staleFiles: [] }
377 changed = true
378 }
379 }
380 return changed ? { ...ledger, records } : ledger
381}
382
383// A fingerprint scan of `root` found `sig`. Compares it with each finished record's end fingerprint.
384// `previous` is { hash, reason, files } describing what changed since the scan with that hash;
385// a record whose end fingerprint is some other scan gets the generic reason.
386export function applyScan(ledger, root, sig, now, previous) {
387 let changed = false
388 const records = {}
389 for (const [key, rec] of Object.entries(ledger.records)) {
390 records[key] = rec
391 if (rec.root !== root || !sig) continue
392 if ((rec.status !== 'passed' && rec.status !== 'failed') || rec.staleAt || !rec.endSig) continue
393 if (rec.endSig.partial || sig.partial) continue // a capped scan cannot prove a difference
394 if (rec.endSig.hash === sig.hash) continue
395 const known = previous && previous.hash === rec.endSig.hash
396 records[key] = {
397 ...rec,
398 staleAt: now,
399 staleReason: known && previous.reason ? previous.reason : 'Files changed after this check completed.',
400 staleFiles: known && previous.files ? previous.files.slice(0, MAX_STALE_FILES) : [],
401 }
402 changed = true
403 }
404 return changed ? { ...ledger, records } : ledger
405}
406
407// Records still marked Running after a reload or restart can no longer be completed.
408export function settleRunning(ledger) {
409 let changed = false
410 const records = {}
411 for (const [key, rec] of Object.entries(ledger.records)) {
412 if (rec.status === 'running') {
413 records[key] = { ...rec, status: 'unknown', reason: 'The session reloaded before this check reported a result.' }
414 changed = true
415 } else records[key] = rec
416 }
417 return changed ? { ...ledger, records } : ledger
418}
419
420export function activeRoots(ledger) {
421 const roots = new Set()
422 for (const rec of Object.values(ledger.records)) {
423 if (!rec.root) continue
424 if (rec.status === 'running' || ((rec.status === 'passed' || rec.status === 'failed') && !rec.staleAt)) roots.add(rec.root)
425 }
426 return [...roots]
427}
428
429// Text that goes into the prompt box when the user presses "Prepare checks".
430export function preparePrompt(items) {
431 const lines = items.map((i) => '- ' + i.command + (i.location ? ' (in ' + i.location + ')' : ''))
432 return (
433 'Please re-run these checks now and tell me the real results from the tool output, not from memory:\n' +
434 (lines.length ? lines.join('\n') : '- the project’s test, type-check, and build commands') +
435 '\nRun each one separately and wait for it to finish.'
436 )
437}
438hooks/lib/view.js 139 lines1// Builds the element trees for the status line and the Ship Check pane.
2// `els` is what `$.ui.resolve(e)` returned: { Box, Text, Button, ... }. No Claude Code API is called here.
3
4import { displayStatus, STATUS, ICONS, STATUS_COLORS, kindSummary, summaryLine } from './model.js'
5import { kindLabel, DEFAULT_KINDS } from './commands.js'
6import { displayLocation } from './paths.js'
7
8const OUTPUT_LINES = 14
9
10function statusText(Text, status, key) {
11 const props = { key, children: [ICONS[status] + ' ' + status] }
12 const color = STATUS_COLORS[status]
13 if (color) props.color = color
14 return Text(props)
15}
16
17// "Tests ✓ | Types ↻ Stale | Build ○ Not run"
18export function buildStrip(els, ledger, cwd, customKinds, onOpen) {
19 const { Box, Text, Button } = els
20 const items = kindSummary(ledger, cwd, customKinds)
21 const children = []
22 items.forEach((item, i) => {
23 if (i > 0) children.push(Text({ key: 'sep-' + i, dimColor: true, children: ['|'] }))
24 const color = STATUS_COLORS[item.status]
25 const mark = item.status === STATUS.PASSED ? ICONS[item.status] : ICONS[item.status] + ' ' + item.status
26 const parts = [Text({ key: 'l-' + item.kind, children: [item.label] })]
27 const markProps = { key: 'm-' + item.kind, children: [mark] }
28 if (color) markProps.color = color
29 parts.push(Text(markProps))
30 children.push(Box({ key: 'k-' + item.kind, flexDirection: 'row', columnGap: 1, children: parts }))
31 })
32 // A button, because some apps do not offer a command that a mod registers.
33 if (onOpen) children.push(Button({ key: 'open', label: 'Details', plain: true, onPress: onOpen }))
34 return Box({ flexDirection: 'row', columnGap: 1, children })
35}
36
37function outputBlock(els, record, key) {
38 const { Box, Text } = els
39 const lines = (record.summary || '').split('\n')
40 const shown = lines.slice(-OUTPUT_LINES)
41 const children = []
42 if (!record.summary) children.push(Text({ key: key + '-none', dimColor: true, children: ['No output was captured.'] }))
43 else {
44 if (lines.length > shown.length) children.push(Text({ key: key + '-cut', dimColor: true, children: ['… showing the last ' + shown.length + ' lines'] }))
45 shown.forEach((line, i) => children.push(Text({ key: key + '-o' + i, dimColor: true, wrap: 'truncate-end', children: [line === '' ? ' ' : line] })))
46 }
47 return Box({ key: key + '-out', flexDirection: 'column', paddingLeft: 2, children })
48}
49
50function recordRows(els, record, cwd, expanded, onToggle) {
51 const { Box, Text, Button } = els
52 const shown = displayStatus(record)
53 const key = record.key
54 const rows = []
55
56 const head = [Text({ key: key + '-label', bold: true, children: [kindLabel(record.kind)] }), statusText(Text, shown.status, key + '-st')]
57 const when = summaryLine(record)
58 if (when) head.push(Text({ key: key + '-when', dimColor: true, children: [when] }))
59 rows.push(Box({ key: key + '-head', flexDirection: 'row', columnGap: 2, children: head }))
60
61 const where = displayLocation(record.location, cwd) + (record.scope ? ' · ' + record.scope : '')
62 rows.push(Text({ key: key + '-cmd', dimColor: true, wrap: 'truncate-end', children: [' ' + record.command + ' · ' + where] }))
63
64 if (shown.detail) rows.push(Text({ key: key + '-why', children: [' ' + shown.detail] }))
65 if (shown.status === STATUS.STALE) {
66 const files = (record.staleFiles || []).slice(0, 3)
67 const tail = files.length ? ' Changed: ' + files.join(', ') + ((record.staleFiles || []).length > files.length ? ', …' : '') : ''
68 rows.push(Text({ key: key + '-last', dimColor: true, children: [' Last result: ' + shown.last + '.' + tail] }))
69 }
70 if (record.filtered && (shown.status === STATUS.PASSED || shown.status === STATUS.FAILED || shown.status === STATUS.STALE)) {
71 rows.push(Text({ key: key + '-filter', dimColor: true, children: [' This run passed extra arguments, so it may not cover everything.'] }))
72 }
73
74 if (record.summary || shown.status !== STATUS.RUNNING) {
75 const open = expanded === key
76 rows.push(
77 Button({
78 key: 'out-' + key,
79 label: open ? 'Hide output' : 'View output',
80 onPress: () => onToggle(key),
81 }),
82 )
83 if (open) rows.push(outputBlock(els, record, key))
84 }
85 return Box({ key: key + '-card', flexDirection: 'column', children: rows })
86}
87
88function notRunRow(els, kind) {
89 const { Box, Text } = els
90 return Box({
91 key: 'nr-' + kind,
92 flexDirection: 'row',
93 columnGap: 2,
94 children: [Text({ key: 'nr-l-' + kind, bold: true, children: [kindLabel(kind)] }), statusText(Text, STATUS.NOT_RUN, 'nr-s-' + kind)],
95 })
96}
97
98export function buildPane(els, { ledger, cwd, expanded, customKinds, onToggle, onPrepare }) {
99 const { Box, Text, Button } = els
100 const records = Object.values(ledger.records).sort(
101 (a, b) => a.location.localeCompare(b.location) || a.kind.localeCompare(b.kind) || a.scope.localeCompare(b.scope),
102 )
103 const children = [Text({ key: 'title', bold: true, children: ['Ship Check'] })]
104
105 if (records.length === 0) {
106 children.push(Text({ key: 'empty', children: ['No checks have run yet. Ask Claude to run your tests, type check, or build.'] }))
107 }
108
109 let lastLocation = null
110 for (const record of records) {
111 if (record.location !== lastLocation) {
112 children.push(Text({ key: 'loc-' + record.location, dimColor: true, children: [' '] }))
113 children.push(Text({ key: 'locn-' + record.location, dimColor: true, children: [displayLocation(record.location, cwd)] }))
114 lastLocation = record.location
115 }
116 children.push(recordRows(els, record, cwd, expanded, onToggle))
117 }
118
119 // Default kinds nobody has run near the current directory.
120 const kinds = [...DEFAULT_KINDS, ...(customKinds || []).filter((k) => !DEFAULT_KINDS.includes(k))]
121 const missing = kindSummary(ledger, cwd, customKinds).filter((s) => !s.record && kinds.includes(s.kind))
122 if (missing.length) {
123 children.push(Text({ key: 'nr-gap', dimColor: true, children: [' '] }))
124 for (const m of missing) children.push(notRunRow(els, m.kind))
125 }
126
127 children.push(Text({ key: 'gap-btn', children: [' '] }))
128 children.push(
129 Box({
130 key: 'actions',
131 flexDirection: 'row',
132 columnGap: 2,
133 children: [Button({ key: 'prepare', label: 'Prepare checks', hotkey: 'p', autoFocus: true, onPress: onPrepare })],
134 }),
135 )
136 children.push(Text({ key: 'note', dimColor: true, children: ['Results come only from what the tools reported, not from Claude’s summaries.'] }))
137 return Box({ flexDirection: 'column', children })
138}
139types/index.d.ts 52 lines1// The values Ship Check keeps in `$.state`, so they survive a hot reload.
2declare module 'claude-code' {
3 interface PluginState {
4 'ship-check': {
5 ledger: {
6 records: Record<string, ShipCheckRecord>
7 runs: ShipCheckRun[]
8 }
9 view: { expanded: string | null }
10 }
11 }
12
13 interface ShipCheckSig {
14 hash: string
15 count: number
16 partial: boolean
17 }
18
19 interface ShipCheckRecord {
20 key: string
21 kind: string
22 location: string
23 scope: string
24 root: string
25 command: string
26 together: number
27 filtered: boolean
28 status: 'running' | 'passed' | 'failed' | 'unknown'
29 startedAt: number
30 endedAt: number | null
31 exitCode: number | null
32 reason: string | null
33 summary: string
34 startSig: ShipCheckSig | null
35 endSig: ShipCheckSig | null
36 dirtyDuringRun: boolean
37 staleAt: number | null
38 staleReason: string | null
39 staleFiles: string[]
40 }
41
42 interface ShipCheckRun {
43 key: string
44 kind: string
45 location: string
46 command: string
47 status: string
48 startedAt: number
49 endedAt: number | null
50 }
51}
52