@renzrollon/interlock 1.5.0 → 1.6.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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +12 -1
  2. package/CHANGELOG.md +104 -0
  3. package/README.md +7 -8
  4. package/bin/interlock +1655 -29
  5. package/hooks/mod.mjs +21 -20
  6. package/hooks/notify-option.mjs +69 -0
  7. package/hooks/preflight.mjs +6 -42
  8. package/lib/conformance.mjs +75 -0
  9. package/lib/coverage.mjs +387 -0
  10. package/lib/doctor.mjs +17 -13
  11. package/lib/drift.mjs +36 -12
  12. package/lib/findings.mjs +40 -3
  13. package/lib/graph/extract/openspec.mjs +6 -15
  14. package/lib/limits.mjs +78 -1
  15. package/lib/merge-confidence.mjs +1302 -0
  16. package/lib/merge-evidence.mjs +527 -0
  17. package/lib/notify.mjs +10 -20
  18. package/lib/outcomes.mjs +26 -5
  19. package/lib/pipeline.mjs +380 -0
  20. package/lib/prompts/dimensions.mjs +24 -2
  21. package/lib/prompts/domain-context.mjs +271 -0
  22. package/lib/prompts/handoff.mjs +32 -1
  23. package/lib/prompts/index.mjs +57 -0
  24. package/lib/prompts/remediate.mjs +29 -5
  25. package/lib/prompts/review.mjs +21 -7
  26. package/lib/prompts/schemas.mjs +2 -0
  27. package/lib/ready.mjs +13 -1
  28. package/lib/receipt.mjs +72 -2
  29. package/lib/review-core.mjs +224 -21
  30. package/lib/risk.mjs +8 -0
  31. package/lib/run-log.mjs +44 -0
  32. package/lib/run.mjs +859 -27
  33. package/lib/spec-audit.mjs +232 -0
  34. package/lib/spec-coverage.mjs +105 -0
  35. package/lib/spec-text.mjs +257 -0
  36. package/lib/verify.mjs +17 -4
  37. package/package.json +1 -1
  38. package/shared/SESSION-SHAPES.md +1 -1
  39. package/shared/TEST-PROFILE.md +39 -2
  40. package/skills/mr/SKILL.md +161 -11
  41. package/skills/review-code/REVIEW.template.md +24 -6
  42. package/skills/review-code/SKILL.md +16 -6
  43. package/skills/review-code/dimensions/domain.md +21 -0
package/hooks/mod.mjs CHANGED
@@ -102,8 +102,12 @@
102
102
  // run's trajectory, its manifest or its wave state, and no listing or
103
103
  // existence check (the hook lists, in Node). It reads at each prompt a person
104
104
  // submits, the one event that reaches a module after the preflight wrote, and
105
- // after the classic session-start event where an engine raises one. A report
106
- // is known by its own written time, so a re-read changes nothing and the band
105
+ // when one of the two commands is run. It never reads at the classic
106
+ // session-start event: the settings hook that writes the file runs inside that
107
+ // event's chain, and the module's hook there resets the meter and returns the
108
+ // chain's call as it is, so after a boundary the module holds no report until
109
+ // the next prompt (clear-the-plugin-directory-holds design D1). A report is
110
+ // known by its own written time, so a re-read changes nothing and the band
107
111
  // stays hidden once hidden; the lines, the hide rule and the card's cap are
108
112
  // `lib/preflight-file.mjs`'s.
109
113
  //
@@ -908,11 +912,6 @@ function endRunAtBoundary($, input) {
908
912
  }
909
913
  }
910
914
 
911
- /** Once the chain beneath has run: the preflight's report, read for every source. */
912
- async function readPreflightAfterStart($, input) {
913
- if (session.interactive) await readPreflight($, str(input.cwd) || session.cwd)
914
- }
915
-
916
915
  /** One Explore investigator the chain let through (design D5). Never throws. */
917
916
  async function countInvestigator($) {
918
917
  try {
@@ -2117,22 +2116,24 @@ export function register(on) {
2117
2116
  // The session boundary (design D4). /clear, /resume and /branch start the
2118
2117
  // session over: the engine empties `$.state`, the launch guard's record with
2119
2118
  // it, and `session.start` does not fire. The meter ends the run it holds on
2120
- // the same event. A compaction is the same session and the same run, and a
2121
- // source this cannot read changes nothing. The pane is left open: it redraws
2122
- // to the no-run text, which is true, and the person dismisses it. Once the
2123
- // settings hooks beneath have run, the preflight's report is read for every
2124
- // source (show-preflight-and-interrupted-runs-at-session-start design D5).
2125
- // The kit raises this event; Claude Code 2.1.291 raises no classic event to
2126
- // a module, so there the prompt hook above reads the report instead.
2119
+ // the same event, before the chain beneath is called. A compaction is the
2120
+ // same session and the same run, and a source this cannot read changes
2121
+ // nothing. The pane is left open: it redraws to the no-run text, which is
2122
+ // true, and the person dismisses it.
2123
+ //
2124
+ // Pass the event on unchanged. The hook returns the forward call itself,
2125
+ // with nothing bound to it, awaited on it or chained after it, so the
2126
+ // event's result, the session's first message included, is the chain's own
2127
+ // and never this module's (clear-the-plugin-directory-holds design D1).
2128
+ // test/spine/mod-pins.test.mjs holds the hook to that shape.
2127
2129
  //
2128
- // Pass the event on unchanged. The value this hook returns is the forward
2129
- // itself: no first message, no permission decision. The meter's reset runs
2130
- // before that chain and the preflight read after it; neither becomes the return.
2130
+ // The preflight's report is never read here. The settings hook that writes
2131
+ // it runs inside this chain, so a read before the call would draw the
2132
+ // previous session's file, and a read after it needs the chain's promise.
2133
+ // The report is read at a person's prompt, above, and by the two commands.
2131
2134
  on('classic.SessionStart', async (meterSessionApi, meterSessionInput, meterSessionForward) => {
2132
2135
  endRunAtBoundary(meterSessionApi, meterSessionInput)
2133
- const sessionForwarded = meterSessionForward(meterSessionInput)
2134
- await sessionForwarded.then(() => readPreflightAfterStart(meterSessionApi, meterSessionInput))
2135
- return sessionForwarded
2136
+ return meterSessionForward(meterSessionInput)
2136
2137
  })
2137
2138
 
2138
2139
  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
@@ -0,0 +1,69 @@
1
+ // SessionStart hook: hand one saved plugin option to the session's commands.
2
+ //
3
+ // Run as `node hooks/notify-option.mjs <topic|url> <value>`, where the value is
4
+ // what the manifest wrote as an option reference and the host substituted.
5
+ //
6
+ // WHY IT EXISTS. The host gives a plugin option to hook processes only. The
7
+ // command that pushes when a ship run stops (`interlock run close --notify`,
8
+ // `interlock notify`) is run through the Bash tool or by `interlock-run`, and
9
+ // is not a hook. The session's environment file is the host's channel between
10
+ // a hook and those commands, so this hook appends one `export` of the matching
11
+ // variable there, and `lib/notify.mjs` reads that variable and nothing else.
12
+ //
13
+ // WHY ONE HOOK TO AN OPTION. The host does not run a hook whose arguments name
14
+ // an option with no saved value, and prints nothing about it (probed on Claude
15
+ // Code 2.1.296 and 2.1.161; clear-the-plugin-directory-holds design D4). So
16
+ // the reference cannot sit in the preflight's entry, which must run for
17
+ // everyone, and the topic and the server cannot share an entry, or a person
18
+ // who saved a topic and left the server alone would have neither exported.
19
+ // Each entry exists, for the host, only when the person saved that option.
20
+ //
21
+ // WHAT IT NEVER DOES. It prints nothing to standard output, because a
22
+ // session-start hook's output becomes context and the topic is a capability.
23
+ // It exits 0 on every path. It leaves a variable the operator already provided
24
+ // as it is. It writes nothing for a value that is empty, that carries a line
25
+ // break or a NUL, or that still reads as a reference nobody substituted. It
26
+ // never judges the value's shape: `readNotifyConfig` does that where the value
27
+ // is used, and names the variable without the value.
28
+
29
+ import { appendFileSync } from 'node:fs'
30
+
31
+ /** The variable each kind of option is exported as. */
32
+ const VARIABLES = new Map([
33
+ ['topic', 'INTERLOCK_NTFY_TOPIC'],
34
+ ['url', 'INTERLOCK_NTFY_URL']
35
+ ])
36
+
37
+ /** How a reference the host passed through as written begins. */
38
+ const UNSUBSTITUTED = '${user_config.'
39
+
40
+ /** A single-quoted shell word. */
41
+ function shellQuote(value) {
42
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
43
+ }
44
+
45
+ /** A value worth exporting: text, not blank, on one line, and not a reference left as written. */
46
+ function publishable(value) {
47
+ return typeof value === 'string' && value.trim() !== '' && !/[\0\r\n]/.test(value) && !value.includes(UNSUBSTITUTED)
48
+ }
49
+
50
+ /** A variable the operator's own environment already carries. */
51
+ function provided(name) {
52
+ const value = process.env[name]
53
+ return typeof value === 'string' && value.trim() !== ''
54
+ }
55
+
56
+ let variable
57
+ try {
58
+ const [kind, value] = process.argv.slice(2)
59
+ variable = VARIABLES.get(kind)
60
+ const file = process.env.CLAUDE_ENV_FILE
61
+ if (variable && publishable(value) && typeof file === 'string' && file && !provided(variable)) {
62
+ // One short line in a single write: the other option's hook appends beside this one.
63
+ appendFileSync(file, `export ${variable}=${shellQuote(value.trim())}\n`)
64
+ }
65
+ } catch (err) {
66
+ const reason = String((err && err.message) || err).replace(/\s+/g, ' ')
67
+ process.stderr.write(`interlock notify option: ${variable || 'the option'} was not exported (ignored): ${reason}\n`)
68
+ }
69
+ process.exit(0)
@@ -40,9 +40,14 @@
40
40
  // on stderr. Every other repository gets no file, no directory, and exactly
41
41
  // the output it got before. The file is outcome-class: losing it costs the
42
42
  // band, never the session.
43
+ //
44
+ // It reads and writes nothing for the push. A plugin option reaches the
45
+ // session's commands through a hook of its own, `hooks/notify-option.mjs`,
46
+ // which the host runs only for an option the person saved; the manifest's
47
+ // entry for this hook names no option, so it runs for everyone.
43
48
 
44
49
  import { execFileSync } from 'node:child_process'
45
- import { appendFileSync, existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
50
+ import { existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
46
51
  import { dirname, join, resolve } from 'node:path'
47
52
  import { fileURLToPath } from 'node:url'
48
53
 
@@ -283,48 +288,7 @@ function report(message) {
283
288
  process.stdout.write(JSON.stringify(out) + '\n')
284
289
  }
285
290
 
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
-
320
291
  try {
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
292
  const cwd = process.cwd()
329
293
  const source = await sessionSource()
330
294
  const { message, stateHome, surface, doctor } = run()
@@ -79,6 +79,79 @@ export function buildChecklist(root = '.', requested, opts = {}) {
79
79
  }
80
80
  }
81
81
 
82
+ /**
83
+ * The living list: the requirements already in force for the capabilities the
84
+ * change touches, as questions for the handoff. Pure. It is the domain
85
+ * reviewer's own selection and bounded text (`lib/prompts/domain-context.mjs`)
86
+ * turned into locations, so the reviewer and the handoff can never disagree about
87
+ * which living requirements a change is judged against.
88
+ *
89
+ * Headings and locations only, never a requirement body: whoever answers reads
90
+ * the spec at the cited line, as it reads a scenario's artifact. Identifiers run
91
+ * L1..Ln in capability then spec order, and `requirementsRequired` is the only
92
+ * denominator a reader may use. design D5 of
93
+ * complete-and-record-the-merge-verdict.
94
+ *
95
+ * `skipped` and `partial` come from the selection alone: the assembler also
96
+ * reports a skip when a graph linked nothing, and that is an empty list, not a
97
+ * missing graph.
98
+ *
99
+ * @param {{ selection?: object, assembled?: object }} input
100
+ * @returns {{
101
+ * skipped: string|null, partial: string|null,
102
+ * requirements: { id: string, capability: string, artifact: string, line: number, title: string }[],
103
+ * requirementsRequired: number,
104
+ * omitted: { capability: string, heading: string }[],
105
+ * source: string
106
+ * }}
107
+ */
108
+ export function buildLivingChecklist({ selection, assembled } = {}) {
109
+ const skipped = typeof selection?.skipped === 'string' && selection.skipped ? selection.skipped : null
110
+ const partial = typeof selection?.partial === 'string' && selection.partial ? selection.partial : null
111
+ const kept = Array.isArray(assembled?.kept) ? assembled.kept : []
112
+ const requirements = kept.map((entry, i) => ({
113
+ id: `L${i + 1}`,
114
+ capability: entry.capability,
115
+ artifact: `openspec/specs/${entry.capability}/spec.md`,
116
+ line: entry.line,
117
+ title: entry.heading
118
+ }))
119
+ return {
120
+ skipped,
121
+ partial,
122
+ requirements,
123
+ requirementsRequired: requirements.length,
124
+ omitted: (Array.isArray(assembled?.omitted) ? assembled.omitted : []).map(o => ({
125
+ capability: o.capability,
126
+ heading: o.heading
127
+ })),
128
+ source: typeof assembled?.source === 'string' ? assembled.source : 'the working tree'
129
+ }
130
+ }
131
+
132
+ /** The living block of a checklist, as lines; empty when none was asked for. */
133
+ function livingLines(living) {
134
+ if (!living || typeof living !== 'object') return []
135
+ const lines = ['']
136
+ const n = Array.isArray(living.requirements) ? living.requirements.length : 0
137
+ if (living.skipped) {
138
+ lines.push(` Living requirements — not built: ${living.skipped}`)
139
+ return lines
140
+ }
141
+ lines.push(` Living requirements in force — ${n} to confirm (read from ${living.source})`)
142
+ if (living.partial) lines.push(` Partial: ${living.partial}`)
143
+ for (const r of living.requirements ?? []) {
144
+ lines.push(` ${r.id} ${r.title}`)
145
+ lines.push(` ${r.artifact}:${r.line}`)
146
+ }
147
+ if (Array.isArray(living.omitted) && living.omitted.length) {
148
+ lines.push('')
149
+ lines.push(` ${living.omitted.length} requirement${living.omitted.length === 1 ? '' : 's'} left out to fit the size bound:`)
150
+ for (const o of living.omitted) lines.push(` ${o.capability}: ${o.heading}`)
151
+ }
152
+ return lines
153
+ }
154
+
82
155
  /** Human-readable checklist. One numbered question per scenario, nothing decided. */
83
156
  export function formatChecklist(result) {
84
157
  const lines = []
@@ -103,6 +176,7 @@ export function formatChecklist(result) {
103
176
  lines.push(' A change may legitimately carry none. But a change that promised behaviour and')
104
177
  lines.push(' wrote no scenario has nothing to conform to, which is worth noticing.')
105
178
  }
179
+ lines.push(...livingLines(result.living))
106
180
  lines.push('')
107
181
  lines.push('Nothing here blocks.')
108
182
  return lines.join('\n')
@@ -116,6 +190,7 @@ export function formatChecklist(result) {
116
190
  lines.push(` ${s.id} ${s.title}`)
117
191
  lines.push(` ${s.artifact}:${s.line}`)
118
192
  }
193
+ lines.push(...livingLines(result.living))
119
194
 
120
195
  if (result.changedFiles.length) {
121
196
  lines.push('')