@renzrollon/interlock 1.2.1 → 1.3.1

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.
@@ -15,16 +15,34 @@
15
15
  //
16
16
  // It also surfaces any interrupted-run note a previous session left — a run
17
17
  // that ended with its session and never reached its close (lib/interrupted.mjs).
18
- // It marks nothing: the next `interlock run start`, where the reader is about to
19
- // spend agents, is the moment that marks a note spoken. A linked worktree's run
20
- // writes its note into the state home — the main checkout — so the notes are
21
- // read from the home the doctor resolved AND from the working directory (a run
22
- // whose manifest recorded no home wrote there), each run spoken once. When the
23
- // doctor gave no home — it could not run, or predates the field — the working
24
- // directory alone, as before.
18
+ // A linked worktree's run writes its note into the state home — the main
19
+ // checkout — so the notes are read from the home the doctor resolved AND from
20
+ // the working directory (a run whose manifest recorded no home wrote there),
21
+ // each run spoken once. When the doctor gave no home — it could not run, or
22
+ // predates the field — the working directory alone, as before.
23
+ //
24
+ // It marks each note it speaks, in every directory the note was found in
25
+ // (show-preflight-and-interrupted-runs-at-session-start design D3). A session
26
+ // start now reaches the person as well as the model, through the hooks
27
+ // module's band, and a note nobody clears must not be said at every session
28
+ // start. The mark is `markSpoken`, the run program's own, and it fails toward
29
+ // speaking again: a note that cannot be marked stays unspoken on disk, so the
30
+ // next session start and the next `run start` say it again, and the report
31
+ // file below records why.
32
+ //
33
+ // In a project where a ship run can start — one whose working root holds
34
+ // `openspec/` — it leaves what it computed in `.claude/ship/preflight.json`
35
+ // (lib/preflight-file.mjs, design D1, D2): the doctor's verdict and checks,
36
+ // the notes it spoke and whether each mark landed, and the halt resume cards
37
+ // of open changes, listed from each card's first-line stamp and never read
38
+ // past it. The previous session's file is removed before the new one is
39
+ // written, so a failed write leaves no stale report; the failure is one line
40
+ // on stderr. Every other repository gets no file, no directory, and exactly
41
+ // the output it got before. The file is outcome-class: losing it costs the
42
+ // band, never the session.
25
43
 
26
44
  import { execFileSync } from 'node:child_process'
27
- import { appendFileSync, existsSync, realpathSync } from 'node:fs'
45
+ import { appendFileSync, existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
28
46
  import { dirname, join, resolve } from 'node:path'
29
47
  import { fileURLToPath } from 'node:url'
30
48
 
@@ -38,10 +56,11 @@ function interlockBin() {
38
56
  }
39
57
 
40
58
  /**
41
- * The preflight's own report, as one message, and the state home the doctor
42
- * resolved — `null` when its output carried none or could not be read.
59
+ * The preflight's own report, as one message; the state home and surface the
60
+ * doctor resolved — `null` when its output carried none or could not be read;
61
+ * and the doctor's verdict as the report file carries it (design D1).
43
62
  *
44
- * @returns {{ message: string, stateHome: string|null }}
63
+ * @returns {{ message: string, stateHome: string|null, surface: string|null, doctor: object }}
45
64
  */
46
65
  function run() {
47
66
  let raw
@@ -65,7 +84,9 @@ function run() {
65
84
  `The \`interlock\` binary is not resolvable from this session. Inside Claude Code the ` +
66
85
  `plugin's bin/ is normally injected; if you see this, reinstall the plugin. The session ` +
67
86
  `continues — this preflight is advisory.`,
68
- stateHome: null
87
+ stateHome: null,
88
+ surface: null,
89
+ doctor: { ran: false, parsed: false, ok: null, counts: null, checks: [] }
69
90
  }
70
91
  }
71
92
  }
@@ -79,18 +100,35 @@ function run() {
79
100
  if (!doctor || typeof doctor !== 'object') {
80
101
  return {
81
102
  message: 'interlock preflight ran but its output could not be parsed; skipping. The session continues.',
82
- stateHome: null
103
+ stateHome: null,
104
+ surface: null,
105
+ doctor: { ran: true, parsed: false, ok: null, counts: null, checks: [] }
83
106
  }
84
107
  }
85
108
  const stateHome =
86
109
  typeof doctor.stateHome === 'string' && doctor.stateHome ? resolve(process.cwd(), doctor.stateHome) : null
87
110
 
111
+ const surface = typeof doctor.surface === 'string' && doctor.surface ? doctor.surface : null
112
+
88
113
  const checks = Array.isArray(doctor.checks) ? doctor.checks : []
114
+ // The checks as the file carries them: the fix travels, the evidence does not (design D1).
115
+ const verdict = {
116
+ ran: true,
117
+ parsed: true,
118
+ ok: typeof doctor.ok === 'boolean' ? doctor.ok : null,
119
+ counts: doctor.counts && typeof doctor.counts === 'object' ? doctor.counts : null,
120
+ checks: checks.filter(c => c && typeof c === 'object').map(c => ({ id: c.id, status: c.status, detail: c.detail, fix: c.fix ?? null }))
121
+ }
89
122
  const failures = checks.filter(c => c && c.status === 'fail')
90
123
  if (!failures.length) {
91
124
  const warns = (doctor.counts && doctor.counts.warn) || 0
92
125
  // A clean preflight is quiet, not a wall of output — one confirming line.
93
- return { message: `interlock preflight OK${warns ? ` (${warns} warning${warns === 1 ? '' : 's'})` : ''}.`, stateHome }
126
+ return {
127
+ message: `interlock preflight OK${warns ? ` (${warns} warning${warns === 1 ? '' : 's'})` : ''}.`,
128
+ stateHome,
129
+ surface,
130
+ doctor: verdict
131
+ }
94
132
  }
95
133
 
96
134
  const lines = ['interlock preflight found issues that can stall an unattended ship run:']
@@ -103,7 +141,7 @@ function run() {
103
141
  }
104
142
  }
105
143
  lines.push('The session still starts — apply the fixes above before /interlock:ship.')
106
- return { message: lines.join('\n'), stateHome }
144
+ return { message: lines.join('\n'), stateHome, surface, doctor: verdict }
107
145
  }
108
146
 
109
147
  /** Two paths naming one directory — through a symlink too — so one directory is read once. */
@@ -120,35 +158,114 @@ function sameDirectory(a, b) {
120
158
 
121
159
  /**
122
160
  * One line per interrupted-run note not yet spoken, and one per note that
123
- * could not be read, from the state home and then the working directory. A run
124
- * whose note is in both is one interrupted run, spoken once. Loaded on demand
125
- * rather than imported, so a hook that finds its library missing still exits 0
126
- * — and says so instead of falling silent.
161
+ * could not be read, from the state home and then the working directory, each
162
+ * spoken note marked in every directory it was found in (design D3). A run
163
+ * whose note is in both is one interrupted run, spoken once and marked in
164
+ * both. Loaded on demand rather than imported, so a hook that finds its
165
+ * library missing still exits 0 — and says so instead of falling silent.
166
+ *
167
+ * @returns {Promise<{lines: string[], spoken: object[], unreadable: Array<{file: string, reason: string}>}>}
127
168
  */
128
- async function interruptedLines(cwd, stateHome) {
169
+ async function interruptedNotes(cwd, stateHome) {
129
170
  let lib
130
171
  try {
131
172
  lib = await import(new URL('../lib/interrupted.mjs', import.meta.url).href)
132
173
  } catch (err) {
133
- return [`interrupted-run notes could not be read: ${(err && err.message) || String(err)}`]
174
+ const reason = (err && err.message) || String(err)
175
+ return { lines: [`interrupted-run notes could not be read: ${reason}`], spoken: [], unreadable: [{ file: 'lib/interrupted.mjs', reason }] }
134
176
  }
135
177
  const roots = stateHome && !sameDirectory(stateHome, cwd) ? [stateHome, cwd] : [cwd]
136
- const spoken = new Set()
137
- const banners = []
138
- const unreadableLines = []
178
+ const found = new Map()
179
+ const unreadable = []
139
180
  for (const root of roots) {
140
- const { notes, unreadable } = lib.readInterruptedNotes(root)
181
+ const { notes, unreadable: bad } = lib.readInterruptedNotes(root)
141
182
  for (const note of notes.filter(lib.isUnspoken)) {
142
- if (spoken.has(note.runId)) continue
143
- spoken.add(note.runId)
144
- banners.push(lib.formatInterruptedBanner(note))
183
+ if (!found.has(note.runId)) found.set(note.runId, { note, roots: [] })
184
+ found.get(note.runId).roots.push(root)
145
185
  }
146
186
  // The working directory's notes keep their root-relative names; the home's are named in full.
147
- for (const u of unreadable) {
148
- unreadableLines.push(`An interrupted-run note could not be read: ${root === cwd ? u.file : join(root, u.file)}: ${u.reason}`)
149
- }
187
+ for (const u of bad) unreadable.push({ file: root === cwd ? u.file : join(root, u.file), reason: u.reason })
188
+ }
189
+ const banners = []
190
+ const spoken = []
191
+ for (const { note, roots: where } of found.values()) {
192
+ const banner = lib.formatInterruptedBanner(note)
193
+ banners.push(banner)
194
+ // Spoken, then marked: a mark that fails leaves the note to be said again.
195
+ const at = new Date().toISOString()
196
+ const marks = where.map(root => {
197
+ const m = lib.markSpoken(root, note.runId, at)
198
+ return { root, marked: m.marked === true, reason: m.marked === true ? null : m.reason }
199
+ })
200
+ spoken.push({ runId: note.runId, change: note.change, stage: note.stage, banner, marked: marks.every(m => m.marked), marks })
201
+ }
202
+ return {
203
+ lines: [...banners, ...unreadable.map(u => `An interrupted-run note could not be read: ${u.file}: ${u.reason}`)],
204
+ spoken,
205
+ unreadable
206
+ }
207
+ }
208
+
209
+ /**
210
+ * The halt resume cards of open changes under the state home and the working
211
+ * directory, listed from each card's stamp (design D4). The listing reads no
212
+ * card past its first line, and the advisory output never mentions a card.
213
+ */
214
+ async function resumeCards(cwd, stateHome) {
215
+ const roots = stateHome && !sameDirectory(stateHome, cwd) ? [stateHome, cwd] : [cwd]
216
+ try {
217
+ const lib = await import(new URL('../lib/resume-card.mjs', import.meta.url).href)
218
+ return lib.listResumeCards({ roots, changesDir: join(cwd, 'openspec', 'changes') })
219
+ } catch (err) {
220
+ const reason = `halt resume cards could not be listed: ${(err && err.message) || String(err)}`
221
+ return { listed: [], archived: 0, unreadable: [{ path: join(cwd, '.claude', 'handoff'), reason }], lookedIn: [] }
222
+ }
223
+ }
224
+
225
+ /** A project where a ship run can start: the working root holds an `openspec/` directory (design D2). */
226
+ function isOpenSpecProject(cwd) {
227
+ try {
228
+ return statSync(join(cwd, 'openspec')).isDirectory()
229
+ } catch {
230
+ return false
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Leave this session's report in `.claude/ship/preflight.json` (design D1,
236
+ * D2): the previous file removed first, so a write that fails leaves no stale
237
+ * report, then the new one written whole. A failure is one stderr line, and
238
+ * the session starts as before.
239
+ */
240
+ async function writeReport(cwd, input) {
241
+ let path = join(cwd, '.claude', 'ship', 'preflight.json')
242
+ try {
243
+ const lib = await import(new URL('../lib/preflight-file.mjs', import.meta.url).href)
244
+ path = join(cwd, lib.PREFLIGHT_FILE)
245
+ rmSync(path, { force: true })
246
+ mkdirSync(dirname(path), { recursive: true })
247
+ writeFileSync(path, JSON.stringify(lib.buildPreflightReport(input), null, 2) + '\n')
248
+ } catch (err) {
249
+ process.stderr.write(`interlock preflight: report not written: ${path}: ${(err && err.message) || String(err)}\n`)
250
+ }
251
+ }
252
+
253
+ /**
254
+ * The session-start source from the host's event on stdin (`startup`,
255
+ * `resume`, `clear`, `compact`), or `null`: the report records it so the
256
+ * hooks module keeps a band hidden across a compaction (design D5).
257
+ */
258
+ async function sessionSource() {
259
+ try {
260
+ if (process.stdin.isTTY) return null
261
+ const chunks = []
262
+ for await (const chunk of process.stdin) chunks.push(chunk)
263
+ const raw = Buffer.concat(chunks).toString('utf8').trim()
264
+ const event = raw ? JSON.parse(raw) : null
265
+ return event && typeof event.source === 'string' ? event.source : null
266
+ } catch {
267
+ return null
150
268
  }
151
- return [...banners, ...unreadableLines]
152
269
  }
153
270
 
154
271
  /**
@@ -208,8 +325,25 @@ try {
208
325
  `interlock preflight could not publish notify options (ignored): ${(err && err.message) || err}\n`
209
326
  )
210
327
  }
211
- const { message, stateHome } = run()
212
- const lines = [message, ...(await interruptedLines(process.cwd(), stateHome))].filter(Boolean)
328
+ const cwd = process.cwd()
329
+ const source = await sessionSource()
330
+ const { message, stateHome, surface, doctor } = run()
331
+ const notes = await interruptedNotes(cwd, stateHome)
332
+ const lines = [message, ...notes.lines].filter(Boolean)
333
+ // Written before stdout, so a session that reads the context has the file on disk already (design D10).
334
+ if (isOpenSpecProject(cwd)) {
335
+ await writeReport(cwd, {
336
+ writtenAt: new Date().toISOString(),
337
+ source,
338
+ root: cwd,
339
+ stateHome,
340
+ surface,
341
+ message,
342
+ doctor,
343
+ notes: { spoken: notes.spoken, unreadable: notes.unreadable },
344
+ cards: await resumeCards(cwd, stateHome)
345
+ })
346
+ }
213
347
  if (lines.length) report(lines.join('\n'))
214
348
  } catch (err) {
215
349
  // Belt and suspenders: nothing this hook does may abort the session.