@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
@@ -0,0 +1,527 @@
1
+ // The merge-confidence gather and the run's evidence file.
2
+ //
3
+ // `lib/merge-confidence.mjs` is pure: it takes one plain object and never reads
4
+ // a file. Something has to assemble that object, and it used to be assembled
5
+ // inline by `interlock merge-confidence`. A ship run's close needs the same
6
+ // object, and two assemblers of one shape drift — the drift would show as two
7
+ // verdicts for one change. So the assembly lives here, once, and both callers
8
+ // reach it (design D1 of complete-and-record-the-merge-verdict).
9
+ //
10
+ // WHAT IT READS. From the working tree: the change and its artifacts, the
11
+ // decision ledger, the delta checklist, drift (unarchived changes and broken
12
+ // citations only — the aging tier costs one `git log` per spec and no signal
13
+ // reads it), the living list and, when no base is named, the living text. From
14
+ // the base: the review policy, the living text the list is built from, and the
15
+ // living specs the diff changed. Git is reached ONLY through `deps`
16
+ // (`diffShape`, `nameStatus`, `listFiles`, `readAtRef`), each taking the root
17
+ // first, so the module is testable without a repository and a caller with no
18
+ // git still gets an answer.
19
+ //
20
+ // DEGRADATION IS SPOKEN. A `deps` function that is absent, or that throws, never
21
+ // ends the gather: the fact that needed it stays null — which the signal reads
22
+ // as unobserved, never green — and one note per function says what could not be
23
+ // read and why. The caller speaks the notes: the command on standard error, the
24
+ // close in its degradation block.
25
+ //
26
+ // THE EVIDENCE FILE. What only a run observed (the adjudicated review, the final
27
+ // verdict, the handoff counts, the coverage figures, the run record) is written
28
+ // by the close to `.claude/ship/merge-confidence.json` and read back by
29
+ // `interlock merge-confidence --run`. It is evidence about the commit that run
30
+ // made and about nothing else (design D9), so `readRunEvidence` refuses a file
31
+ // for another commit, another change, or a run that made no commit. It is also
32
+ // evidence about one DIFF: a run reviews from its start commit to the commit it
33
+ // made, so when the caller names the base under assessment the file is used only
34
+ // when that is where the run started (design D8 of
35
+ // label-and-harden-the-merge-verdict). The assessment recorded beside the
36
+ // evidence is for a reader of the file and is never returned as an input.
37
+
38
+ import { existsSync, readFileSync, statSync } from 'node:fs'
39
+ import { join } from 'node:path'
40
+ import { inspectChange, resolveChange } from './artifacts.mjs'
41
+ import { buildChecklist, buildLivingChecklist } from './conformance.mjs'
42
+ import { detectStaleSpecs, detectUnarchived, livingSpecEdges, readGraphAt } from './drift.mjs'
43
+ import { readLedger, summarize as summarizeLedger } from './ledger.mjs'
44
+ import { LIMITS } from './limits.mjs'
45
+ import { assembleDomainContext, selectDomainCapabilities } from './prompts/domain-context.mjs'
46
+ import { REVIEW_POLICY_FILE, composePolicy, parseReviewPolicy, readReviewPolicy } from './review-core.mjs'
47
+ import { canonicalizePath } from './risk.mjs'
48
+ import { specCoverageFacts } from './spec-coverage.mjs'
49
+ import { capabilityOf } from './spec-text.mjs'
50
+
51
+ /** The identifier a run's evidence file carries. A reader refuses any other. */
52
+ export const RUN_EVIDENCE_SCHEMA = 'interlock.merge-evidence/1'
53
+
54
+ /** The inputs only a caller can hand in, in the order the input object lists them. */
55
+ const SUPPLIED_KEYS = Object.freeze(['review', 'verify', 'handoff', 'pipeline', 'coverage', 'run'])
56
+
57
+ /** The evidence a run's file carries: the supplied inputs a run can observe (never the pipeline). */
58
+ const RUN_EVIDENCE_KEYS = Object.freeze(['review', 'verify', 'handoff', 'coverage', 'run'])
59
+
60
+ /** Where a repository's graph lives when the caller names no other. */
61
+ const GRAPH_FILE = join('.claude', 'graph', 'graph.json')
62
+
63
+ /** A living spec, on the canonical path form. Only these are read as specs. */
64
+ const LIVING_SPEC_PATH = /^openspec\/specs\/.+\/spec\.md$/
65
+
66
+ const livingSpecPathOf = capability => `openspec/specs/${capability}/spec.md`
67
+
68
+ const isObject = value => !!value && typeof value === 'object' && !Array.isArray(value)
69
+ const strings = value => (Array.isArray(value) ? value.filter(v => typeof v === 'string' && v) : [])
70
+ const messageOf = err => (err && err.message) || String(err)
71
+
72
+ /**
73
+ * What each git function is needed for, as a note names it. One note per
74
+ * function however many reads failed: three unreadable files at one unreachable
75
+ * base are one fact, not three.
76
+ */
77
+ const DEP_WHAT = Object.freeze({
78
+ diffShape: () => 'the diff against the base',
79
+ nameStatus: () => 'spec coverage: the files the diff adds, edits and deletes',
80
+ listFiles: () => 'spec coverage: the tracked files at the head',
81
+ readAtRef: uses => `files at the base (${[...uses].join(', ')})`
82
+ })
83
+
84
+ /**
85
+ * Calls into `deps` that never throw, and the notes for the ones that could not
86
+ * answer. `call(name, use, ...args)` returns `{ value }` or `{ failed: true }`.
87
+ */
88
+ function gitThrough(deps) {
89
+ const source = isObject(deps) ? deps : {}
90
+ /** dep name -> { reason, uses } for every function that was absent or threw. */
91
+ const failures = new Map()
92
+ const fail = (name, use, reason) => {
93
+ if (!failures.has(name)) failures.set(name, { reason, uses: new Set() })
94
+ failures.get(name).uses.add(use)
95
+ return { failed: true }
96
+ }
97
+ return {
98
+ call(name, use, ...args) {
99
+ if (typeof source[name] !== 'function') {
100
+ return fail(name, use, `no ${name} function was supplied, so this caller has no means of reading it`)
101
+ }
102
+ try {
103
+ return { value: source[name](...args) }
104
+ } catch (err) {
105
+ return fail(name, use, messageOf(err))
106
+ }
107
+ },
108
+ notes() {
109
+ const out = []
110
+ for (const name of Object.keys(DEP_WHAT)) {
111
+ const failure = failures.get(name)
112
+ if (failure) out.push({ what: DEP_WHAT[name](failure.uses), reason: failure.reason })
113
+ }
114
+ return out
115
+ }
116
+ }
117
+ }
118
+
119
+ /** One working-tree file: `{ text }`, `{ absent: true }` or `{ error }`; never throws. */
120
+ function readWorking(root, relative) {
121
+ const path = join(root, relative)
122
+ try {
123
+ if (!existsSync(path)) return { absent: true }
124
+ return { text: readFileSync(path, 'utf8') }
125
+ } catch (err) {
126
+ return { error: messageOf(err) }
127
+ }
128
+ }
129
+
130
+ /** A change's artifacts as text, keyed as `lib/risk.mjs` and the checklist read them. */
131
+ function readChangeArtifacts(root, change) {
132
+ const info = inspectChange(root, change)
133
+ const artifacts = {}
134
+ for (const name of info.present) artifacts[name] = readFileSync(join(info.path, name), 'utf8')
135
+ for (const rel of info.specFiles) {
136
+ const path = join(info.path, 'specs', rel)
137
+ if (existsSync(path)) artifacts[join('specs', rel)] = readFileSync(path, 'utf8')
138
+ }
139
+ return artifacts
140
+ }
141
+
142
+ /**
143
+ * A change's capabilities: the capability each delta spec belongs to, through
144
+ * `capabilityOf`, the one derivation domain selection and the spec audit share.
145
+ */
146
+ function capabilitiesOf(root, change) {
147
+ return inspectChange(root, change).specFiles.map(rel => capabilityOf(rel)).filter(Boolean)
148
+ }
149
+
150
+ /** The working tree's `REVIEW.md` as text plus its parsed policy, or null when absent. */
151
+ function workingPolicyCopy(root) {
152
+ const path = join(root, REVIEW_POLICY_FILE)
153
+ if (!existsSync(path)) return null
154
+ let text = ''
155
+ try {
156
+ if (statSync(path).size <= LIMITS.maxReviewPolicyBytes) text = readFileSync(path, 'utf8')
157
+ } catch {
158
+ // `readReviewPolicy` reports an unreadable file as a problem.
159
+ }
160
+ return { text, policy: readReviewPolicy(root) }
161
+ }
162
+
163
+ /**
164
+ * The always-human-review list, read from the base as a hosting platform reads
165
+ * CODEOWNERS from the base branch, with the working copy compared against it.
166
+ * Null when the base copy could not be reached at all: an unread policy is
167
+ * unobserved, never an empty list.
168
+ */
169
+ function reviewPolicyAtBase(root, base, git) {
170
+ const read = git.call('readAtRef', REVIEW_POLICY_FILE, root, base, REVIEW_POLICY_FILE, {
171
+ maxBytes: LIMITS.maxReviewPolicyBytes
172
+ })
173
+ if (read.failed) return null
174
+ const at = isObject(read.value) ? read.value : {}
175
+ let committed = null
176
+ if (typeof at.text === 'string') {
177
+ committed = { text: at.text, policy: parseReviewPolicy(at.text) }
178
+ } else if (!at.absent) {
179
+ const policy = parseReviewPolicy('')
180
+ policy.problems.push(
181
+ at.tooLarge
182
+ ? `${REVIEW_POLICY_FILE} is ${at.size} bytes, over the ${LIMITS.maxReviewPolicyBytes}-byte scan ` +
183
+ `cap — not parsed; review proceeds under default policy`
184
+ : `${REVIEW_POLICY_FILE} at ${base} could not be read: the reader returned nothing`
185
+ )
186
+ committed = { text: '', policy }
187
+ }
188
+ const policy = composePolicy({ working: workingPolicyCopy(root), base: committed, ref: base })
189
+ // Only the base copy's problems: an unreadable committed always-human-review
190
+ // section is what the signal names, and a Do Not Report slip is not its business.
191
+ const problems = (committed ? committed.policy.problems : []).filter(
192
+ p => !/Do Not Report|excludes nothing|no exclusions applied/.test(p)
193
+ )
194
+ return { ref: base, humanPaths: policy.humanPaths, problems, differsFromBase: policy.differsFromBase }
195
+ }
196
+
197
+ /**
198
+ * The living list (design D5): the selection and bounded text the `domain`
199
+ * reviewer is given, turned into locations by `buildLivingChecklist`. The living
200
+ * text is read from the base when one is named and from the working tree
201
+ * otherwise. Null when a base read it needed could not be made — a list built
202
+ * from half the text would count the wrong denominator.
203
+ */
204
+ function gatherLiving(root, { name, changedPaths, base, graphPath, git }) {
205
+ const readLiving = capability => {
206
+ const relative = livingSpecPathOf(capability)
207
+ if (!base) return readWorking(root, relative)
208
+ const read = git.call('readAtRef', 'the living requirements', root, base, relative)
209
+ if (read.failed) return { failed: true }
210
+ return isObject(read.value) ? read.value : { error: 'the reader returned nothing' }
211
+ }
212
+
213
+ const graph = readGraphAt(graphPath || join(root, GRAPH_FILE))
214
+ const edges = Array.isArray(graph.links) ? livingSpecEdges(graph) : null
215
+
216
+ // Declared: a capability the change's delta specs modify that has a living spec.
217
+ const living = {}
218
+ const declared = []
219
+ let specFiles = []
220
+ if (name) {
221
+ try {
222
+ specFiles = inspectChange(root, name).specFiles
223
+ } catch {
224
+ // A change that cannot be inspected declares nothing.
225
+ }
226
+ }
227
+ for (const rel of specFiles) {
228
+ const capability = capabilityOf(rel)
229
+ if (!capability || declared.includes(capability)) continue
230
+ const read = readLiving(capability)
231
+ if (read.failed) return null
232
+ if (typeof read.text === 'string') {
233
+ living[capability] = read.text
234
+ declared.push(capability)
235
+ }
236
+ }
237
+
238
+ const selection = selectDomainCapabilities({ changedPaths, edges, graphReason: graph.reason, declared })
239
+ const source = base ? `base ${base}` : 'the working tree'
240
+ if (!selection.capabilities.length) return buildLivingChecklist({ selection, assembled: { source } })
241
+
242
+ const deltas = {}
243
+ for (const { capability } of selection.capabilities) {
244
+ if (!(capability in living)) {
245
+ const read = readLiving(capability)
246
+ if (read.failed) return null
247
+ if (typeof read.text === 'string') living[capability] = read.text
248
+ }
249
+ if (name) {
250
+ const delta = readWorking(root, `openspec/changes/${name}/specs/${capability}/spec.md`)
251
+ if (typeof delta.text === 'string') deltas[capability] = delta.text
252
+ }
253
+ }
254
+ const assembled = assembleDomainContext({ capabilities: selection.capabilities, living, deltas, ref: base })
255
+ return buildLivingChecklist({ selection, assembled })
256
+ }
257
+
258
+ /**
259
+ * The spec-coverage facts (design D7, D8), from text alone: the diff's added,
260
+ * edited and deleted paths, the tracked files at the head, every living spec as
261
+ * the working tree has it, the base text of each living spec the diff changed or
262
+ * deleted (an untouched spec mentions the same paths on both sides), and the
263
+ * change's own delta specs. Null when any of them could not be read.
264
+ */
265
+ function gatherSpecCoverage(root, { name, artifacts, base, headSha, git }) {
266
+ const status = git.call('nameStatus', 'spec coverage', root, base, headSha)
267
+ const listed = git.call('listFiles', 'spec coverage', root, headSha || 'HEAD')
268
+ if (status.failed || listed.failed) return null
269
+ const changed = isObject(status.value) ? status.value : {}
270
+ const sourceFiles = strings(listed.value)
271
+
272
+ const livingAtHead = []
273
+ for (const tracked of sourceFiles) {
274
+ const path = canonicalizePath(tracked)
275
+ if (!path || !LIVING_SPEC_PATH.test(path)) continue
276
+ const read = readWorking(root, path)
277
+ if (typeof read.text === 'string') livingAtHead.push({ path, text: read.text })
278
+ }
279
+
280
+ const changedSpecsAtBase = []
281
+ const seen = new Set()
282
+ for (const entry of [...strings(changed.modified), ...strings(changed.deleted)]) {
283
+ const path = canonicalizePath(entry)
284
+ if (!path || seen.has(path) || !LIVING_SPEC_PATH.test(path)) continue
285
+ seen.add(path)
286
+ const read = git.call('readAtRef', 'the living specs the diff changed', root, base, path)
287
+ if (read.failed) return null
288
+ if (isObject(read.value) && typeof read.value.text === 'string') {
289
+ changedSpecsAtBase.push({ path, text: read.value.text })
290
+ }
291
+ }
292
+
293
+ const deltaSpecs = []
294
+ if (name) {
295
+ for (const [key, text] of Object.entries(artifacts)) {
296
+ const relative = key.replace(/\\/g, '/')
297
+ if (relative.startsWith('specs/')) deltaSpecs.push({ path: `openspec/changes/${name}/${relative}`, text })
298
+ }
299
+ }
300
+
301
+ return specCoverageFacts({
302
+ added: strings(changed.added),
303
+ modified: strings(changed.modified),
304
+ sourceFiles,
305
+ livingAtHead,
306
+ changedSpecsAtBase,
307
+ deltaSpecs
308
+ })
309
+ }
310
+
311
+ /**
312
+ * The living list on its own, for `interlock conformance --living` (design D5):
313
+ * the same selection, text and bound the merge gather uses, so the list a person
314
+ * prints by hand and the one the verdict counts cannot differ for one change,
315
+ * base and graph. `living` is null when a base read it needed could not be made,
316
+ * and `notes` then says why.
317
+ *
318
+ * @param {string} root
319
+ * @param {{ change?: string|null, changedPaths?: string[], base?: string|null, graphPath?: string,
320
+ * deps?: { readAtRef?: Function } }} given
321
+ * @returns {{ living: object|null, notes: { what: string, reason: string }[] }}
322
+ */
323
+ export function gatherLivingList(root = '.', { change, changedPaths, base: baseRef, graphPath, deps } = {}) {
324
+ const base = typeof baseRef === 'string' && baseRef ? baseRef : null
325
+ const git = gitThrough(deps)
326
+ const living = gatherLiving(root, {
327
+ name: typeof change === 'string' && change ? change : null,
328
+ changedPaths: [...new Set(strings(changedPaths))],
329
+ base,
330
+ graphPath,
331
+ git
332
+ })
333
+ return { living, notes: git.notes() }
334
+ }
335
+
336
+ /**
337
+ * Gather the input `assessMergeConfidence` takes (design D1).
338
+ *
339
+ * @param {string} root
340
+ * @param {object} given
341
+ * @param {string|null} [given.change] the requested change name; resolved from the repository when omitted
342
+ * @param {string[]} [given.changedPaths] the caller's changed paths; git's own list is added when a base is named
343
+ * @param {string|null} [given.base] the ref the diff, the review policy and the living text are read against
344
+ * @param {string|null} [given.headSha]
345
+ * @param {object} [given.supplied] what only a caller can hand in: review, verify, handoff, pipeline, coverage, run
346
+ * @param {object} [given.deps] git, root first: diffShape(root, base, headSha), nameStatus(root, base, headSha),
347
+ * listFiles(root, ref), readAtRef(root, ref, path, { maxBytes })
348
+ * @param {string} [given.graphPath] the graph to select living requirements from; the root's own when omitted
349
+ * @returns {{ input: object, notes: { what: string, reason: string }[] }}
350
+ */
351
+ export function gatherMergeEvidence(
352
+ root = '.',
353
+ { change: requested, changedPaths: given, base: baseRef, headSha: head, supplied, deps, graphPath } = {}
354
+ ) {
355
+ const base = typeof baseRef === 'string' && baseRef ? baseRef : null
356
+ const headSha = typeof head === 'string' && head ? head : null
357
+ const git = gitThrough(deps)
358
+ const notes = []
359
+
360
+ // With a base, git's own file list is a changed-path source: a path the
361
+ // caller left out still reaches risk, conformance and drift.
362
+ let changedPaths = [...new Set(strings(given))]
363
+ let diff = null
364
+ if (base) {
365
+ const shape = git.call('diffShape', 'the diff', root, base, headSha)
366
+ if (!shape.failed && isObject(shape.value)) {
367
+ diff = shape.value
368
+ const named = new Set(changedPaths)
369
+ changedPaths = [...changedPaths, ...strings(diff.paths).filter(p => !named.has(p))]
370
+ changedPaths = [...new Set(changedPaths)]
371
+ }
372
+ }
373
+
374
+ // What the repository itself says.
375
+ const name = typeof requested === 'string' && requested.trim() ? requested.trim() : undefined
376
+ const resolved = resolveChange(root, name)
377
+ const changeName = resolved.error ? null : resolved.name
378
+ if (!changeName) {
379
+ notes.push({
380
+ what: 'no change under assessment',
381
+ reason: `${resolved.error} — its ledger, artifacts and scenarios are not read`
382
+ })
383
+ }
384
+ const change = changeName ? { name: changeName, capabilities: capabilitiesOf(root, changeName) } : null
385
+ const artifacts = changeName ? readChangeArtifacts(root, changeName) : {}
386
+ let ledger = null
387
+ if (changeName) {
388
+ const parsed = readLedger(root, changeName)
389
+ ledger = {
390
+ ...summarizeLedger(parsed),
391
+ rows: parsed.rows.map(r => ({ id: r.id, question: r.question, class: r.class, valid: r.valid }))
392
+ }
393
+ }
394
+ const checklist = buildChecklist(root, changeName || name, { changed: changedPaths })
395
+
396
+ // Drift, for what the signal reads and no more: complete, unarchived changes
397
+ // (each with its capabilities, so the overlap can be read) and broken
398
+ // citations. A `lastCommit` that knows nothing skips the per-spec `git log`
399
+ // the aging tier needs, and the aging tier is left out rather than reported
400
+ // empty.
401
+ const { aging: _notAsked, ...stale } = detectStaleSpecs(root, { lastCommit: () => null })
402
+ const drift = {
403
+ unarchived: detectUnarchived(root).map(u => ({ ...u, capabilities: capabilitiesOf(root, u.change) })),
404
+ stale
405
+ }
406
+
407
+ const reviewPolicy = base ? reviewPolicyAtBase(root, base, git) : null
408
+ const living = gatherLiving(root, { name: changeName, changedPaths, base, graphPath, git })
409
+ // A diff that is empty by construction has no added or edited file to judge.
410
+ const specCoverage =
411
+ base && !(diff && typeof diff.nothingToDiff === 'string')
412
+ ? gatherSpecCoverage(root, { name: changeName, artifacts, base, headSha, git })
413
+ : null
414
+
415
+ // Passed through as given, and null when not given: a signal reads null as unobserved.
416
+ const fromCaller = isObject(supplied) ? supplied : {}
417
+ const handed = Object.fromEntries(SUPPLIED_KEYS.map(key => [key, fromCaller[key] ?? null]))
418
+
419
+ return {
420
+ input: {
421
+ change,
422
+ changedPaths,
423
+ headSha,
424
+ artifacts,
425
+ review: handed.review,
426
+ verify: handed.verify,
427
+ checklist,
428
+ handoff: handed.handoff,
429
+ pipeline: handed.pipeline,
430
+ coverage: handed.coverage,
431
+ diff,
432
+ ledger,
433
+ drift,
434
+ reviewPolicy,
435
+ run: handed.run,
436
+ living,
437
+ specCoverage
438
+ },
439
+ notes: [...notes, ...git.notes()]
440
+ }
441
+ }
442
+
443
+ /**
444
+ * The object a run's close writes to `.claude/ship/merge-confidence.json`
445
+ * (design D9). The five evidence entries are always present, null for what the
446
+ * run did not observe; `result` is the assessment, for a reader of the file.
447
+ */
448
+ export function buildRunEvidence({ change, runId, base, headSha, committed, evidence, result } = {}) {
449
+ const observed = isObject(evidence) ? evidence : {}
450
+ return {
451
+ schema: RUN_EVIDENCE_SCHEMA,
452
+ change: change ?? null,
453
+ runId: runId ?? null,
454
+ base: base ?? null,
455
+ headSha: headSha ?? null,
456
+ committed: committed === true,
457
+ evidence: Object.fromEntries(RUN_EVIDENCE_KEYS.map(key => [key, observed[key] ?? null])),
458
+ result: result ?? null
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Whether a run's evidence file may be used for the commit under assessment
464
+ * (design D9), and what it supplies when it may. Usable only when the file
465
+ * carries the published schema identifier, names the change under assessment,
466
+ * says the run committed, and records as that commit the head. Otherwise nothing
467
+ * is taken from it and `reason` names the condition that failed. The recorded
468
+ * assessment is never returned. Never throws and never mutates the file.
469
+ *
470
+ * `base` adds one condition, applied after those four and only when it is given
471
+ * (design D8 of label-and-harden-the-merge-verdict). A run reviewed the diff
472
+ * from the commit it started on to the commit it made, and the file ends at the
473
+ * same head whatever lies under it: a pull request of five commits whose last
474
+ * came from a reviewed run is not a reviewed pull request. So `{ sha }`, the
475
+ * merge base of the base under assessment and the head, must equal the base the
476
+ * file records; `{ unreadable: true }` (git gave no merge base, as in a shallow
477
+ * clone) refuses the file. Left `undefined`, nothing changes: no base is ever
478
+ * taken from the file, so the signals that need one stay unobserved.
479
+ *
480
+ * @param {unknown} file the parsed file
481
+ * @param {{ change?: string|null, headSha?: string|null,
482
+ * base?: { sha: string } | { unreadable: true } }} against
483
+ * @returns {{ usable: true, evidence: object } | { usable: false, reason: string }}
484
+ */
485
+ export function readRunEvidence(file, { change, headSha, base } = {}) {
486
+ const refuse = reason => ({ usable: false, reason })
487
+ if (!isObject(file)) return refuse('it does not hold a JSON object')
488
+ if (file.schema !== RUN_EVIDENCE_SCHEMA) {
489
+ return refuse(
490
+ `its schema identifier is ${JSON.stringify(file.schema ?? null)}, not ${JSON.stringify(RUN_EVIDENCE_SCHEMA)}`
491
+ )
492
+ }
493
+ if (!isObject(file.evidence)) return refuse(`it carries the schema identifier ${RUN_EVIDENCE_SCHEMA} and no evidence`)
494
+ if (typeof change !== 'string' || !change || file.change !== change) {
495
+ return refuse(
496
+ `it names the change ${JSON.stringify(file.change ?? null)}, not the change under assessment ` +
497
+ `(${JSON.stringify(change ?? null)})`
498
+ )
499
+ }
500
+ if (file.committed !== true) {
501
+ return refuse('the run that wrote it made no commit, so it describes a working tree no later command can name')
502
+ }
503
+ if (typeof headSha !== 'string' || !headSha) {
504
+ return refuse(`the head commit could not be read, so the commit it records (${file.headSha}) matches nothing`)
505
+ }
506
+ if (file.headSha !== headSha) {
507
+ return refuse(`it describes another commit: it records ${file.headSha}, and the head is ${headSha}`)
508
+ }
509
+ if (base !== undefined && base !== null) {
510
+ const reviewedFrom = typeof file.base === 'string' && file.base ? file.base : null
511
+ const reviewed = `the run reviewed the diff from ${reviewedFrom ?? 'a base it did not record'} to ${file.headSha}`
512
+ const mergeBase = isObject(base) && typeof base.sha === 'string' && base.sha ? base.sha : null
513
+ if (!mergeBase) {
514
+ return refuse(
515
+ `${reviewed}, and the merge base of the base under assessment and the head could not be read, ` +
516
+ `so the two diffs cannot be compared (a shallow clone lacks it: raise the fetch depth)`
517
+ )
518
+ }
519
+ if (reviewedFrom !== mergeBase) {
520
+ return refuse(`${reviewed}, and the diff under assessment runs from the merge base ${mergeBase} to ${headSha}`)
521
+ }
522
+ }
523
+ return {
524
+ usable: true,
525
+ evidence: Object.fromEntries(RUN_EVIDENCE_KEYS.map(key => [key, file.evidence[key] ?? null]))
526
+ }
527
+ }
package/lib/notify.mjs CHANGED
@@ -39,14 +39,6 @@ export const NOTIFY_TOPIC_ENV = 'INTERLOCK_NTFY_TOPIC'
39
39
  /** Where a self-hosted server is read from. Absent means the public default. */
40
40
  export const NOTIFY_URL_ENV = 'INTERLOCK_NTFY_URL'
41
41
 
42
- /**
43
- * Plugin options the host injects into a hook process, from `userConfig`
44
- * `ntfy_topic` and `ntfy_url`. Preferred over the shell variables when present,
45
- * so a value the operator typed into the plugin is the one that is shared.
46
- */
47
- export const NOTIFY_TOPIC_OPTION = 'CLAUDE_PLUGIN_OPTION_NTFY_TOPIC'
48
- export const NOTIFY_URL_OPTION = 'CLAUDE_PLUGIN_OPTION_NTFY_URL'
49
-
50
42
  function messageOf(err) {
51
43
  return (err && err.message) || String(err)
52
44
  }
@@ -74,10 +66,12 @@ function trimmed(source, name) {
74
66
  }
75
67
 
76
68
  /**
77
- * Read push configuration. Never the tree. A plugin option the host injected
78
- * wins over the shell variable of the same meaning, and an absent topic is
79
- * unconfigured rather than an error (design D2). The shell variables remain
80
- * the channel for a terminal, CI, and any host that does not inject options.
69
+ * Read push configuration. Never the tree, and never the host's option
70
+ * variables: the two shell variables are the only source, for a terminal, CI
71
+ * and every host. A plugin option reaches them through its own session-start
72
+ * hook (`hooks/notify-option.mjs`), which exports the matching variable for
73
+ * the session's commands. An absent topic is unconfigured rather than an
74
+ * error (design D2).
81
75
  *
82
76
  * @param {object} source values to read (injected in tests; the process values at call sites)
83
77
  * @returns {{topic: string, url: string} | {topic: null} | {invalid: string}}
@@ -86,16 +80,12 @@ function trimmed(source, name) {
86
80
  * offending variable and never its value.
87
81
  */
88
82
  export function readNotifyConfig(source) {
89
- const optionTopic = trimmed(source, NOTIFY_TOPIC_OPTION)
90
- const shellTopic = trimmed(source, NOTIFY_TOPIC_ENV)
91
- const rawTopic = optionTopic || shellTopic
83
+ const rawTopic = trimmed(source, NOTIFY_TOPIC_ENV)
92
84
  if (!rawTopic) return { topic: null }
93
- if (topicShapeInvalid(rawTopic)) return { invalid: optionTopic ? NOTIFY_TOPIC_OPTION : NOTIFY_TOPIC_ENV }
85
+ if (topicShapeInvalid(rawTopic)) return { invalid: NOTIFY_TOPIC_ENV }
94
86
 
95
- const optionUrl = trimmed(source, NOTIFY_URL_OPTION)
96
- const shellUrl = trimmed(source, NOTIFY_URL_ENV)
97
- const rawUrl = optionUrl || shellUrl || DEFAULT_NTFY_URL
98
- if (urlShapeInvalid(rawUrl)) return { invalid: optionUrl ? NOTIFY_URL_OPTION : NOTIFY_URL_ENV }
87
+ const rawUrl = trimmed(source, NOTIFY_URL_ENV) || DEFAULT_NTFY_URL
88
+ if (urlShapeInvalid(rawUrl)) return { invalid: NOTIFY_URL_ENV }
99
89
 
100
90
  return { topic: rawTopic, url: rawUrl }
101
91
  }
package/lib/outcomes.mjs CHANGED
@@ -71,9 +71,22 @@ export const OBSERVED_FIELDS = Object.freeze([
71
71
  'remediationRounds',
72
72
  'codeBlockersSurviving',
73
73
  'waves',
74
- 'commit'
74
+ 'commit',
75
+ 'mergeVerdict'
75
76
  ])
76
77
 
78
+ /**
79
+ * The three words a merge verdict may be recorded as (design D12). The outcome
80
+ * record stores the verdict and nothing reads it back: no gate, readiness check
81
+ * or ladder consults it, and `interlock report` reads neither this nor the
82
+ * receipt block it comes from.
83
+ */
84
+ const MERGE_VERDICTS = Object.freeze(['MERGE', 'HUMAN', 'BLOCK'])
85
+
86
+ function mergeVerdictOf(value) {
87
+ return typeof value === 'string' && MERGE_VERDICTS.includes(value) ? value : null
88
+ }
89
+
77
90
  /**
78
91
  * The reported group: values only an agent can supply, because nothing else in
79
92
  * the run has seen them — they live in the wave state and in the suite result.
@@ -217,7 +230,7 @@ function isObject(value) {
217
230
  * @param {object|null} receipt a `run-receipt` event, as `readRunLog` returns it
218
231
  * @returns {{receipt: boolean, ok: boolean|null, haltReason: string|null,
219
232
  * remediationRounds: number|null, codeBlockersSurviving: number|null,
220
- * waves: Array<object>|null, commit: string|null}}
233
+ * waves: Array<object>|null, commit: string|null, mergeVerdict: string|null}}
221
234
  */
222
235
  export function observedFromReceipt(receipt) {
223
236
  if (!isObject(receipt)) {
@@ -228,7 +241,8 @@ export function observedFromReceipt(receipt) {
228
241
  remediationRounds: null,
229
242
  codeBlockersSurviving: null,
230
243
  waves: null,
231
- commit: null
244
+ commit: null,
245
+ mergeVerdict: null
232
246
  }
233
247
  }
234
248
  const halted = typeof receipt.halted === 'boolean' ? receipt.halted : null
@@ -244,7 +258,11 @@ export function observedFromReceipt(receipt) {
244
258
  // than becoming zero blockers.
245
259
  codeBlockersSurviving: nullableCount(receipt.reviewBlockers),
246
260
  waves: waveTallies(receipt.waves),
247
- commit: nullableText(receipt.commit, MAX_NAME)
261
+ commit: nullableText(receipt.commit, MAX_NAME),
262
+ // Taken from the receipt's block and nowhere else, and only when it is one
263
+ // of the three published words: a run that never assessed (a halt, a lean
264
+ // run, a receipt from before the block) reads `null`, never a default.
265
+ mergeVerdict: isObject(receipt.mergeConfidence) ? mergeVerdictOf(receipt.mergeConfidence.verdict) : null
248
266
  }
249
267
  }
250
268
 
@@ -454,7 +472,10 @@ export function appendOutcome(root, input = {}) {
454
472
  remediationRounds: nullableCount(observed.remediationRounds),
455
473
  codeBlockersSurviving: nullableCount(observed.codeBlockersSurviving),
456
474
  waves: waveTallies(observed.waves),
457
- commit: nullableText(observed.commit, MAX_NAME)
475
+ commit: nullableText(observed.commit, MAX_NAME),
476
+ // The second layer: a caller that assembles `observed` in process
477
+ // still cannot write a word outside the three.
478
+ mergeVerdict: mergeVerdictOf(observed.mergeVerdict)
458
479
  },
459
480
  // What an agent said, labelled as a report. `null` throughout means
460
481
  // nobody said — never zero skips, never a green suite.