@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
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
|
-
//
|
|
106
|
-
//
|
|
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
|
|
2121
|
-
//
|
|
2122
|
-
// to the no-run text, which is
|
|
2123
|
-
//
|
|
2124
|
-
//
|
|
2125
|
-
//
|
|
2126
|
-
//
|
|
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
|
-
//
|
|
2129
|
-
//
|
|
2130
|
-
//
|
|
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
|
-
|
|
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)
|
package/hooks/preflight.mjs
CHANGED
|
@@ -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 {
|
|
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()
|
package/lib/conformance.mjs
CHANGED
|
@@ -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('')
|