@renzrollon/interlock 1.0.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 (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
@@ -0,0 +1,316 @@
1
+ // OpenSpec change-artifact inspection.
2
+ //
3
+ // Answers two questions the flow asks constantly, without a model call:
4
+ // "is this change ready to implement?" and "how far through tasks.md are we?"
5
+ //
6
+ // Deliberately thin. OpenSpec's own CLI (`openspec status --json`,
7
+ // `openspec validate`) is the authority on schema conformance; this module only
8
+ // covers the filesystem-level checks the flow gates on, so a skill can branch
9
+ // before spending a turn.
10
+ //
11
+ // Exposed to skills as `interlock validate --change <name>`.
12
+
13
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+
16
+ // Extensions whose lines a coverage tool can meaningfully attribute. Anything
17
+ // outside this set (docs, JSON/YAML/TOML config, lockfiles, shell) is dropped
18
+ // before we ever ask an agent to measure it.
19
+ const SOURCE_EXTENSIONS = new Set([
20
+ '.js', '.jsx', '.mjs', '.cjs',
21
+ '.ts', '.tsx', '.mts', '.cts',
22
+ '.py', '.go', '.rs', '.rb', '.java', '.kt', '.kts',
23
+ '.swift', '.php', '.cs', '.scala', '.vue', '.svelte'
24
+ ])
25
+
26
+ const EXCLUDED_DIR =
27
+ /(^|\/)(node_modules|dist|build|out|coverage|vendor|__tests__|__mocks__|__fixtures__|tests?|e2e|fixtures)(\/|$)/i
28
+
29
+ function extensionOf(path) {
30
+ const base = path.slice(path.lastIndexOf('/') + 1)
31
+ const dot = base.lastIndexOf('.')
32
+ return dot <= 0 ? '' : base.slice(dot).toLowerCase()
33
+ }
34
+
35
+ // Diff coverage is only reported for real source files. Test files, config
36
+ // files, generated output and docs are skipped — covering them is meaningless
37
+ // and warning about them is noise.
38
+ export function isCoverableSource(path) {
39
+ if (typeof path !== 'string' || path.length === 0) return false
40
+ const p = path.replace(/\\/g, '/')
41
+ if (p.split('/').some(seg => seg.startsWith('.'))) return false
42
+ if (!SOURCE_EXTENSIONS.has(extensionOf(p))) return false
43
+ if (EXCLUDED_DIR.test(p)) return false
44
+ const base = p.slice(p.lastIndexOf('/') + 1)
45
+ if (/\.(test|spec)\./i.test(base)) return false
46
+ if (/\.(config|conf|d)\./i.test(base)) return false
47
+ return true
48
+ }
49
+
50
+ const CHANGES_DIR = join('openspec', 'changes')
51
+
52
+ /** List change directories under openspec/changes/, excluding archive/. */
53
+ export function listChanges(root = '.') {
54
+ const dir = join(root, CHANGES_DIR)
55
+ if (!existsSync(dir)) return []
56
+ return readdirSync(dir, { withFileTypes: true })
57
+ .filter(e => e.isDirectory() && e.name !== 'archive')
58
+ .map(e => e.name)
59
+ .sort()
60
+ }
61
+
62
+ /**
63
+ * Resolve which change to act on.
64
+ * Returns { name } on success, or { error, candidates } when ambiguous/absent —
65
+ * the caller decides whether to ask a human or halt.
66
+ */
67
+ export function resolveChange(root = '.', requested) {
68
+ const changes = listChanges(root)
69
+ if (requested) {
70
+ if (changes.includes(requested)) return { name: requested }
71
+ return { error: `change "${requested}" not found under ${CHANGES_DIR}/`, candidates: changes }
72
+ }
73
+ if (changes.length === 1) return { name: changes[0] }
74
+ if (changes.length === 0) return { error: `no changes found under ${CHANGES_DIR}/`, candidates: [] }
75
+ return { error: 'multiple active changes — name one explicitly', candidates: changes }
76
+ }
77
+
78
+ // A tasks.md checkbox line: "- [ ] 1.1 Do the thing" / "- [x] ..."
79
+ const TASK_LINE = /^\s*[-*]\s*\[( |x|X)\]\s*(.+?)\s*$/
80
+
81
+ // The leading `1.2` / `1.2.3` of a checkbox line: exactly the token
82
+ // `taskMatchesId` compares against, so an id read here is an id `tasks tick`
83
+ // would act on. A bare `3` counts, because the ticker accepts it — agreeing
84
+ // with the ticker matters more than guessing which numbers were meant as ids.
85
+ const TASK_ID = /^(\d+(?:\.\d+)*)(?:\s|$)/
86
+
87
+ /**
88
+ * Rewrite every checkbox marker to the unchecked form, leaving the rest of the
89
+ * line byte-identical.
90
+ *
91
+ * The plan fingerprint (`lib/plan-fingerprint.mjs`) hashes `tasks.md` through
92
+ * this, so ticking a task does not invalidate the plan covering the tasks that
93
+ * remain — while adding, removing, reordering or rewording one still does,
94
+ * because every other byte of the line is hashed as written. It lives here
95
+ * rather than there so `TASK_LINE` stays the single reader of what a checkbox
96
+ * looks like; a second regex for the same shape is how the two drift apart.
97
+ */
98
+ export function normalizeTaskMarkers(markdown) {
99
+ return (markdown || '')
100
+ .split('\n')
101
+ .map(line => (TASK_LINE.test(line) ? line.replace(/\[[xX]\]/, '[ ]') : line))
102
+ .join('\n')
103
+ }
104
+
105
+ /**
106
+ * The heading token `/interlock:spec` writes above a TDD change's leading
107
+ * failing-test section.
108
+ *
109
+ * Exported because it is the ONE contract between a prose surface and a code
110
+ * surface in this repo: `skills/spec/SKILL.md` writes it and this module reads
111
+ * it, so a reword on either side would silently stop every TDD change from
112
+ * being detected as one. `test/skills.test.mjs` asserts the skill still writes
113
+ * this exact value rather than a copy of it.
114
+ */
115
+ export const RED_SECTION_MARKER = 'Failing tests first'
116
+
117
+ // A numbered tasks.md section heading: "## 1. Failing tests first". Separate
118
+ // from TASK_LINE because it reads a different line shape, and kept beside it so
119
+ // the two readers of tasks.md structure stay together.
120
+ const SECTION_HEADING = /^\s*##\s+(\d+)\s*\.\s*(.*?)\s*$/
121
+
122
+ /**
123
+ * The group number of the leading failing-test section, or `null`.
124
+ *
125
+ * Deterministic and pure: the shape of a TDD run is decided by what the author
126
+ * wrote in `tasks.md`, not by a model re-reading it at plan time. Only the
127
+ * LOWEST matching section counts — a marker further down the file describes
128
+ * tests that run after implementation, which is not a red wave, and treating it
129
+ * as one would reorder a change its author did not write for TDD.
130
+ *
131
+ * The caller decides what to do with the answer; nothing here validates it
132
+ * against a classification (see `resolveRedWave` in `lib/run.mjs`).
133
+ */
134
+ export function detectRedSection(markdown) {
135
+ const marker = RED_SECTION_MARKER.toLowerCase()
136
+ let lowest = null
137
+ for (const line of (markdown || '').split('\n')) {
138
+ const m = SECTION_HEADING.exec(line)
139
+ if (!m) continue
140
+ if (!m[2].toLowerCase().includes(marker)) continue
141
+ const group = Number(m[1])
142
+ if (!Number.isInteger(group)) continue
143
+ if (lowest === null || group < lowest) lowest = group
144
+ }
145
+ return lowest
146
+ }
147
+
148
+ export function parseTasks(markdown) {
149
+ const tasks = []
150
+ const lines = (markdown || '').split('\n')
151
+ lines.forEach((line, i) => {
152
+ const m = TASK_LINE.exec(line)
153
+ if (!m) return
154
+ tasks.push({
155
+ done: m[1].toLowerCase() === 'x',
156
+ text: m[2],
157
+ // The id a caller would tick, resolved here rather than by every reader.
158
+ // A host that has to split the text itself is a host that can get it
159
+ // wrong, and `null` says "this line carries no id" instead of inventing
160
+ // one from prose.
161
+ id: TASK_ID.exec(m[2]) ? TASK_ID.exec(m[2])[1] : null,
162
+ line: i + 1
163
+ })
164
+ })
165
+ return tasks
166
+ }
167
+
168
+ /** True when checkbox text is exactly `id` or starts with `id` plus a space. */
169
+ export function taskMatchesId(text, id) {
170
+ if (typeof text !== 'string' || typeof id !== 'string' || !id) return false
171
+ return text === id || text.startsWith(`${id} `)
172
+ }
173
+
174
+ /**
175
+ * Flip `- [ ]` to `- [x]` for checkbox lines whose text matches an id.
176
+ * Missing ids are reported, never invented. Already-checked matches are listed
177
+ * as already, not re-written.
178
+ */
179
+ export function tickTasks(root, changeName, ids) {
180
+ const wanted = [...new Set((ids || []).map(String).map(s => s.trim()).filter(Boolean))]
181
+ const path = join(root, CHANGES_DIR, changeName, 'tasks.md')
182
+ if (!existsSync(path)) {
183
+ return { change: changeName, path, ticked: [], already: [], missing: wanted, written: false }
184
+ }
185
+ const lines = readFileSync(path, 'utf8').split('\n')
186
+ const ticked = []
187
+ const already = []
188
+ const found = new Set()
189
+ const next = lines.map(line => {
190
+ const m = TASK_LINE.exec(line)
191
+ if (!m) return line
192
+ const id = wanted.find(i => taskMatchesId(m[2], i))
193
+ if (!id) return line
194
+ found.add(id)
195
+ if (m[1].toLowerCase() === 'x') {
196
+ if (!already.includes(id)) already.push(id)
197
+ return line
198
+ }
199
+ if (!ticked.includes(id)) ticked.push(id)
200
+ return line.replace(/\[ \]/, '[x]')
201
+ })
202
+ const missing = wanted.filter(id => !found.has(id))
203
+ if (ticked.length) writeFileSync(path, next.join('\n'))
204
+ return { change: changeName, path, ticked, already, missing, written: ticked.length > 0 }
205
+ }
206
+
207
+ /**
208
+ * Every remaining unchecked checkbox must be claimed by a classified id.
209
+ * Omitted entries are the checkbox text, so a halt names what was dropped.
210
+ */
211
+ export function planCoverage(parsedTasks, classifiedTasks) {
212
+ const items = Array.isArray(parsedTasks) ? parsedTasks : []
213
+ const classified = Array.isArray(classifiedTasks) ? classifiedTasks : []
214
+ const ids = classified.map(t => t && t.id).filter(Boolean)
215
+ const remaining = items.filter(t => !t.done)
216
+ const omitted = remaining
217
+ .filter(t => !ids.some(id => taskMatchesId(t.text, id)))
218
+ .map(t => t.text)
219
+ return {
220
+ ok: omitted.length === 0,
221
+ remaining: remaining.length,
222
+ classified: ids.length,
223
+ omitted
224
+ }
225
+ }
226
+
227
+ const REQUIRED = ['proposal.md', 'design.md', 'tasks.md']
228
+
229
+ /**
230
+ * Filesystem readiness of one change.
231
+ * @returns {{
232
+ * change: string, path: string, exists: boolean, ready: boolean,
233
+ * present: string[], missing: string[],
234
+ * tasks: {total: number, done: number, remaining: number, items: Array},
235
+ * specFiles: string[], problems: string[]
236
+ * }}
237
+ */
238
+ export function inspectChange(root = '.', changeName) {
239
+ const dir = join(root, CHANGES_DIR, changeName)
240
+ const problems = []
241
+
242
+ if (!existsSync(dir)) {
243
+ return {
244
+ change: changeName,
245
+ path: dir,
246
+ exists: false,
247
+ ready: false,
248
+ present: [],
249
+ missing: [...REQUIRED],
250
+ tasks: { total: 0, done: 0, remaining: 0, items: [], redSection: null },
251
+ specFiles: [],
252
+ problems: [`change directory does not exist: ${dir}`]
253
+ }
254
+ }
255
+
256
+ const present = []
257
+ const missing = []
258
+ for (const f of REQUIRED) {
259
+ const p = join(dir, f)
260
+ if (existsSync(p) && statSync(p).size > 0) present.push(f)
261
+ else missing.push(f)
262
+ }
263
+
264
+ let items = []
265
+ // The leading failing-test section, read from the same bytes as the tasks so
266
+ // the shape of a change and its task list can never disagree about which file
267
+ // they came from. `null` for a change with no tasks.md at all.
268
+ let redSection = null
269
+ if (present.includes('tasks.md')) {
270
+ const markdown = readFileSync(join(dir, 'tasks.md'), 'utf8')
271
+ items = parseTasks(markdown)
272
+ redSection = detectRedSection(markdown)
273
+ if (items.length === 0) problems.push('tasks.md contains no checkbox task lines')
274
+ }
275
+
276
+ const specDir = join(dir, 'specs')
277
+ const specFiles = existsSync(specDir) ? walkSpecs(specDir, specDir) : []
278
+
279
+ for (const f of missing) problems.push(`missing or empty: ${f}`)
280
+
281
+ const done = items.filter(t => t.done).length
282
+ return {
283
+ change: changeName,
284
+ path: dir,
285
+ exists: true,
286
+ ready: missing.length === 0 && items.length > 0,
287
+ present,
288
+ missing,
289
+ tasks: { total: items.length, done, remaining: items.length - done, items, redSection },
290
+ specFiles,
291
+ problems
292
+ }
293
+ }
294
+
295
+ function walkSpecs(dir, base) {
296
+ const out = []
297
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
298
+ const p = join(dir, entry.name)
299
+ if (entry.isDirectory()) out.push(...walkSpecs(p, base))
300
+ else if (entry.name.endsWith('.md')) out.push(p.slice(base.length + 1))
301
+ }
302
+ return out.sort()
303
+ }
304
+
305
+ export function formatInspection(r) {
306
+ const lines = []
307
+ lines.push(
308
+ r.ready
309
+ ? `READY — ${r.change}: ${r.tasks.remaining} of ${r.tasks.total} task(s) remaining`
310
+ : `NOT READY — ${r.change}`
311
+ )
312
+ if (r.present.length) lines.push(` present: ${r.present.join(', ')}`)
313
+ if (r.specFiles.length) lines.push(` specs: ${r.specFiles.length} file(s)`)
314
+ for (const p of r.problems) lines.push(` problem: ${p}`)
315
+ return lines.join('\n') + '\n'
316
+ }
@@ -0,0 +1,149 @@
1
+ // The earned-autonomy ladder.
2
+ //
3
+ // Every gated path starts at L2 (human in the loop) and is promoted to L3
4
+ // (autonomous) only after PROMOTE_THRESHOLD consecutive clean runs. A blocker or
5
+ // a human override resets the counter and demotes L3 back to L2.
6
+ //
7
+ // Autonomy changes *whether the flow waits for a human*, never *whether the
8
+ // quality gates run*. A blocker is a hard stop at every level.
9
+ //
10
+ // Pure fs + JSON: no agent, no network, no clock beyond timestamps.
11
+ // Exposed to skills as `interlock autonomy <level|record|state> ...`.
12
+
13
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
14
+ import { dirname, join } from 'node:path'
15
+
16
+ const STATE_FILE = join('.claude', 'autonomy.json')
17
+ const PROMOTE_THRESHOLD = 3
18
+
19
+ // Upstream paths whose counters reset when a downstream gate fails.
20
+ // Key = downstream gate, Value = upstream paths that get blamed.
21
+ //
22
+ // This is what stops a path gaming its way to L3: `spec` cannot earn autonomy by
23
+ // emitting shallow "no open questions" output, because when the artifact gate
24
+ // catches the resulting bad spec it resets `spec` and `explore` too. Clean
25
+ // passes credit the same chain in reverse via recordCleanFlow().
26
+ //
27
+ // Blame is one hop, deliberately: a review-code blocker blames `ship`, and does
28
+ // not cascade further back to `spec` / `explore`.
29
+ const TRANSITIVE_BLAME = {
30
+ 'review-artifacts': ['explore', 'spec'],
31
+ 'review-code': ['ship']
32
+ }
33
+
34
+ function statePath(root) {
35
+ return root ? join(root, STATE_FILE) : STATE_FILE
36
+ }
37
+
38
+ function load(root) {
39
+ try {
40
+ return JSON.parse(readFileSync(statePath(root), 'utf8'))
41
+ } catch {
42
+ return {}
43
+ }
44
+ }
45
+
46
+ function save(state, root) {
47
+ const p = statePath(root)
48
+ mkdirSync(dirname(p), { recursive: true })
49
+ writeFileSync(p, JSON.stringify(state, null, 2) + '\n')
50
+ }
51
+
52
+ function ensureEntry(state, path) {
53
+ if (!state[path]) {
54
+ state[path] = {
55
+ runs: 0,
56
+ human_overrides: 0,
57
+ blockers_caught: 0,
58
+ consecutive_clean: 0,
59
+ current_level: 'L2',
60
+ last_promoted_at: null
61
+ }
62
+ }
63
+ return state[path]
64
+ }
65
+
66
+ export function record(path, outcome, opts = {}) {
67
+ const { root } = opts
68
+ const state = load(root)
69
+ const entry = ensureEntry(state, path)
70
+
71
+ entry.runs++
72
+
73
+ if (outcome.human_override) {
74
+ entry.human_overrides++
75
+ entry.consecutive_clean = 0
76
+ if (entry.current_level === 'L3') {
77
+ entry.current_level = 'L2'
78
+ entry.last_demoted_at = new Date().toISOString()
79
+ }
80
+ } else if (outcome.blockers > 0) {
81
+ entry.blockers_caught += outcome.blockers
82
+ entry.consecutive_clean = 0
83
+
84
+ // Transitive blame: reset upstream paths that fed into this gate
85
+ const upstreams = TRANSITIVE_BLAME[path] || []
86
+ for (const upstream of upstreams) {
87
+ const upEntry = ensureEntry(state, upstream)
88
+ upEntry.consecutive_clean = 0
89
+ if (upEntry.current_level === 'L3') {
90
+ upEntry.current_level = 'L2'
91
+ upEntry.last_demoted_at = new Date().toISOString()
92
+ }
93
+ }
94
+ } else {
95
+ entry.consecutive_clean++
96
+ }
97
+
98
+ state[path] = entry
99
+ save(state, root)
100
+
101
+ return promote(path, state, opts)
102
+ }
103
+
104
+ export function recordCleanFlow(paths, opts = {}) {
105
+ const { root } = opts
106
+ const state = load(root)
107
+ const results = []
108
+ for (const path of paths) {
109
+ const entry = ensureEntry(state, path)
110
+ entry.runs++
111
+ entry.consecutive_clean++
112
+ state[path] = entry
113
+ results.push({ path, consecutive_clean: entry.consecutive_clean })
114
+ }
115
+ save(state, root)
116
+ return results.map(r => ({ ...r, ...promote(r.path, state, opts) }))
117
+ }
118
+
119
+ export function promote(path, stateOverride, opts = {}) {
120
+ const { root } = opts
121
+ const state = stateOverride || load(root)
122
+ const entry = state[path]
123
+ if (!entry) return { promoted: false, reason: 'no-entry' }
124
+
125
+ if (entry.current_level === 'L2' && entry.consecutive_clean >= PROMOTE_THRESHOLD) {
126
+ entry.current_level = 'L3'
127
+ entry.last_promoted_at = new Date().toISOString()
128
+ save(state, root)
129
+ return { promoted: true, from: 'L2', to: 'L3', consecutive_clean: entry.consecutive_clean }
130
+ }
131
+
132
+ return {
133
+ promoted: false,
134
+ current_level: entry.current_level,
135
+ consecutive_clean: entry.consecutive_clean,
136
+ needed: PROMOTE_THRESHOLD
137
+ }
138
+ }
139
+
140
+ export function getLevel(path, opts = {}) {
141
+ const state = load(opts.root)
142
+ return state[path]?.current_level || 'L2'
143
+ }
144
+
145
+ export function getState(opts = {}) {
146
+ return load(opts.root)
147
+ }
148
+
149
+ export { PROMOTE_THRESHOLD, TRANSITIVE_BLAME }
@@ -0,0 +1,132 @@
1
+ // Spec conformance — does the code actually do what the delta specs say?
2
+ //
3
+ // The oldest criticism of spec-driven anything: prose specifications are not
4
+ // unambiguous, and nothing proves the code conforms to them. Interlock checks
5
+ // that tests pass and that the diff survives review, but neither of those asks
6
+ // the question the specs were written to answer — *was the described behaviour
7
+ // built?* A change can be green, reviewed, shipped, and still not implement the
8
+ // scenario its own spec set out.
9
+ //
10
+ // This module does the deterministic half: enumerate every scenario the change
11
+ // promised and pair it with the files the change touched. It renders a
12
+ // checklist. It NEVER decides whether a scenario is satisfied — that is a
13
+ // judgement about prose against code, which is the model's job, and pretending
14
+ // otherwise would be inventing a verdict from a regex.
15
+ //
16
+ // WHY IT DOES NOT BLOCK. Same reason as lib/drift.mjs, and the reasoning is not
17
+ // worth restating twice: a model matching prose scenarios to code produces a
18
+ // probabilistic answer, and a gate that halts a run on one would be wrong often
19
+ // enough to be switched off. A gate everyone disables protects nothing. So the
20
+ // checklist is emitted, an agent fills it in, and a person reads the result.
21
+ //
22
+ // Exposed to skills as `interlock conformance --change <name>`.
23
+
24
+ import { inspectChange, isCoverableSource, resolveChange } from './artifacts.mjs'
25
+ import { extractScenarios, readArtifactsFromDisk } from './ready.mjs'
26
+
27
+ /** Delta specs are the behavioural contract. A scenario in prose elsewhere is context. */
28
+ const SPEC_ARTIFACT = /^specs\//
29
+
30
+ /**
31
+ * Build the conformance checklist for a change.
32
+ *
33
+ * @param {string} root
34
+ * @param {string} [requested] change name; resolved when omitted
35
+ * @param {{changed?: string[]}} [opts] the run's diff, for pairing
36
+ */
37
+ export function buildChecklist(root = '.', requested, opts = {}) {
38
+ const resolved = resolveChange(root, requested)
39
+ if (resolved.error) {
40
+ return { skipped: true, reason: resolved.error, change: null, scenarios: [], changedFiles: [] }
41
+ }
42
+ const change = resolved.name
43
+
44
+ const inspection = inspectChange(root, change)
45
+ if (!inspection || !inspection.exists) {
46
+ return { skipped: true, reason: `change "${change}" has no artifacts on disk`, change, scenarios: [], changedFiles: [] }
47
+ }
48
+
49
+ const artifacts = readArtifactsFromDisk(inspection)
50
+ const all = extractScenarios(artifacts)
51
+ const fromSpecs = all.filter(s => SPEC_ARTIFACT.test(s.artifact))
52
+
53
+ // Scenarios written into proposal.md or design.md are not the contract, but
54
+ // silently dropping them would hide a change that put its only scenarios in
55
+ // the wrong file — which looks identical to a change with no scenarios.
56
+ const outsideSpecs = all.length - fromSpecs.length
57
+
58
+ const scenarios = fromSpecs.map((s, i) => ({
59
+ id: `C${i + 1}`,
60
+ artifact: s.artifact,
61
+ line: s.line,
62
+ title: s.title,
63
+ body: s.body.trim()
64
+ }))
65
+
66
+ const changedFiles = (Array.isArray(opts.changed) ? opts.changed : []).filter(isCoverableSource)
67
+
68
+ return {
69
+ skipped: false,
70
+ reason: null,
71
+ change,
72
+ scenarios,
73
+ outsideSpecs,
74
+ changedFiles,
75
+ // Stated rather than implied: this module produced the questions, not the
76
+ // answers, and a consumer that treats an unanswered checklist as a pass has
77
+ // misread it.
78
+ verdictsRequired: scenarios.length
79
+ }
80
+ }
81
+
82
+ /** Human-readable checklist. One numbered question per scenario, nothing decided. */
83
+ export function formatChecklist(result) {
84
+ const lines = []
85
+ if (result.skipped) {
86
+ lines.push(`CONFORMANCE — not built: ${result.reason}`)
87
+ lines.push('')
88
+ lines.push('Nothing here blocks.')
89
+ return lines.join('\n')
90
+ }
91
+
92
+ if (!result.scenarios.length) {
93
+ lines.push(`CONFORMANCE — no scenarios in the delta specs for "${result.change}"`)
94
+ if (result.outsideSpecs) {
95
+ lines.push('')
96
+ lines.push(
97
+ ` ${result.outsideSpecs} scenario heading${result.outsideSpecs === 1 ? '' : 's'} exist outside specs/ ` +
98
+ `(proposal.md or design.md). Those are context, not contract — if they describe`
99
+ )
100
+ lines.push(' behaviour this change promises, they belong in a delta spec.')
101
+ } else {
102
+ lines.push('')
103
+ lines.push(' A change may legitimately carry none. But a change that promised behaviour and')
104
+ lines.push(' wrote no scenario has nothing to conform to, which is worth noticing.')
105
+ }
106
+ lines.push('')
107
+ lines.push('Nothing here blocks.')
108
+ return lines.join('\n')
109
+ }
110
+
111
+ lines.push(
112
+ `CONFORMANCE — ${result.scenarios.length} scenario${result.scenarios.length === 1 ? '' : 's'} to verify for "${result.change}"`
113
+ )
114
+ lines.push('')
115
+ for (const s of result.scenarios) {
116
+ lines.push(` ${s.id} ${s.title}`)
117
+ lines.push(` ${s.artifact}:${s.line}`)
118
+ }
119
+
120
+ if (result.changedFiles.length) {
121
+ lines.push('')
122
+ lines.push(` Changed source files to check against (${result.changedFiles.length}):`)
123
+ for (const f of result.changedFiles) lines.push(` ${f}`)
124
+ }
125
+
126
+ lines.push('')
127
+ lines.push(' These are questions, not results. Each needs an agent to read the implementation')
128
+ lines.push(' and answer with a file:line citation. An unanswered checklist is not a pass.')
129
+ lines.push('')
130
+ lines.push('Nothing here blocks. Conformance is reported so a person can decide.')
131
+ return lines.join('\n')
132
+ }