@renzrollon/interlock 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ // The ship meter's refusal words (speak-permission-prompts-and-guard-denials
2
+ // design D4-D6): the four guard names a settings guard's denial is read by,
3
+ // the slice of an errored result's text the meter repeats, and the pane lines.
4
+ //
5
+ // Pure and Node-free: `hooks/mod.mjs` imports it inside the engine, where there
6
+ // is no Node, and `test/spine/mod-pins.test.mjs` walks the import and pins the
7
+ // names against the four guards' own `GUARD` constants. Nothing here judges a
8
+ // denial: the words are the guard's, and the ruling is `lib/launch-rule.mjs`'s.
9
+
10
+ /** The settings guards whose denials the meter repeats, as each prints its name. */
11
+ export const GUARD_NAMES = Object.freeze(['guard-tests', 'guard-tasks', 'guard-commit', 'guard-relaunch'])
12
+
13
+ // Anywhere in the text, not only at its start: the engine puts
14
+ // `PreToolUse:<tool> hook error: ` in front of a settings hook's reason (probe 2).
15
+ const NAMED = new RegExp(`\\b(${GUARD_NAMES.join('|')}): `)
16
+
17
+ /**
18
+ * The text from the first guard name on, or `null` for a text that names no
19
+ * guard (another plugin's deny, a command that failed) or is not a string.
20
+ *
21
+ * @param {unknown} text an errored tool result's `text`
22
+ * @returns {string|null}
23
+ */
24
+ export function guardReason(text) {
25
+ if (typeof text !== 'string') return null
26
+ const m = NAMED.exec(text)
27
+ return m ? text.slice(m.index) : null
28
+ }
29
+
30
+ /**
31
+ * The guard that spoke `reason`, as `guardReason` sliced it; `null` for none.
32
+ *
33
+ * @param {unknown} reason
34
+ * @returns {string|null}
35
+ */
36
+ export function guardOf(reason) {
37
+ if (typeof reason !== 'string') return null
38
+ const m = NAMED.exec(reason)
39
+ return m && m.index === 0 ? m[1] : null
40
+ }
41
+
42
+ /**
43
+ * `guard denials: <n> (<guard> <n>, …)` in the order each guard was first
44
+ * seen, or `null` when none was: the pane draws no line for nothing.
45
+ *
46
+ * @param {Map<string, number>|undefined} counts
47
+ * @returns {string|null}
48
+ */
49
+ export function guardDenialLine(counts) {
50
+ if (!(counts instanceof Map) || counts.size === 0) return null
51
+ let total = 0
52
+ const parts = []
53
+ for (const [guard, n] of counts) {
54
+ total += n
55
+ parts.push(`${guard} ${n}`)
56
+ }
57
+ return `guard denials: ${total} (${parts.join(', ')})`
58
+ }
59
+
60
+ /**
61
+ * The launch guard's current ruling over the session's record, in the rule's
62
+ * own words. `now` is `null` only when the guard's facts could not be read,
63
+ * which allows the next launch (design D5).
64
+ *
65
+ * @param {{ decision: string, reason: string|null }} ruling what `decideLaunch` returned
66
+ * @param {number|null} now
67
+ * @returns {string}
68
+ */
69
+ export function launchGuardLine(ruling, now) {
70
+ if (now === null) return 'launch guard: facts unreadable; the next launch is allowed'
71
+ if (ruling && ruling.decision === 'deny' && ruling.reason) return `launch guard: next launch refused: ${ruling.reason}`
72
+ return 'launch guard: next launch allowed'
73
+ }
@@ -0,0 +1,119 @@
1
+ // The plan summary (draw-the-wave-board-in-the-meter-pane design D3, spec
2
+ // ship/diagrams): the `plan` field the adoption and replan steps carry, so a
3
+ // second reader of the step stream — the ship meter — can draw the wave board
4
+ // without reading a file.
5
+ //
6
+ // The summary is the plan's shape cut to what the board reads: per wave its
7
+ // position, group, kind, red marker and batches; per task its id, tier, model
8
+ // and the words the lane title is built from; and the ordered-after edges as
9
+ // the plan's deferral records. Nothing else rides, because every byte of it is
10
+ // retyped by the Workflow host's relay. `test/spine/plan-summary.test.mjs`
11
+ // draws a plan and its summary through the renderer and compares the lines.
12
+ //
13
+ // Pure and Node-free, so the CLI builds the field with it and the hooks module
14
+ // could read it; its closure is `lib/lane.mjs` and `lib/limits.mjs`.
15
+
16
+ import { dependsOnIds, titleGist } from './lane.mjs'
17
+
18
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
19
+ const list = value => (Array.isArray(value) ? value : [])
20
+ const isText = value => typeof value === 'string' && value !== ''
21
+
22
+ /** A task as the board reads it; a field the task lacks is left out, never filled. */
23
+ function summarizeTask(task) {
24
+ const out = { id: task.id }
25
+ if (Number.isInteger(task.tier)) out.tier = task.tier
26
+ if (isText(task.model)) out.model = task.model
27
+ const gist = titleGist(task.description)
28
+ if (gist) out.description = gist
29
+ return out
30
+ }
31
+
32
+ /** A wave's batches as the board draws them: lanes of tasks, empty lanes and batches dropped. */
33
+ function summarizeBatches(wave) {
34
+ return list(wave.batches)
35
+ .map(batch =>
36
+ list(batch)
37
+ .map(lane => list(lane).filter(task => isObject(task) && isText(task.id)))
38
+ .filter(lane => lane.length > 0)
39
+ )
40
+ .filter(batch => batch.length > 0)
41
+ }
42
+
43
+ /** A deferral record reduced to the three fields the board reads. */
44
+ const reduceRecord = record => ({
45
+ id: record.id,
46
+ group: record.group ?? null,
47
+ after: list(record.after).filter(isText)
48
+ })
49
+
50
+ /**
51
+ * The planner's deferral records, derived from the tasks' own dependency
52
+ * fields: each id a task depends on that belongs to its own group and is
53
+ * placed at an earlier position or batch. A run state keeps no deferral list,
54
+ * and over the real halted run's state this yields its plan's four records.
55
+ */
56
+ function deriveDeferred(positions) {
57
+ const placed = new Map()
58
+ positions.forEach(({ batches }, index) =>
59
+ batches.forEach((batch, b) => {
60
+ for (const task of batch.flat()) if (!placed.has(task.id)) placed.set(task.id, { index, b, task })
61
+ })
62
+ )
63
+ const out = []
64
+ for (const [id, at] of placed) {
65
+ const group = at.task.group ?? null
66
+ const after = dependsOnIds(at.task).filter(dep => {
67
+ const before = placed.get(dep)
68
+ return (
69
+ before !== undefined &&
70
+ (before.task.group ?? null) === group &&
71
+ (before.index < at.index || (before.index === at.index && before.b < at.b))
72
+ )
73
+ })
74
+ if (after.length) out.push({ id, group, after })
75
+ }
76
+ return out
77
+ }
78
+
79
+ /**
80
+ * The summary of a plan (`waves` plus `testWave`) or of a run state (`waves`,
81
+ * the test wave last among them with `kind: 'test'`), in the plan's shape so
82
+ * the renderer reads either the same way.
83
+ *
84
+ * @param {unknown} source a plan or a run state; anything else summarises to no waves
85
+ * @param {{deferred?: Array}} [opts] the planner's own deferral records, when the caller has them
86
+ * @returns {{waves: Array, testWave: object|null, deferred: Array}}
87
+ */
88
+ export function summarizePlan(source, { deferred } = {}) {
89
+ const from = isObject(source) ? source : {}
90
+ const waves = list(from.waves).filter(isObject)
91
+ let impl = waves
92
+ let test = isObject(from.testWave) ? from.testWave : null
93
+ if (!test && waves.length && waves.at(-1).kind === 'test') {
94
+ impl = waves.slice(0, -1)
95
+ test = waves.at(-1)
96
+ }
97
+
98
+ const positions = impl
99
+ .map(wave => ({ wave, batches: summarizeBatches(wave) }))
100
+ .filter(position => position.batches.length > 0)
101
+ const testBatches = test ? summarizeBatches(test) : []
102
+ const all = testBatches.length ? [...positions, { wave: test, batches: testBatches }] : positions
103
+
104
+ const records = Array.isArray(deferred) ? deferred : Array.isArray(from.deferred) ? from.deferred : null
105
+ const summarize = batches => batches.map(batch => batch.map(lane => lane.map(summarizeTask)))
106
+ return {
107
+ waves: positions.map(({ wave, batches }, index) => ({
108
+ index,
109
+ group: wave.group ?? null,
110
+ kind: wave.kind === 'test' ? 'test' : 'impl',
111
+ ...(wave.red === true ? { red: true } : {}),
112
+ batches: summarize(batches)
113
+ })),
114
+ testWave: testBatches.length ? { index: positions.length, batches: summarize(testBatches) } : null,
115
+ deferred: records
116
+ ? records.filter(record => isObject(record) && isText(record.id)).map(reduceRecord)
117
+ : deriveDeferred(all)
118
+ }
119
+ }
@@ -0,0 +1,266 @@
1
+ // The session start's report file: what `hooks/preflight.mjs` leaves in
2
+ // `.claude/ship/preflight.json`, and every line the hooks module draws from it
3
+ // (show-preflight-and-interrupted-runs-at-session-start design D1, D5-D8).
4
+ //
5
+ // Two readers of one shape. The hook builds the report from what it already
6
+ // holds: the doctor's verdict and checks, the interrupted-run notes it spoke
7
+ // and marked, the halt resume cards it listed. The hooks module reads it at a
8
+ // prompt the person submits and draws the `AbovePrompt` band and two panes.
9
+ // Neither side derives a fact the other did not say: every word drawn is the
10
+ // doctor's, the hook's or the card's, and the time is a date, never a
11
+ // staleness verdict.
12
+ //
13
+ // Node-free, and importing only `./limits.mjs`, because the hooks module runs
14
+ // in the engine with no Node (test/spine/mod-pins.test.mjs walks it). The
15
+ // handoff pane's cap is read here and restated nowhere in the module.
16
+
17
+ import { LIMITS } from './limits.mjs'
18
+
19
+ /** Bump when the report's shape changes, so a reader can tell an older one. */
20
+ export const PREFLIGHT_SCHEMA = 'interlock.preflight/1'
21
+
22
+ /** Where the report lands, relative to the working root. Already gitignored with `.claude/ship/`. */
23
+ export const PREFLIGHT_FILE = '.claude/ship/preflight.json'
24
+
25
+ /** The most characters of a card the handoff pane renders: the `Markdown` element's own bound (design D7). */
26
+ export const HANDOFF_PANE_CHARS = LIMITS.handoffPaneChars
27
+
28
+ /** The SessionStart sources the host names; anything else is recorded as null. */
29
+ const SOURCES = new Set(['startup', 'resume', 'clear', 'compact'])
30
+
31
+ const str = v => (typeof v === 'string' && v ? v : null)
32
+ const text = v => (typeof v === 'string' ? v : v === null || v === undefined ? '' : String(v))
33
+ const list = v => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : [])
34
+ const count = v => (Number.isInteger(v) && v >= 0 ? v : 0)
35
+
36
+ /** The report file's path under `cwd`, or the relative path the engine resolves under the session's directory. */
37
+ export function preflightFilePath(cwd) {
38
+ if (typeof cwd !== 'string' || !cwd) return PREFLIGHT_FILE
39
+ return `${cwd.endsWith('/') ? cwd.slice(0, -1) : cwd}/${PREFLIGHT_FILE}`
40
+ }
41
+
42
+ function doctorOf(d) {
43
+ const doctor = d && typeof d === 'object' ? d : {}
44
+ const counts =
45
+ doctor.counts && typeof doctor.counts === 'object'
46
+ ? Object.fromEntries(['ok', 'warn', 'fail', 'skip'].map(k => [k, count(doctor.counts[k])]))
47
+ : null
48
+ return {
49
+ ran: doctor.ran === true,
50
+ parsed: doctor.parsed === true,
51
+ ok: typeof doctor.ok === 'boolean' ? doctor.ok : null,
52
+ counts,
53
+ // `evidence` is not copied: no reader draws it, and the module reads the file whole.
54
+ checks: list(doctor.checks).map(c => ({ id: text(c.id), status: text(c.status), detail: text(c.detail), fix: str(c.fix) }))
55
+ }
56
+ }
57
+
58
+ function notesOf(n) {
59
+ const notes = n && typeof n === 'object' ? n : {}
60
+ return {
61
+ spoken: list(notes.spoken).map(s => ({
62
+ runId: text(s.runId),
63
+ change: str(s.change),
64
+ stage: str(s.stage),
65
+ banner: text(s.banner),
66
+ marked: s.marked === true,
67
+ marks: list(s.marks).map(m => ({ root: text(m.root), marked: m.marked === true, reason: str(m.reason) }))
68
+ })),
69
+ unreadable: list(notes.unreadable).map(u => ({ file: text(u.file), reason: text(u.reason) }))
70
+ }
71
+ }
72
+
73
+ function cardsOf(c) {
74
+ const cards = c && typeof c === 'object' ? c : {}
75
+ return {
76
+ listed: list(cards.listed).map(k => ({ path: text(k.path), change: text(k.change), runId: text(k.runId), writtenAt: str(k.writtenAt) })),
77
+ archived: count(cards.archived),
78
+ unreadable: list(cards.unreadable).map(u => ({ path: text(u.path), reason: text(u.reason) })),
79
+ lookedIn: Array.isArray(cards.lookedIn) ? cards.lookedIn.filter(d => typeof d === 'string' && d) : []
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The report, every field present: an absent fact is `null` or `[]`, never
85
+ * omitted (design D1). Pure: the caller passes the time it wrote at.
86
+ */
87
+ export function buildPreflightReport(input) {
88
+ const i = input && typeof input === 'object' ? input : {}
89
+ return {
90
+ schema: PREFLIGHT_SCHEMA,
91
+ writtenAt: str(i.writtenAt),
92
+ source: SOURCES.has(i.source) ? i.source : null,
93
+ root: str(i.root),
94
+ stateHome: str(i.stateHome),
95
+ surface: str(i.surface),
96
+ message: typeof i.message === 'string' ? i.message : null,
97
+ doctor: doctorOf(i.doctor),
98
+ notes: notesOf(i.notes),
99
+ cards: cardsOf(i.cards)
100
+ }
101
+ }
102
+
103
+ /** `{ report }` from the file's text, or `{ problem }` naming why there is none. */
104
+ export function readPreflightReport(raw) {
105
+ if (typeof raw !== 'string') return { problem: `not text: the read returned ${raw === null ? 'null' : typeof raw}` }
106
+ let value
107
+ try {
108
+ value = JSON.parse(raw)
109
+ } catch (err) {
110
+ return { problem: `not JSON: ${(err && err.message) || err}` }
111
+ }
112
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
113
+ if (value.schema !== PREFLIGHT_SCHEMA) return { problem: `stamped ${JSON.stringify(value.schema)}, not ${PREFLIGHT_SCHEMA}` }
114
+ return { report: buildPreflightReport(value) }
115
+ }
116
+
117
+ const flagged = report => report.doctor.checks.filter(c => c.status === 'fail' || c.status === 'warn')
118
+
119
+ /** Whether the band has anything to say: a fail or warn check, a doctor that did not run or parse, a spoken note, a card. */
120
+ export function shouldDraw(report) {
121
+ return (
122
+ flagged(report).length > 0 ||
123
+ !report.doctor.ran ||
124
+ !report.doctor.parsed ||
125
+ report.notes.spoken.length > 0 ||
126
+ report.cards.listed.length > 0
127
+ )
128
+ }
129
+
130
+ /** One listed card's line: where it is, which change and run, when it was written. */
131
+ export function cardLine(card) {
132
+ return `${card.path} · ${card.change} · run ${card.runId} · written ${card.writtenAt || 'at an unknown time'}`
133
+ }
134
+
135
+ /** The listed cards, newest written first; equal or unknown times keep the listing's order. */
136
+ export function orderCards(report) {
137
+ const at = c => {
138
+ const t = Date.parse(c.writtenAt)
139
+ return Number.isNaN(t) ? -Infinity : t
140
+ }
141
+ return report.cards.listed
142
+ .map((card, i) => ({ card, i }))
143
+ .sort((a, b) => at(b.card) - at(a.card) || a.i - b.i)
144
+ .map(x => x.card)
145
+ }
146
+
147
+ const writtenLine = report => `preflight written ${report.writtenAt || 'at an unknown time'}`
148
+
149
+ /**
150
+ * The band's lines, in order (design D6): the message's first line, each
151
+ * `fail` or `warn` check in the doctor's words, each spoken note's banner,
152
+ * each card with the command that shows it, and when the report was written.
153
+ * Empty when there is nothing to say.
154
+ */
155
+ export function bandLines(report) {
156
+ if (!shouldDraw(report)) return []
157
+ const lines = []
158
+ const first = (report.message || '').split('\n')[0]
159
+ if (first) lines.push(first)
160
+ for (const c of flagged(report)) lines.push(`${c.status} ${c.id}: ${c.detail}`)
161
+ for (const n of report.notes.spoken) lines.push(n.banner)
162
+ for (const card of orderCards(report)) lines.push(`${cardLine(card)} — /interlock-handoff`)
163
+ lines.push(`${writtenLine(report)} · /interlock-preflight`)
164
+ return lines
165
+ }
166
+
167
+ /**
168
+ * The `/interlock-preflight` pane's lines: the message verbatim, every check
169
+ * with its fix lines indented as the hook prints them, the notes spoken at
170
+ * this session start with each root's mark, the cards and what was not
171
+ * listed, and every note and card the hook could not read. No empty line.
172
+ */
173
+ export function preflightPaneLines(report) {
174
+ const lines = []
175
+ for (const line of (report.message || '').split('\n')) if (line) lines.push(line)
176
+ lines.push(writtenLine(report))
177
+
178
+ lines.push(report.doctor.ran ? (report.doctor.parsed ? 'checks' : 'checks: the doctor ran and its output could not be parsed') : 'checks: the doctor did not run')
179
+ for (const c of report.doctor.checks) {
180
+ lines.push(`${c.status} ${c.id}: ${c.detail}`)
181
+ if (c.fix) {
182
+ const [first, ...rest] = c.fix.split('\n')
183
+ lines.push(` fix: ${first}`)
184
+ for (const more of rest) if (more.trim()) lines.push(` ${more.trim()}`)
185
+ }
186
+ }
187
+
188
+ lines.push(report.notes.spoken.length ? 'interrupted runs spoken at this session start' : 'no interrupted run was spoken at this session start')
189
+ for (const n of report.notes.spoken) {
190
+ lines.push(n.banner)
191
+ for (const m of n.marks) lines.push(m.marked ? ` marked · ${m.root}` : ` not marked: ${m.reason || 'no reason given'}`)
192
+ }
193
+ for (const u of report.notes.unreadable) lines.push(`note unreadable: ${u.file}: ${u.reason}`)
194
+
195
+ const ordered = orderCards(report)
196
+ lines.push(ordered.length ? 'halt resume cards — /interlock-handoff' : 'no halt resume card is on disk for an open change')
197
+ for (const card of ordered) lines.push(cardLine(card))
198
+ if (report.cards.archived) lines.push(`${report.cards.archived} card(s) of archived changes not listed`)
199
+ for (const u of report.cards.unreadable) lines.push(`card unreadable: ${u.path}: ${u.reason}`)
200
+ for (const dir of report.cards.lookedIn) lines.push(`looked in: ${dir}`)
201
+ return lines
202
+ }
203
+
204
+ /** What a pane says with no report held: the file's path and why, or that it has not been read yet. */
205
+ export function noReportText(path, problem) {
206
+ return `no preflight report is held for this session start: ${path}: ${problem || 'not read yet'}`
207
+ }
208
+
209
+ /** What the handoff pane says with a report and no card: that, and every directory looked in. */
210
+ export function noCardText(report) {
211
+ return ['no halt resume card is on disk for an open change', ...report.cards.lookedIn.map(dir => `looked in: ${dir}`)]
212
+ }
213
+
214
+ // Every control character but tab and newline: the `Markdown` element refuses them.
215
+ const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
216
+
217
+ /**
218
+ * A card's text as the handoff pane renders it: control characters made
219
+ * spaces, and when longer than `cap`, cut so that the text and a note naming
220
+ * the characters left out and the card's path fit within `cap` (design D7).
221
+ */
222
+ export function clampCard(raw, path, cap = LIMITS.handoffPaneChars) {
223
+ const clean = text(raw).replace(CONTROL, ' ')
224
+ if (clean.length <= cap) return { text: clean, cut: 0 }
225
+ const note = n => `\n\n_… ${n} characters left out; the whole card is ${path}_`
226
+ // The note for the whole text is the longest the note can be, so the kept head plus any shorter note still fits.
227
+ let keep = Math.max(0, cap - note(clean.length).length)
228
+ const code = clean.charCodeAt(keep - 1)
229
+ if (keep > 0 && code >= 0xd800 && code <= 0xdbff) keep -= 1
230
+ const cut = clean.length - keep
231
+ return { text: (clean.slice(0, keep) + note(cut)).slice(0, cap), cut }
232
+ }
233
+
234
+ /** What the module holds between reads (design D8): the report or the problem, the hide flag, whether the problem was named. */
235
+ export function freshStart() {
236
+ return { report: null, path: null, problem: null, hidden: false, named: false }
237
+ }
238
+
239
+ /**
240
+ * The module's next `start` after a read (design D5, D8), whether the band
241
+ * must be redrawn, and the one debug line to name, if any. A re-read of the
242
+ * report already held changes nothing, hide flag and all. A new report is
243
+ * held, and stays hidden only when it continues a report already held across
244
+ * a compaction. A problem drops the held report, since the file is no longer
245
+ * this session's, and is named once until a report is held again.
246
+ *
247
+ * @param {object} start what `freshStart` returns, as last adopted
248
+ * @param {{report?: object, problem?: string}} outcome `readPreflightReport`'s result, or a read's rejection as a problem
249
+ * @param {string} path the file's path, named on the debug line and the panes
250
+ * @returns {{start: object, changed: boolean, log: string|null}}
251
+ */
252
+ export function adoptPreflight(start, outcome, path) {
253
+ const held = start && typeof start === 'object' ? start : freshStart()
254
+ const report = outcome && outcome.report
255
+ if (report) {
256
+ if (held.report && held.report.writtenAt === report.writtenAt && held.report.root === report.root) {
257
+ return { start: held, changed: false, log: null }
258
+ }
259
+ const hidden = held.report !== null && held.hidden && report.source === 'compact'
260
+ return { start: { report, path, problem: null, hidden, named: false }, changed: true, log: null }
261
+ }
262
+ const problem = (outcome && outcome.problem) || 'no report was read'
263
+ const log = held.named ? null : `interlock meter: no preflight report at ${path}: ${problem}`
264
+ const changed = held.report !== null || held.problem !== problem || held.path !== path
265
+ return { start: { report: null, path, problem, hidden: held.hidden, named: true }, changed, log }
266
+ }
@@ -10,12 +10,18 @@
10
10
  //
11
11
  // Three rules it exists under, and all three are load-bearing:
12
12
  //
13
- // 1. **It is a record, not a trigger.** Nothing reads it back. `/interlock:ship`
14
- // still decides whether to skip the classifier from the plan fingerprint, not
15
- // from this file, so a stale or hand-edited card cannot make a later run
16
- // reuse a plan it should have rebuilt. The card says that about itself, in
17
- // the file, because a markdown "resume" artifact that nothing consumes is
18
- // exactly the thing a reader would otherwise assume is wired in.
13
+ // 1. **It is a record, not a trigger.** Nothing reads it back to decide.
14
+ // `/interlock:ship` still decides whether to skip the classifier from the
15
+ // plan fingerprint, not from this file, so a stale or hand-edited card
16
+ // cannot make a later run reuse a plan it should have rebuilt. The card says
17
+ // that about itself, in the file, because a markdown "resume" artifact that
18
+ // nothing consumes is exactly the thing a reader would otherwise assume is
19
+ // wired in. Two readers show it and nothing more
20
+ // (show-preflight-and-interrupted-runs-at-session-start design D4): the
21
+ // SessionStart preflight lists the cards of open changes from the stamp on
22
+ // each card's first line (`listResumeCards`, below), and the hooks module's
23
+ // `/interlock-handoff` renders one verbatim on request. Neither marks,
24
+ // moves or deletes a card.
19
25
  // 2. **It never throws.** A read-only checkout, a full disk, an unwritable
20
26
  // `.claude/` — each degrades to a no-op that *reports* itself through the
21
27
  // return value, and the close turns that into a degradation banner. The run
@@ -29,8 +35,8 @@
29
35
  // Only on a halt. A clean run's summary already ends in `ARCHIVE PENDING` and
30
36
  // there is nothing to resume.
31
37
 
32
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
33
- import { join } from 'node:path'
38
+ import { closeSync, existsSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'
39
+ import { join, resolve } from 'node:path'
34
40
  import { LIMITS } from './limits.mjs'
35
41
 
36
42
  /** Where the card lands, relative to the repo root. Already gitignored. */
@@ -161,6 +167,7 @@ export function formatResumeCard({
161
167
  const elsewhere = typeof stateHome === 'string' && stateHome.length > 0 && !samePath(stateHome, cwd)
162
168
  const lines = []
163
169
 
170
+ // The stamp `parseResumeCardStamp` reads back: change it and the parser together.
164
171
  lines.push(`<!-- ${RESUME_CARD_SCHEMA} change=${name} run=${hasRun ? runId : NO_RUN_ID} -->`)
165
172
  lines.push(`# Ship halted — ${name}`)
166
173
  lines.push('')
@@ -375,6 +382,117 @@ export function formatResumeCard({
375
382
  return lines.join('\n')
376
383
  }
377
384
 
385
+ // The writer's first line, read back. Anchored on the schema, so a card of
386
+ // another shape is no card; a change or run carrying a separator is refused.
387
+ const SCHEMA_PATTERN = RESUME_CARD_SCHEMA.replace(/[.\/]/g, '\\$&')
388
+ const STAMP = new RegExp(`^<!-- ${SCHEMA_PATTERN} change=([^\\s/\\\\]+) run=([^\\s/\\\\]+) -->$`)
389
+
390
+ /**
391
+ * The change and run id a card's first line names, or `null` when the line is
392
+ * not this schema's stamp. The inverse of the stamp `formatResumeCard` writes.
393
+ *
394
+ * @param {string} line
395
+ * @returns {{change: string, runId: string} | null}
396
+ */
397
+ export function parseResumeCardStamp(line) {
398
+ if (typeof line !== 'string') return null
399
+ const m = STAMP.exec(line.replace(/\r$/, ''))
400
+ if (!m || m[1] === '.' || m[1] === '..') return null
401
+ return { change: m[1], runId: m[2] }
402
+ }
403
+
404
+ /** How much of a card the listing reads: the stamp line, never the body. */
405
+ const STAMP_BYTES = 512
406
+
407
+ function firstLine(path) {
408
+ const fd = openSync(path, 'r')
409
+ try {
410
+ const buffer = Buffer.alloc(STAMP_BYTES)
411
+ const n = readSync(fd, buffer, 0, STAMP_BYTES, 0)
412
+ return buffer.toString('utf8', 0, n).split('\n')[0]
413
+ } finally {
414
+ closeSync(fd)
415
+ }
416
+ }
417
+
418
+ const messageOf = err => (err && err.message) || String(err)
419
+
420
+ /**
421
+ * The halt resume cards under each root's handoff directory, as the
422
+ * SessionStart preflight lists them (design D4). A card whose change still has
423
+ * a directory under `changesDir` is listed with its absolute path, change, run
424
+ * id and modification time; the cards of other changes are counted under
425
+ * `archived`; a `ship-*.md` without a readable stamp is named under
426
+ * `unreadable`. Every handoff directory opened or found absent is recorded
427
+ * under `lookedIn`, and two roots naming one real directory are read once.
428
+ *
429
+ * Reads the first line of each card and nothing past it; marks, moves and
430
+ * deletes nothing. Never throws.
431
+ *
432
+ * @param {{roots?: string[], changesDir?: string}} [input]
433
+ * @returns {{listed: Array<{path: string, change: string, runId: string, writtenAt: string|null}>, archived: number, unreadable: Array<{path: string, reason: string}>, lookedIn: string[]}}
434
+ */
435
+ export function listResumeCards({ roots = [], changesDir } = {}) {
436
+ const out = { listed: [], archived: 0, unreadable: [], lookedIn: [] }
437
+ try {
438
+ const seen = new Set()
439
+ for (const root of Array.isArray(roots) ? roots : []) {
440
+ if (typeof root !== 'string' || !root) continue
441
+ const dir = resolve(root, HANDOFF_DIR)
442
+ let real = dir
443
+ try {
444
+ real = realpathSync(dir)
445
+ } catch {
446
+ // An absent directory is still one directory, named as given.
447
+ }
448
+ if (seen.has(real)) continue
449
+ seen.add(real)
450
+ out.lookedIn.push(dir)
451
+ let names
452
+ try {
453
+ names = readdirSync(dir)
454
+ } catch (err) {
455
+ if (!(err && err.code === 'ENOENT')) out.unreadable.push({ path: dir, reason: messageOf(err) })
456
+ continue
457
+ }
458
+ for (const name of names.filter(n => n.startsWith('ship-') && n.endsWith('.md')).sort()) {
459
+ const path = join(dir, name)
460
+ let stamp
461
+ try {
462
+ stamp = parseResumeCardStamp(firstLine(path))
463
+ } catch (err) {
464
+ out.unreadable.push({ path, reason: messageOf(err) })
465
+ continue
466
+ }
467
+ if (!stamp) {
468
+ out.unreadable.push({ path, reason: `the first line is not an ${RESUME_CARD_SCHEMA} stamp` })
469
+ continue
470
+ }
471
+ let open = false
472
+ try {
473
+ open = typeof changesDir === 'string' && statSync(join(changesDir, stamp.change)).isDirectory()
474
+ } catch {
475
+ open = false
476
+ }
477
+ if (!open) {
478
+ out.archived++
479
+ continue
480
+ }
481
+ let writtenAt = null
482
+ try {
483
+ writtenAt = statSync(path).mtime.toISOString()
484
+ } catch {
485
+ writtenAt = null
486
+ }
487
+ out.listed.push({ path, change: stamp.change, runId: stamp.runId, writtenAt })
488
+ }
489
+ }
490
+ } catch (err) {
491
+ out.unreadable.push({ path: String(roots), reason: messageOf(err) })
492
+ }
493
+ return out
494
+ }
495
+
378
496
  /**
379
497
  * Write the card for a halted run.
380
498
  *