@renzrollon/interlock 1.5.1 → 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 +93 -0
  3. package/README.md +5 -6
  4. package/bin/interlock +1655 -29
  5. package/hooks/mod.mjs +20 -23
  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,232 @@
1
+ // The spec audit: what a reviewer can rely on a living spec to say.
2
+ //
3
+ // Pure over gathered inputs — no fs, no git, no clock. The CLI walks
4
+ // `openspec/specs/**/spec.md`, reads the graph, and hands this module the texts,
5
+ // the reliable spec-to-file edges, the broken links and the graph's coverable
6
+ // files. Every measure is defined once here, by a rule the `spec/spec-audit`
7
+ // spec states, so a figure can be repeated and a second reader gets the same
8
+ // number. The audit judges nothing and blocks nothing: a measure it could not
9
+ // take is absent with its reason, never zero. design D5 of
10
+ // judge-against-base-and-living-specs.
11
+
12
+ import { parseSpec, capabilityOf } from './spec-text.mjs'
13
+
14
+ /** Whole-word bound or prohibition terms, case-insensitive. `nevertheless` is not `never`. */
15
+ const BOUND = /\b(?:shall not|must not|never|at most|at least|exactly once)\b/i
16
+ const NORMATIVE = /\b(?:SHALL|MUST)\b/
17
+ const INLINE_CODE = /`[^`\n]*`/g
18
+ // A standalone number: not welded to a word (`utf8`), not part of a dotted
19
+ // version or a decimal tail (`1.5.0`).
20
+ const NUMBER = /(?<![\w.])\d+(?:\.\d+)?(?![\w]|\.\d)/g
21
+ const LIST_MARKER = /^\s*\d+[.)]\s+/
22
+ const FENCE = /^\s*(```|~~~)/
23
+
24
+ const FLAG_ORDER = ['no-bound', 'no-failure-scenario', 'no-edge-case']
25
+
26
+ const startsWith = (title, word) => new RegExp(`^${word}`, 'i').test(String(title ?? '').trim())
27
+
28
+ /** The measures of one parsed spec. */
29
+ function measure(path, parsed) {
30
+ const c = {
31
+ capability: capabilityOf(path) ?? path,
32
+ path,
33
+ purpose: parsed.purpose !== null,
34
+ requirements: parsed.requirements.length,
35
+ nonNormative: [],
36
+ scenarios: 0,
37
+ missingGiven: 0,
38
+ missingWhen: 0,
39
+ missingThen: 0,
40
+ failure: 0,
41
+ edgeCase: 0,
42
+ unclassified: 0,
43
+ boundLines: 0,
44
+ numericLiterals: [],
45
+ flags: [],
46
+ links: null
47
+ }
48
+
49
+ for (const req of parsed.requirements) {
50
+ if (!NORMATIVE.test(`${req.heading}\n${req.prose}`)) c.nonNormative.push(req.heading)
51
+
52
+ for (const s of req.scenarios) {
53
+ c.scenarios++
54
+ if (!s.given) c.missingGiven++
55
+ if (!s.when) c.missingWhen++
56
+ if (!s.then) c.missingThen++
57
+ if (startsWith(s.title, 'failure')) c.failure++
58
+ else if (startsWith(s.title, 'edge case')) c.edgeCase++
59
+ else if (!startsWith(s.title, 'happy path')) c.unclassified++
60
+ }
61
+
62
+ // `text` opens on the heading line, so index i is line `req.line + i`.
63
+ // Prose is everything before the first scenario heading.
64
+ const lines = req.text.split('\n')
65
+ const proseEnd = req.scenarios.length ? req.scenarios[0].line - req.line : lines.length
66
+ let inFence = false
67
+ for (let i = 0; i < lines.length; i++) {
68
+ const line = lines[i]
69
+ if (BOUND.test(line)) c.boundLines++
70
+ if (FENCE.test(line)) {
71
+ inFence = !inFence
72
+ continue
73
+ }
74
+ if (i === 0 || i >= proseEnd || inFence) continue
75
+ const bare = line.replace(INLINE_CODE, ' ').replace(LIST_MARKER, '')
76
+ for (const m of bare.matchAll(NUMBER)) {
77
+ c.numericLiterals.push({ spec: path, line: req.line + i, value: Number(m[0]) })
78
+ }
79
+ }
80
+ }
81
+
82
+ if (c.boundLines === 0) c.flags.push('no-bound')
83
+ if (c.failure === 0) c.flags.push('no-failure-scenario')
84
+ if (c.edgeCase === 0) c.flags.push('no-edge-case')
85
+ c.flags.sort((a, b) => FLAG_ORDER.indexOf(a) - FLAG_ORDER.indexOf(b))
86
+ return c
87
+ }
88
+
89
+ /**
90
+ * @param {{
91
+ * specs: Array<{ path: string, text?: string, error?: string }>,
92
+ * edges?: Array<{ source_file?: string, target?: string }>|null,
93
+ * graphReason?: string|null,
94
+ * broken?: Array<{ spec: string, file: string }>,
95
+ * graphFiles?: string[]
96
+ * }} input
97
+ */
98
+ export function auditSpecs(input = {}) {
99
+ const specs = Array.isArray(input.specs) ? input.specs : []
100
+ const edges = Array.isArray(input.edges) ? input.edges : null
101
+ const broken = Array.isArray(input.broken) ? input.broken : []
102
+ const graphFiles = Array.isArray(input.graphFiles) ? input.graphFiles : []
103
+
104
+ const graph = { available: edges !== null, reason: null, unspecified: null }
105
+ if (!graph.available) {
106
+ graph.reason = input.graphReason || 'no graph is available'
107
+ }
108
+
109
+ const unreadable = []
110
+ const capabilities = []
111
+ for (const s of specs) {
112
+ if (!s || typeof s.path !== 'string') continue
113
+ if (typeof s.text !== 'string') {
114
+ unreadable.push({ path: s.path, reason: s.error ? String(s.error) : 'the file could not be read' })
115
+ continue
116
+ }
117
+ capabilities.push(measure(s.path, parseSpec(s.text)))
118
+ }
119
+ capabilities.sort((a, b) => a.capability.localeCompare(b.capability))
120
+
121
+ const coverage = { specs: capabilities.length, corpus: specs.length }
122
+
123
+ if (specs.length === 0) {
124
+ return {
125
+ absent: true,
126
+ reason: 'no living spec exists — openspec/specs/ is missing or holds no spec.md',
127
+ coverage,
128
+ unreadable,
129
+ capabilities: [],
130
+ totals: null,
131
+ graph
132
+ }
133
+ }
134
+
135
+ if (edges) {
136
+ const bySpec = new Map()
137
+ const named = new Set()
138
+ for (const e of edges) {
139
+ const spec = e && typeof e.source_file === 'string' ? e.source_file : null
140
+ const target = e && typeof e.target === 'string' ? e.target : ''
141
+ if (!spec || !target.startsWith('file:')) continue
142
+ const file = target.slice('file:'.length)
143
+ if (!bySpec.has(spec)) bySpec.set(spec, new Set())
144
+ bySpec.get(spec).add(file)
145
+ named.add(file)
146
+ }
147
+ for (const c of capabilities) {
148
+ const missing = [...new Set(broken.filter(b => b && b.spec === c.path).map(b => b.file))].sort()
149
+ c.links = { files: (bySpec.get(c.path) ?? new Set()).size, missing }
150
+ }
151
+ const files = [...new Set(graphFiles)].filter(f => !named.has(f)).sort()
152
+ const total = new Set(graphFiles).size
153
+ graph.unspecified = {
154
+ count: files.length,
155
+ total,
156
+ percent: total ? Math.round((files.length / total) * 100) : 0,
157
+ files
158
+ }
159
+ }
160
+
161
+ const sum = key => capabilities.reduce((n, c) => n + c[key], 0)
162
+ const totals = {
163
+ specs: capabilities.length,
164
+ requirements: sum('requirements'),
165
+ nonNormative: capabilities.reduce((n, c) => n + c.nonNormative.length, 0),
166
+ scenarios: sum('scenarios'),
167
+ missingGiven: sum('missingGiven'),
168
+ missingWhen: sum('missingWhen'),
169
+ missingThen: sum('missingThen'),
170
+ failure: sum('failure'),
171
+ edgeCase: sum('edgeCase'),
172
+ unclassified: sum('unclassified'),
173
+ boundLines: sum('boundLines'),
174
+ numericLiterals: capabilities.reduce((n, c) => n + c.numericLiterals.length, 0),
175
+ flagged: capabilities.filter(c => c.flags.length > 0).length
176
+ }
177
+
178
+ return { absent: false, reason: null, coverage, unreadable, capabilities, totals, graph }
179
+ }
180
+
181
+ /** The human report. Every measure not taken is named with its reason. */
182
+ export function formatSpecAudit(report) {
183
+ const out = ['SPEC AUDIT']
184
+ if (report.absent) {
185
+ out.push(` not measured: ${report.reason}`)
186
+ for (const u of report.unreadable) out.push(` unreadable: ${u.path} (${u.reason})`)
187
+ return out.join('\n')
188
+ }
189
+
190
+ const { coverage, totals, graph } = report
191
+ out.push(` covers ${coverage.specs} of ${coverage.corpus} spec${coverage.corpus === 1 ? '' : 's'}`)
192
+ for (const u of report.unreadable) out.push(` unreadable, left out of the totals: ${u.path} (${u.reason})`)
193
+
194
+ out.push(
195
+ ` requirements ${totals.requirements} (${totals.nonNormative} without SHALL or MUST)` +
196
+ `, scenarios ${totals.scenarios}` +
197
+ ` (no GIVEN ${totals.missingGiven}, no WHEN ${totals.missingWhen}, no THEN ${totals.missingThen})`
198
+ )
199
+ out.push(
200
+ ` failure ${totals.failure}, edge case ${totals.edgeCase}, untitled by kind ${totals.unclassified}` +
201
+ `, bound lines ${totals.boundLines}, numeric literals ${totals.numericLiterals}`
202
+ )
203
+
204
+ const flagged = report.capabilities.filter(c => c.flags.length > 0)
205
+ out.push('', `FLAGS — ${flagged.length} of ${report.capabilities.length} capabilities`)
206
+ if (!flagged.length) out.push(' none')
207
+ for (const c of flagged) out.push(` ${c.capability} ${c.flags.join(' ')}`)
208
+
209
+ const nonNormative = report.capabilities.filter(c => c.nonNormative.length > 0)
210
+ if (nonNormative.length) {
211
+ out.push('', 'REQUIREMENTS WITH NEITHER SHALL NOR MUST')
212
+ for (const c of nonNormative) for (const h of c.nonNormative) out.push(` ${c.path} ${h}`)
213
+ }
214
+
215
+ const literals = report.capabilities.flatMap(c => c.numericLiterals)
216
+ out.push('', `NUMERIC LITERALS IN REQUIREMENT PROSE — ${literals.length}, for a person to classify`)
217
+ for (const n of literals) out.push(` ${n.spec}:${n.line} ${n.value}`)
218
+
219
+ out.push('')
220
+ if (!graph.available) {
221
+ out.push(`FILE LINKS — not measured: ${graph.reason}`)
222
+ out.push(`UNSPECIFIED SOURCE — not measured: ${graph.reason}`)
223
+ } else {
224
+ const gone = report.capabilities.filter(c => c.links && c.links.missing.length > 0)
225
+ out.push(`FILE LINKS — ${gone.length} capabilit${gone.length === 1 ? 'y' : 'ies'} name a file that no longer exists`)
226
+ for (const c of gone) for (const f of c.links.missing) out.push(` ${c.path} → ${f} (missing)`)
227
+ const u = graph.unspecified
228
+ out.push(`UNSPECIFIED SOURCE — ${u.count} of ${u.total} coverable files (${u.percent}%) are named by no living spec`)
229
+ for (const f of u.files) out.push(` ${f}`)
230
+ }
231
+ return out.join('\n')
232
+ }
@@ -0,0 +1,105 @@
1
+ // The facts behind the `spec.coverage` signal: which source files a diff adds,
2
+ // edits and keeps, and whether a living spec or one of the change's own delta
3
+ // specs names them.
4
+ //
5
+ // Pure. Spec text and path lists in, a plain structure out: no graph (it is
6
+ // gitignored and absent in CI), no git and no disk. A file is named when a spec
7
+ // mentions its repository-relative path by the one rule the graph's extractor
8
+ // shares (`specMentionedPaths`), compared on the canonical path form, and only
9
+ // coverable source is judged — a test, a configuration file or a document having
10
+ // no spec is not a gap. The gather (`lib/merge-evidence.mjs`) reads the texts;
11
+ // `readSpecCoverage` in `lib/merge-confidence.mjs` judges the facts.
12
+ // design D7 and D8 of complete-and-record-the-merge-verdict.
13
+
14
+ import { isCoverableSource } from './artifacts.mjs'
15
+ import { canonicalizePath } from './risk.mjs'
16
+ import { specMentionedPaths } from './spec-text.mjs'
17
+
18
+ /** The canonical paths a list of `{ path, text }` specs mention. */
19
+ function mentionsOf(specs) {
20
+ const out = new Set()
21
+ for (const spec of Array.isArray(specs) ? specs : []) {
22
+ for (const mentioned of specMentionedPaths(spec?.text)) {
23
+ const canonical = canonicalizePath(mentioned)
24
+ if (canonical) out.add(canonical)
25
+ }
26
+ }
27
+ return out
28
+ }
29
+
30
+ /** The canonical, coverable paths of a list, first-seen order, each once. */
31
+ function coverable(paths) {
32
+ const out = []
33
+ const seen = new Set()
34
+ for (const entry of Array.isArray(paths) ? paths : []) {
35
+ const canonical = canonicalizePath(entry)
36
+ if (!canonical || seen.has(canonical) || !isCoverableSource(canonical)) continue
37
+ seen.add(canonical)
38
+ out.push(canonical)
39
+ }
40
+ return out
41
+ }
42
+
43
+ /**
44
+ * @param {object} input
45
+ * @param {string[]} input.added paths the diff adds (a rename's new path included)
46
+ * @param {string[]} input.modified paths the diff edits
47
+ * @param {string[]} input.sourceFiles every tracked file at the head
48
+ * @param {{path:string,text:string}[]} input.livingAtHead every living spec, as the head has it
49
+ * @param {{path:string,text:string}[]} input.changedSpecsAtBase each living spec the diff changed or deleted, as the base had it
50
+ * @param {{path:string,text:string}[]} input.deltaSpecs the change's own delta specs
51
+ * @returns {{
52
+ * added: { path: string, namedBy: 'living'|'delta'|null }[],
53
+ * lost: { path: string, spec: string }[],
54
+ * legacy: string[],
55
+ * repo: { named: number, total: number }
56
+ * }}
57
+ */
58
+ export function specCoverageFacts({
59
+ added = [],
60
+ modified = [],
61
+ sourceFiles = [],
62
+ livingAtHead = [],
63
+ changedSpecsAtBase = [],
64
+ deltaSpecs = []
65
+ } = {}) {
66
+ const living = mentionsOf(livingAtHead)
67
+ const delta = mentionsOf(deltaSpecs)
68
+ const named = path => living.has(path) || delta.has(path)
69
+
70
+ const addedFiles = coverable(added)
71
+ const addedSet = new Set(addedFiles)
72
+
73
+ const addedFacts = addedFiles.map(path => ({
74
+ path,
75
+ namedBy: living.has(path) ? 'living' : delta.has(path) ? 'delta' : null
76
+ }))
77
+
78
+ // Lost: a kept coverable file (tracked at the head, not added by this diff)
79
+ // that a spec the diff changed named at the base and that nothing names now.
80
+ const tracked = coverable(sourceFiles)
81
+ const lost = []
82
+ const lostSet = new Set()
83
+ for (const spec of Array.isArray(changedSpecsAtBase) ? changedSpecsAtBase : []) {
84
+ const before = mentionsOf([spec])
85
+ for (const path of tracked) {
86
+ if (addedSet.has(path) || lostSet.has(path)) continue
87
+ if (!before.has(path) || named(path)) continue
88
+ lostSet.add(path)
89
+ lost.push({ path, spec: spec.path })
90
+ }
91
+ }
92
+
93
+ // Legacy: an edited coverable file nothing names that this change did not
94
+ // cause to lose its name.
95
+ const legacy = coverable(modified).filter(
96
+ path => !addedSet.has(path) && !lostSet.has(path) && !named(path)
97
+ )
98
+
99
+ return {
100
+ added: addedFacts,
101
+ lost,
102
+ legacy,
103
+ repo: { named: tracked.filter(named).length, total: tracked.length }
104
+ }
105
+ }
@@ -0,0 +1,257 @@
1
+ // The one parser of living-spec text.
2
+ //
3
+ // Three readers need a spec's `### Requirement:` blocks: the subtraction of a
4
+ // requirement a change supersedes, the check that a finding's citation names a
5
+ // requirement that exists, and the spec audit. Left to themselves each would
6
+ // write its own parser, and they would disagree about what a heading is, where
7
+ // a block ends and what a scenario has — the epic's own hand audits already did.
8
+ // So this module is the only place that knows the shape, and it is pure: text
9
+ // in, a plain structure out. No fs, no clock, no git.
10
+ //
11
+ // `extractScenarios` in lib/ready.mjs keeps its own job (conformance) and is not
12
+ // replaced by this. design D1 of judge-against-base-and-living-specs.
13
+
14
+ /** The delta sections OpenSpec splits a change's spec into. */
15
+ const DELTA_SECTIONS = new Set(['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED'])
16
+
17
+ const REQUIREMENT_HEADING = /^###\s+Requirement:\s*(.*?)\s*$/
18
+ const SCENARIO_HEADING = /^####\s+Scenario:\s*(.*?)\s*$/
19
+ const H2 = /^##\s+(.*?)\s*$/
20
+ const ANY_HEADING = /^#{1,6}\s/
21
+ const FENCE = /^\s*(```|~~~)/
22
+ // Only the bulleted bold keyword line counts. The word in running prose does
23
+ // not, and neither does AND, which continues a line rather than starting one.
24
+ const KEYWORD_LINE = /^\s*[-*]\s+\*\*(GIVEN|WHEN|THEN)\*\*/
25
+ const RENAME_LINE = /^\s*[-*]\s+(FROM|TO):\s*`?\s*(?:###\s+Requirement:\s*)?(.*?)\s*`?\s*$/
26
+
27
+ // Any repo-relative path with an extension. Deliberately NOT restricted to a
28
+ // directory allowlist or an extension allowlist: a caller gates every match on
29
+ // the files it knows, so the repository decides what is real. An allowlist here
30
+ // can only produce false negatives — it used to drop Go/Rust/Java layouts
31
+ // (cmd/, internal/, pkg/, apps/, services/) and indexed config files entirely.
32
+ // The leading boundary class excludes URL path segments, which are always
33
+ // preceded by `/`.
34
+ const PATH_RE = /(?:^|[\s`"'(\[])((?:[\w.@-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6})\b/gm
35
+
36
+ /**
37
+ * The paths a piece of spec text mentions, each once, in first-seen order, with
38
+ * the character index of its first mention. The one rule for "a spec mentions
39
+ * this file": the graph's openspec extractor reads it for its spec-to-file
40
+ * edges (which need the line) and `specMentionedPaths` reads it for the
41
+ * spec-coverage signal, so a fresh graph and the signal cannot disagree.
42
+ *
43
+ * A leading `./` is stripped; a path that climbs out of the repository (`..`)
44
+ * cannot name a repository file and is not returned.
45
+ *
46
+ * @param {unknown} text
47
+ * @returns {{ path: string, index: number }[]}
48
+ */
49
+ export function specPathMentions(text) {
50
+ if (typeof text !== 'string' || !text) return []
51
+ const seen = new Set()
52
+ const out = []
53
+ const re = new RegExp(PATH_RE.source, PATH_RE.flags)
54
+ let m
55
+ while ((m = re.exec(text))) {
56
+ const path = m[1].replace(/^(\.\/)+/, '')
57
+ if (path.split('/').includes('..')) continue
58
+ if (seen.has(path)) continue
59
+ seen.add(path)
60
+ out.push({ path, index: m.index })
61
+ }
62
+ return out
63
+ }
64
+
65
+ /**
66
+ * The repository-relative paths a piece of spec text mentions: deduplicated,
67
+ * first-seen order, a leading `./` stripped. See `specPathMentions`.
68
+ *
69
+ * @param {unknown} text
70
+ * @returns {string[]}
71
+ */
72
+ export function specMentionedPaths(text) {
73
+ return specPathMentions(text).map(mention => mention.path)
74
+ }
75
+
76
+ /**
77
+ * The canonical form of a requirement heading: strip a leading `Requirement:`,
78
+ * trim, collapse internal whitespace, keep case. OpenSpec itself matches
79
+ * requirements whitespace-insensitively and case-sensitively, so a case
80
+ * difference is a different requirement.
81
+ *
82
+ * @param {string} heading
83
+ * @returns {string}
84
+ */
85
+ export function requirementKey(heading) {
86
+ return String(heading ?? '')
87
+ .replace(/^\s*Requirement:\s*/, '')
88
+ .replace(/\s+/g, ' ')
89
+ .trim()
90
+ }
91
+
92
+ /**
93
+ * The capability a spec file belongs to, or null when the path is not a spec.
94
+ * Reads the three spellings callers hold: a living spec
95
+ * (`openspec/specs/<cap>/spec.md`), a change's delta spec
96
+ * (`openspec/changes/<name>/specs/<cap>/spec.md`, or `specs/<cap>/spec.md`) and
97
+ * the form relative to a change's specs directory (`<cap>/spec.md`, which is how
98
+ * `inspectChange(...).specFiles` lists them). Windows separators are read too.
99
+ *
100
+ * @param {string} path
101
+ * @returns {string|null}
102
+ */
103
+ export function capabilityOf(path) {
104
+ if (typeof path !== 'string' || !path) return null
105
+ const normal = path.replace(/\\/g, '/').replace(/^(\.\/)+/, '')
106
+ if (normal !== 'spec.md' && !normal.endsWith('/spec.md')) return null
107
+ const dir = normal.slice(0, -'spec.md'.length).replace(/\/+$/, '')
108
+ // A spec.md sitting directly in a specs root names no capability.
109
+ if (/(?:^|\/)(?:openspec\/specs|openspec\/changes\/[^/]+\/specs)$/.test(dir) || dir === 'specs') return null
110
+ const rest =
111
+ dir.match(/(?:^|\/)openspec\/changes\/[^/]+\/specs\/(.+)$/)?.[1] ??
112
+ dir.match(/(?:^|\/)openspec\/specs\/(.+)$/)?.[1] ??
113
+ dir.match(/^specs\/(.+)$/)?.[1] ??
114
+ dir
115
+ return rest ? rest : null
116
+ }
117
+
118
+ /** Which delta section an `## <NAME> Requirements` heading opens, or null. */
119
+ function deltaSection(h2) {
120
+ const name = h2.trim().split(/\s+/)[0]?.toUpperCase()
121
+ return DELTA_SECTIONS.has(name) ? name : null
122
+ }
123
+
124
+ /**
125
+ * Parse a spec's text, living or delta.
126
+ *
127
+ * `section` on a requirement is the delta section it sits under, and null in a
128
+ * living spec. `prose` is the block apart from its scenarios; `text` is the
129
+ * whole block, heading and scenarios included, ending where the next heading of
130
+ * the same or a higher level begins. A RENAMED section yields FROM/TO pairs and
131
+ * no requirements.
132
+ *
133
+ * @param {string} text
134
+ * @returns {{
135
+ * purpose: string|null,
136
+ * requirements: Array<{
137
+ * heading: string, key: string, section: string|null, line: number,
138
+ * prose: string, text: string,
139
+ * scenarios: Array<{ title: string, line: number, given: boolean, when: boolean, then: boolean }>
140
+ * }>,
141
+ * renamed: Array<{ from: string, to: string }>
142
+ * }}
143
+ */
144
+ export function parseSpec(text) {
145
+ const lines = String(text ?? '').split('\n')
146
+ const requirements = []
147
+ const renamed = []
148
+ const purposeLines = []
149
+
150
+ let h2 = null // the current `##` heading's text
151
+ let section = null // the delta section it opens, if any
152
+ let req = null // the requirement being read
153
+ let scenario = null // the scenario being read, inside `req`
154
+ let inFence = false
155
+ let pendingFrom = null
156
+
157
+ const closeRequirement = () => {
158
+ if (req) req.text = req.lines.join('\n').trimEnd()
159
+ if (req) req.prose = req.proseLines.join('\n').trim()
160
+ req = null
161
+ scenario = null
162
+ }
163
+
164
+ for (let i = 0; i < lines.length; i++) {
165
+ const line = lines[i]
166
+ const lineNo = i + 1
167
+
168
+ if (FENCE.test(line)) {
169
+ inFence = !inFence
170
+ if (req) {
171
+ req.lines.push(line)
172
+ if (!scenario) req.proseLines.push(line)
173
+ }
174
+ continue
175
+ }
176
+
177
+ if (!inFence && ANY_HEADING.test(line)) {
178
+ const h2Match = line.match(H2)
179
+ if (h2Match) {
180
+ closeRequirement()
181
+ h2 = h2Match[1]
182
+ section = deltaSection(h2)
183
+ pendingFrom = null
184
+ continue
185
+ }
186
+ const reqMatch = line.match(REQUIREMENT_HEADING)
187
+ if (reqMatch) {
188
+ closeRequirement()
189
+ if (section !== 'RENAMED') {
190
+ const heading = reqMatch[1]
191
+ req = {
192
+ heading,
193
+ key: requirementKey(heading),
194
+ section,
195
+ line: lineNo,
196
+ prose: '',
197
+ text: '',
198
+ scenarios: [],
199
+ lines: [line],
200
+ proseLines: []
201
+ }
202
+ requirements.push(req)
203
+ }
204
+ continue
205
+ }
206
+ const scenarioMatch = line.match(SCENARIO_HEADING)
207
+ if (scenarioMatch && req) {
208
+ scenario = { title: scenarioMatch[1], line: lineNo, given: false, when: false, then: false }
209
+ req.scenarios.push(scenario)
210
+ req.lines.push(line)
211
+ continue
212
+ }
213
+ // A `###` that is not a requirement ends the block it interrupts. A
214
+ // deeper heading that is not a scenario is text inside the block.
215
+ if (req && /^#{4,}\s/.test(line)) {
216
+ req.lines.push(line)
217
+ if (!scenario) req.proseLines.push(line)
218
+ continue
219
+ }
220
+ if (req) closeRequirement()
221
+ continue
222
+ }
223
+
224
+ if (req) {
225
+ req.lines.push(line)
226
+ if (scenario) {
227
+ const kw = line.match(KEYWORD_LINE)
228
+ if (kw) scenario[kw[1].toLowerCase()] = true
229
+ } else {
230
+ req.proseLines.push(line)
231
+ }
232
+ continue
233
+ }
234
+
235
+ if (section === 'RENAMED') {
236
+ const m = line.match(RENAME_LINE)
237
+ if (m) {
238
+ if (m[1] === 'FROM') pendingFrom = m[2]
239
+ else if (pendingFrom !== null) {
240
+ renamed.push({ from: requirementKey(pendingFrom), to: requirementKey(m[2]) })
241
+ pendingFrom = null
242
+ }
243
+ }
244
+ continue
245
+ }
246
+
247
+ if (h2 !== null && /^purpose$/i.test(h2.trim())) purposeLines.push(line)
248
+ }
249
+ closeRequirement()
250
+
251
+ const purpose = purposeLines.join('\n').trim()
252
+ return {
253
+ purpose: purpose ? purpose : null,
254
+ requirements: requirements.map(({ lines: _lines, proseLines: _proseLines, ...r }) => r),
255
+ renamed
256
+ }
257
+ }
package/lib/verify.mjs CHANGED
@@ -829,8 +829,13 @@ export function judgeCoverageResult(result, opts = {}) {
829
829
  * quietly taking the permissive branch on a typo is how a hard halt goes
830
830
  * missing.
831
831
  * @returns {{halt: boolean, reason: string|null, context: string, haltingKinds: string[],
832
- * unit: object|null, e2e: object|null, coverage: object|null, signals: Array,
833
- * banners: string[], skipped: Array}}
832
+ * unit: object|null, e2e: object|null, coverage: object|null,
833
+ * typecheck: {ran: boolean, exitCode: number|null, passed: boolean}|null,
834
+ * signals: Array, banners: string[], skipped: Array}}
835
+ * `typecheck` is the type check's own result in every context — `null` when
836
+ * no type-check result was reported, so a pass reads differently from a
837
+ * result that never arrived. It moves no halting set: a failure at the final
838
+ * gate is still the non-blocking banner.
834
839
  */
835
840
  export function judgeVerification(plan, results = [], opts = {}) {
836
841
  // Both halves matter: `skipped` is iterated below, so a hand-written plan
@@ -891,16 +896,23 @@ export function judgeVerification(plan, results = [], opts = {}) {
891
896
  banners.push(text)
892
897
  }
893
898
 
899
+ // The type check's own result, recorded whether it passed or failed and in
900
+ // every context, so a reader can tell "passed" from "never reported" without
901
+ // parsing a banner. `null` only when no type-check result was reported. It
902
+ // decides nothing here: whether a failure halts is still `record`'s call.
903
+ let typecheckResult = null
894
904
  const typecheck = byKind.get('typecheck')
895
905
  if (typecheck) {
896
906
  validateResult(typecheck, 'typecheck')
897
- const ok = typecheck.ran !== false && Number(typecheck.exitCode) === 0
898
- const detail = typecheck.ran === false
907
+ const ran = typecheck.ran !== false
908
+ const ok = ran && Number(typecheck.exitCode) === 0
909
+ const detail = !ran
899
910
  ? trimmedString(typecheck.reason) || 'the typecheck command could not be executed'
900
911
  : `exit ${Number(typecheck.exitCode)}`
901
912
  record('typecheck', !ok, `typecheck failed (${detail})`, {
902
913
  banner: BANNERS.typecheckFailed(detail)
903
914
  })
915
+ typecheckResult = { ran, exitCode: ran ? Number(typecheck.exitCode) : null, passed: ok }
904
916
  }
905
917
 
906
918
  let unit = null
@@ -937,6 +949,7 @@ export function judgeVerification(plan, results = [], opts = {}) {
937
949
  unit,
938
950
  e2e,
939
951
  coverage,
952
+ typecheck: typecheckResult,
940
953
  signals,
941
954
  banners,
942
955
  skipped: plan.skipped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@renzrollon/interlock",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Spec-driven development for Claude Code, layered on OpenSpec. You read one spec; parallel agents ship it to a tested commit.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -133,7 +133,7 @@ is narrow. Not running tests after the fix.
133
133
  5. Resumable — a stopped run picks up where it left off
134
134
 
135
135
  **Flow (review):**
136
- 1. `/interlock:review-code` fans out up to six dimension reviewers (language, architecture, QA, delivery; devops and security when the diff earns them)
136
+ 1. `/interlock:review-code` fans out up to seven dimension reviewers (language, architecture, QA, delivery; devops and security when the diff earns them; domain when a touched capability has a living spec)
137
137
  2. Two skeptics try to refute every blocker and warning; a dismissal must cite a `file:line` span
138
138
  3. Only surviving findings reach you; the report states how many were dismissed
139
139