@renzrollon/interlock 1.2.0 → 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.
@@ -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 { 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
  /**
@@ -166,9 +283,67 @@ function report(message) {
166
283
  process.stdout.write(JSON.stringify(out) + '\n')
167
284
  }
168
285
 
286
+ /** A single-quoted shell word. A value with a newline or a NUL is refused. */
287
+ function shellQuote(value) {
288
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
289
+ }
290
+
291
+ function publishable(value) {
292
+ return typeof value === 'string' && value.trim() && !/[\0\r\n]/.test(value)
293
+ }
294
+
295
+ /**
296
+ * Copy a plugin option into the session so `interlock notify` can see it.
297
+ *
298
+ * The host injects `userConfig` into hook processes only. A ship run's notify
299
+ * call is not a hook, so without this copy a topic typed into the plugin
300
+ * options would never reach the CLI. An operator who already provided the
301
+ * shell variable keeps that value. Nothing is printed, and a failure here
302
+ * does not change the preflight.
303
+ */
304
+ function publishNotifyOptions() {
305
+ const file = process.env.CLAUDE_ENV_FILE
306
+ if (typeof file !== 'string' || !file) return
307
+ const lines = []
308
+ const topic = process.env.CLAUDE_PLUGIN_OPTION_NTFY_TOPIC
309
+ const url = process.env.CLAUDE_PLUGIN_OPTION_NTFY_URL
310
+ if (publishable(topic) && !publishable(process.env.INTERLOCK_NTFY_TOPIC)) {
311
+ lines.push(`export INTERLOCK_NTFY_TOPIC=${shellQuote(topic.trim())}`)
312
+ }
313
+ if (publishable(url) && !publishable(process.env.INTERLOCK_NTFY_URL)) {
314
+ lines.push(`export INTERLOCK_NTFY_URL=${shellQuote(url.trim())}`)
315
+ }
316
+ if (!lines.length) return
317
+ appendFileSync(file, lines.join('\n') + '\n')
318
+ }
319
+
169
320
  try {
170
- const { message, stateHome } = run()
171
- const lines = [message, ...(await interruptedLines(process.cwd(), stateHome))].filter(Boolean)
321
+ try {
322
+ publishNotifyOptions()
323
+ } catch (err) {
324
+ process.stderr.write(
325
+ `interlock preflight could not publish notify options (ignored): ${(err && err.message) || err}\n`
326
+ )
327
+ }
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
+ }
172
347
  if (lines.length) report(lines.join('\n'))
173
348
  } catch (err) {
174
349
  // Belt and suspenders: nothing this hook does may abort the session.
package/lib/doctor.mjs CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  readModelRouting
79
79
  } from './host/claude-env.mjs'
80
80
  import { RUNTIME } from './limits.mjs'
81
+ import { DEFAULT_NTFY_URL } from './notify-defaults.mjs'
81
82
  import { STATE_HOME_ENV, resolveStateHome } from './state-home.mjs'
82
83
 
83
84
  /** The installed copy of the plugin — the one whose workflow and agents run. */
@@ -203,8 +204,7 @@ export const REQUIRED_COMMANDS = Object.freeze([
203
204
  * Commands Claude Code auto-approves as read-only no matter what the
204
205
  * project's own allowlist says, so a briefing that instructs one of these
205
206
  * needs no rule of its own and the drift test below must not demand one.
206
- * Source: the Claude Code permissions documentation
207
- * (https://docs.claude.com/en/docs/claude-code/permissions), checked
207
+ * Source: the Claude Code permissions documentation, checked
208
208
  * 2026-09-07 — a host claim this module cannot itself verify, so it is dated
209
209
  * and cited rather than asserted bare. If the host narrows this list the
210
210
  * failure is loud: the drift test below goes red naming the command, never a
@@ -586,11 +586,11 @@ function checkOpenSpec(root, env, probe) {
586
586
  if (configured) {
587
587
  return check('openspec', 'warn', 'the openspec CLI is not on PATH; ship reads artifacts from disk, but /interlock:spec and /interlock:bootstrap need it', {
588
588
  evidence: ['openspec/config.yaml is present, so existing changes are still readable'],
589
- fix: 'npm install -g openspec (requires Node 20.19.0+).'
589
+ fix: 'Install the openspec command globally. It needs Node.js 20.19.0 or newer.'
590
590
  })
591
591
  }
592
592
  return check('openspec', 'fail', 'no openspec CLI on PATH and no openspec/ project in this repo — neither authoring nor shipping a change can start', {
593
- fix: 'npm install -g openspec && openspec init'
593
+ fix: 'Install the openspec command globally (Node.js 20.19.0 or newer), then run `openspec init` in the project root.'
594
594
  })
595
595
  }
596
596
 
@@ -915,17 +915,22 @@ function checkPermissions(root, required, { home, sources } = {}) {
915
915
  /**
916
916
  * Advice, never a gate: push notification is an optional feature, so this row
917
917
  * is `ok` or `skip` and NEVER `fail` — an operator who never configured it has
918
- * not degraded their run. Reads the same two environment variable NAMES
919
- * `lib/notify.mjs` reads (`INTERLOCK_NTFY_TOPIC`, `INTERLOCK_NTFY_URL`) by
920
- * literal string rather than by importing that module: this file has no
921
- * other reason to depend on the network transport, and the two spellings are
922
- * pinned identically in both modules' own test suites. The topic value is
923
- * never read into a string that could reach the report; only the server URL,
924
- * which is not a capability, is echoed.
918
+ * not degraded their run. Reads the same names `lib/notify.mjs` reads — the
919
+ * plugin options `CLAUDE_PLUGIN_OPTION_NTFY_TOPIC` / `CLAUDE_PLUGIN_OPTION_NTFY_URL`
920
+ * and the shell variables `INTERLOCK_NTFY_TOPIC` / `INTERLOCK_NTFY_URL` — by
921
+ * literal string rather than by importing the transport. The default server
922
+ * comes from `notify-defaults.mjs`, so this row cannot name a different one.
923
+ * The topic value is never read into a string that could reach the report;
924
+ * only the server URL, which is not a capability, is echoed.
925
925
  */
926
+ function configuredValue(env, name) {
927
+ return env && typeof env[name] === 'string' && env[name].trim() ? env[name].trim() : ''
928
+ }
929
+
926
930
  function checkNotify(env) {
927
931
  try {
928
- const topic = env && typeof env.INTERLOCK_NTFY_TOPIC === 'string' ? env.INTERLOCK_NTFY_TOPIC.trim() : ''
932
+ const topic =
933
+ configuredValue(env, 'CLAUDE_PLUGIN_OPTION_NTFY_TOPIC') || configuredValue(env, 'INTERLOCK_NTFY_TOPIC')
929
934
  if (!topic) {
930
935
  return check(
931
936
  'notify',
@@ -934,9 +939,9 @@ function checkNotify(env) {
934
939
  )
935
940
  }
936
941
  const url =
937
- env && typeof env.INTERLOCK_NTFY_URL === 'string' && env.INTERLOCK_NTFY_URL.trim()
938
- ? env.INTERLOCK_NTFY_URL.trim()
939
- : 'https://ntfy.sh'
942
+ configuredValue(env, 'CLAUDE_PLUGIN_OPTION_NTFY_URL') ||
943
+ configuredValue(env, 'INTERLOCK_NTFY_URL') ||
944
+ DEFAULT_NTFY_URL
940
945
  return check('notify', 'ok', `push configured: ${url}`)
941
946
  } catch (err) {
942
947
  // Fail-open, and never `fail`: an unreadable env is a reason to skip the
@@ -1427,12 +1432,12 @@ export function evalPrerequisiteChecks(pluginRoot, env = process.env) {
1427
1432
 
1428
1433
  return [
1429
1434
  enabled
1430
- ? check('evals-harness', 'ok', `${EVAL_ENABLEMENT_VAR} is set, so \`claude plugin eval\` is enabled`)
1435
+ ? check('evals-harness', 'ok', `${EVAL_ENABLEMENT_VAR} is set, so the early-access harness is enabled`)
1431
1436
  : check(
1432
1437
  'evals-harness',
1433
1438
  'skip',
1434
- `${EVAL_ENABLEMENT_VAR} is not set, so the eval harness is unavailable — no ship run needs it`,
1435
- { fix: `export ${EVAL_ENABLEMENT_VAR}=1 to run \`claude plugin eval\`. Nothing else changes.` }
1439
+ `${EVAL_ENABLEMENT_VAR} is not set, so the early-access harness is unavailable — no ship run needs it`,
1440
+ { fix: `Set one of the harness prerequisites by assigning ${EVAL_ENABLEMENT_VAR} the value 1. Nothing else changes.` }
1436
1441
  ),
1437
1442
  credential
1438
1443
  ? // The NAME of the variable, never its value. Reporting which one is