@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.
- package/.claude-plugin/plugin.json +12 -1
- package/CHANGELOG.md +104 -0
- package/README.md +7 -8
- package/bin/interlock +1655 -29
- package/hooks/mod.mjs +21 -20
- package/hooks/notify-option.mjs +69 -0
- package/hooks/preflight.mjs +6 -42
- package/lib/conformance.mjs +75 -0
- package/lib/coverage.mjs +387 -0
- package/lib/doctor.mjs +17 -13
- package/lib/drift.mjs +36 -12
- package/lib/findings.mjs +40 -3
- package/lib/graph/extract/openspec.mjs +6 -15
- package/lib/limits.mjs +78 -1
- package/lib/merge-confidence.mjs +1302 -0
- package/lib/merge-evidence.mjs +527 -0
- package/lib/notify.mjs +10 -20
- package/lib/outcomes.mjs +26 -5
- package/lib/pipeline.mjs +380 -0
- package/lib/prompts/dimensions.mjs +24 -2
- package/lib/prompts/domain-context.mjs +271 -0
- package/lib/prompts/handoff.mjs +32 -1
- package/lib/prompts/index.mjs +57 -0
- package/lib/prompts/remediate.mjs +29 -5
- package/lib/prompts/review.mjs +21 -7
- package/lib/prompts/schemas.mjs +2 -0
- package/lib/ready.mjs +13 -1
- package/lib/receipt.mjs +72 -2
- package/lib/review-core.mjs +224 -21
- package/lib/risk.mjs +8 -0
- package/lib/run-log.mjs +44 -0
- package/lib/run.mjs +859 -27
- package/lib/spec-audit.mjs +232 -0
- package/lib/spec-coverage.mjs +105 -0
- package/lib/spec-text.mjs +257 -0
- package/lib/verify.mjs +17 -4
- package/package.json +1 -1
- package/shared/SESSION-SHAPES.md +1 -1
- package/shared/TEST-PROFILE.md +39 -2
- package/skills/mr/SKILL.md +161 -11
- package/skills/review-code/REVIEW.template.md +24 -6
- package/skills/review-code/SKILL.md +16 -6
- 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
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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:
|
|
85
|
+
if (topicShapeInvalid(rawTopic)) return { invalid: NOTIFY_TOPIC_ENV }
|
|
94
86
|
|
|
95
|
-
const
|
|
96
|
-
|
|
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.
|