@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
package/lib/ready.mjs ADDED
@@ -0,0 +1,812 @@
1
+ // The continuity gate (§F1, §F6.3) — may this change skip the human checkpoint?
2
+ //
3
+ // Everywhere else in Interlock a human reads the artifacts before ship starts.
4
+ // The continuity path (WS-F) can skip that read. A gate that removes a person
5
+ // from the loop cannot be a paragraph in a skill: a model asked "are these
6
+ // artifacts implementable?" answers from vibes, differently every run, and the
7
+ // one run where it answers optimistically is the run nobody checked. So the
8
+ // checklist in §F1 lives here, as code, and every answer carries its evidence.
9
+ //
10
+ // Three properties are load-bearing:
11
+ //
12
+ // 1. FAIL CLOSED, WITHOUT EXCEPTION. `ready` is true only when every
13
+ // implemented check affirmatively passed. A check that could not run is
14
+ // not a pass — an unreadable ledger, an artifact that would not read, a
15
+ // classifier that threw, all become blockers. Nothing in this module can
16
+ // turn an error into permission.
17
+ //
18
+ // 2. AN ABSENT INPUT IS NOT A CLEAN INPUT. The two ways this gate would
19
+ // otherwise be silently defeated are a missing `decisions.md` ("nobody
20
+ // recorded anything, so nothing needs a human") and a caller that simply
21
+ // forgets to pass the artifact-review result ("no blockers were reported,
22
+ // so there are no blockers"). Both are blockers here, by name.
23
+ //
24
+ // 3. COMPOSITION, NOT REIMPLEMENTATION. Artifact structure is
25
+ // `lib/artifacts.mjs`, the ledger audit is `lib/ledger.mjs`, blast radius
26
+ // is `lib/risk.mjs`. This module owns only the *policy* that combines
27
+ // them — §4.14 "balanced", encoded once in `STRICTNESS` so re-tuning to
28
+ // strict or loose is a single edit rather than a hunt.
29
+ //
30
+ // Deliberately unimplemented checks (§F1 items A, part of C, E's agent-tool
31
+ // probe, and all of F) are reported as `status: 'skip'` with a reason rather
32
+ // than omitted, so the printed checklist never implies they passed. They do
33
+ // not block: continuity knowingly accepts wrong-idea risk (§F0) in exchange
34
+ // for speed, and a skip that blocked would make the gate unreachable forever.
35
+ // A skip is "we chose not to check this"; a fail is "we tried and could not".
36
+ //
37
+ // Exposed to skills as `interlock ready --change <name> --json`.
38
+
39
+ import { existsSync, readFileSync, statSync } from 'node:fs'
40
+ import { join } from 'node:path'
41
+ import { inspectChange, parseTasks, resolveChange } from './artifacts.mjs'
42
+ import { readLedger, summarize } from './ledger.mjs'
43
+ import { evaluateGate } from './findings.mjs'
44
+ import { CONTINUITY_ALLOWED_CLASSES, classifyRisk } from './risk.mjs'
45
+
46
+ /**
47
+ * §4.14 **balanced**, and nothing else. Every threshold the gate applies is a
48
+ * field here: re-tuning to (strict) or (loose) is one edit to this object, not
49
+ * a search through the checks below.
50
+ */
51
+ export const STRICTNESS = Object.freeze({
52
+ /** Which §4.14 option this encodes. Printed, so a run says what it obeyed. */
53
+ name: 'balanced',
54
+ /** Artifact-review blockers tolerated. */
55
+ maxArtifactBlockers: 0,
56
+ /** Balanced: warnings are reported and do not block. Strict would flip this. */
57
+ artifactWarningsBlock: false,
58
+ /** `needs_human` ledger rows tolerated. */
59
+ maxNeedsHuman: 0,
60
+ /** Malformed / unsubstantiated ledger rows tolerated (see lib/ledger.mjs). */
61
+ maxInvalidDecisions: 0,
62
+ /** A change with no ledger at all has recorded no decisions — that blocks. */
63
+ requireLedger: true,
64
+ /** Risk classes that may continue past the checkpoint. */
65
+ allowedRiskClasses: CONTINUITY_ALLOWED_CLASSES,
66
+ /** `.claude/testing/profile.json` must exist before ship runs unattended. */
67
+ requireTestProfile: true
68
+ })
69
+
70
+ /** Where the test profile lives, relative to the repo root. */
71
+ export const TEST_PROFILE_PATH = join('.claude', 'testing', 'profile.json')
72
+
73
+ /**
74
+ * Task phrases that describe an intention rather than a change (§F1 C). A task
75
+ * carrying one of these is not implementable by an agent that cannot ask a
76
+ * question, so it blocks continuity. A trailing `.` in a phrase is optional
77
+ * when matching, so `etc.` also catches a bare `etc`.
78
+ */
79
+ export const WEASEL_PHRASES = Object.freeze([
80
+ 'appropriately',
81
+ 'as needed',
82
+ 'etc.',
83
+ 'handle edge cases',
84
+ 'and so on',
85
+ 'where applicable',
86
+ 'if necessary',
87
+ 'update accordingly'
88
+ ])
89
+
90
+ /**
91
+ * The single escape hatch for the weasel lint: a task may keep a vague phrase
92
+ * when it also states what "done" means. One greppable marker rather than a
93
+ * heuristic, so nobody has to guess whether their wording qualifies.
94
+ */
95
+ export const ACCEPTANCE_MARKER = /\bacceptance\s*:/i
96
+
97
+ /** The §F1 escape hatch for a scenario a task covers without naming it. */
98
+ export const TASK_COVERAGE_MARKER = /\bcovered\s+by\s+tasks?\s*[:#]?\s*\d[\d.,\s]*/i
99
+
100
+ /** Artifacts read from the change directory when the caller supplies none. */
101
+ const ARTIFACT_FILES = Object.freeze(['proposal.md', 'design.md', 'tasks.md'])
102
+
103
+ // Tolerate a mis-levelled heading: §F1 says `#### Scenario:`, but a scenario
104
+ // written with three hashes is still a scenario, and skipping it would be a
105
+ // silent hole in the mapping check.
106
+ const SCENARIO_HEADING = /^#{2,6}\s*Scenario\s*:\s*(.+?)\s*$/i
107
+ const ANY_HEADING = /^#{1,6}\s/
108
+
109
+ // How much of an offending line is quoted back as evidence.
110
+ const EVIDENCE_CHARS = 160
111
+ // Scenario titles shorter than this are too generic to match against a task by
112
+ // substring, so they must use the explicit coverage marker instead.
113
+ const MIN_TITLE_CHARS = 3
114
+
115
+ function fail(message) {
116
+ const err = new Error(message)
117
+ err.userFacing = true
118
+ throw err
119
+ }
120
+
121
+ function messageOf(err) {
122
+ return (err && err.message) || String(err)
123
+ }
124
+
125
+ function clip(text, max = EVIDENCE_CHARS) {
126
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim()
127
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s
128
+ }
129
+
130
+ // Normalised for substring comparison: case, punctuation and spacing all stop
131
+ // mattering, so "Scenario: User signs in" matches a task written "user signs-in".
132
+ function normalize(text) {
133
+ return String(text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
134
+ }
135
+
136
+ function escapeRegExp(s) {
137
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
138
+ }
139
+
140
+ const WEASEL_PATTERNS = WEASEL_PHRASES.map(phrase => ({
141
+ phrase,
142
+ // A trailing literal dot becomes optional; word-ish boundaries stop
143
+ // "appropriately" from firing inside a longer identifier.
144
+ re: new RegExp(`(^|[^\\w])${escapeRegExp(phrase).replace(/\\\.$/, '\\.?')}(?![\\w])`, 'i')
145
+ }))
146
+
147
+ // --- accumulator ---------------------------------------------------------
148
+ //
149
+ // Every check pushes exactly one entry, so the printed checklist has one line
150
+ // per §F1 item whatever happened.
151
+
152
+ function pass(run, id, detail) {
153
+ run.checks.push({ id, status: 'pass', detail })
154
+ }
155
+
156
+ function skip(run, id, detail) {
157
+ run.checks.push({ id, status: 'skip', detail })
158
+ }
159
+
160
+ function warn(run, code, message) {
161
+ run.warnings.push({ code, message })
162
+ }
163
+
164
+ function block(run, id, code, message, evidence = []) {
165
+ run.checks.push({ id, status: 'fail', detail: message })
166
+ run.blockers.push({ code, message, evidence: evidence.slice(0, 10) })
167
+ }
168
+
169
+ // A gather step failed: mark every check that depended on it as failed, and
170
+ // raise ONE blocker naming the cause. Dependent checks are 'fail', never
171
+ // 'skip' — they were meant to run.
172
+ function blockAll(run, ids, code, message, evidence = []) {
173
+ for (const id of ids) run.checks.push({ id, status: 'fail', detail: message })
174
+ run.blockers.push({ code, message, evidence: evidence.slice(0, 10) })
175
+ }
176
+
177
+ // Run an evidence-gathering step without letting it escape. An exception here
178
+ // must become a blocker, never an unhandled throw and never a silent pass.
179
+ function attempt(fn) {
180
+ try {
181
+ return { ok: true, value: fn(), error: null }
182
+ } catch (err) {
183
+ return { ok: false, value: null, error: messageOf(err) }
184
+ }
185
+ }
186
+
187
+ // --- evidence gathering --------------------------------------------------
188
+
189
+ /** Exported for `lib/conformance.mjs`, for the same anti-drift reason as `extractScenarios`. */
190
+ export function readArtifactsFromDisk(inspection) {
191
+ const out = {}
192
+ for (const name of ARTIFACT_FILES) {
193
+ const p = join(inspection.path, name)
194
+ if (existsSync(p)) out[name] = readFileSync(p, 'utf8')
195
+ }
196
+ for (const rel of inspection.specFiles || []) {
197
+ const p = join(inspection.path, 'specs', rel)
198
+ if (existsSync(p)) out[`specs/${rel}`] = readFileSync(p, 'utf8')
199
+ }
200
+ return out
201
+ }
202
+
203
+ // A caller-supplied artifact map is filtered to strings: a stray object would
204
+ // otherwise reach the risk classifier's text matching as "[object Object]".
205
+ function normalizeArtifacts(input) {
206
+ const out = {}
207
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return out
208
+ for (const key of Object.keys(input)) {
209
+ if (typeof input[key] === 'string') out[key] = input[key]
210
+ }
211
+ return out
212
+ }
213
+
214
+ /**
215
+ * Compute the blocker count from the artifact review's own findings.
216
+ *
217
+ * This is the whole of §continuity-provenance. The number that decides whether
218
+ * a human reads a specification used to be written by the party being gated —
219
+ * `skills/spec/continuity.md` had the agent `printf` it into a file, and this
220
+ * module validated its *shape* but never its *provenance*. Meanwhile
221
+ * `evaluateGate` was already computing the real count from the real findings
222
+ * and the value was thrown away.
223
+ *
224
+ * `evaluateGate` is reused rather than reimplemented for the same reason
225
+ * `extractScenarios` is shared with lib/conformance.mjs: a second counting
226
+ * implementation lets the readiness gate and the review gate disagree about
227
+ * one findings file, and nobody notices until the two contradict each other.
228
+ *
229
+ * Fails closed on anything it cannot read. Absent, blank and unparseable all
230
+ * resolve to *the review did not run*, never to zero — an unreadable findings
231
+ * file is not a findings file with no blockers.
232
+ *
233
+ * @param {unknown} findings parsed findings, or the raw text of the file
234
+ * @returns {{ran: boolean, blockers?: number, warnings?: number, reason?: string}}
235
+ */
236
+ function readFindings(findings) {
237
+ if (findings === undefined || findings === null) {
238
+ return { ran: false, reason: 'no artifact-review findings file was supplied' }
239
+ }
240
+
241
+ let parsed = findings
242
+ if (typeof findings === 'string') {
243
+ if (!findings.trim()) {
244
+ return { ran: false, reason: 'the artifact-review findings file is empty' }
245
+ }
246
+ try {
247
+ parsed = JSON.parse(findings)
248
+ } catch (err) {
249
+ return { ran: false, reason: `the artifact-review findings file is not valid JSON: ${messageOf(err)}` }
250
+ }
251
+ }
252
+ if (typeof parsed !== 'object' || parsed === null) {
253
+ return { ran: false, reason: 'the artifact-review findings are not an object or an array' }
254
+ }
255
+
256
+ const got = attempt(() => evaluateGate(parsed))
257
+ if (!got.ok) {
258
+ return { ran: false, reason: `the artifact-review findings could not be evaluated: ${got.error}` }
259
+ }
260
+ const gate = got.value
261
+ return {
262
+ ran: true,
263
+ blockers: gate.blockers.length,
264
+ warnings: gate.counts.warning,
265
+ malformed: gate.malformed.length,
266
+ total: gate.total
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Interpret a hand-written artifact-review count.
272
+ *
273
+ * Retained for one release, and no longer sufficient on its own: its
274
+ * provenance is the agent being gated. Only an object carrying a usable
275
+ * `blockers` count counts as "supplied at all" — `undefined`, `true`, an empty
276
+ * object and a negative count are all *unrun*, never zero.
277
+ */
278
+ function readReview(review) {
279
+ if (!review || typeof review !== 'object' || Array.isArray(review)) {
280
+ return { ran: false, reason: 'no artifact-review result was supplied' }
281
+ }
282
+ const blockers = countOf(review.blockers)
283
+ if (blockers === null) {
284
+ return { ran: false, reason: 'the artifact-review result carried no usable blocker count' }
285
+ }
286
+ const warnings = countOf(review.warnings)
287
+ return { ran: true, blockers, warnings: warnings === null ? 0 : warnings, reason: null }
288
+ }
289
+
290
+ // A count may arrive as a number or as the array it counts. Anything else is
291
+ // "not a count" (null), which the caller above turns into "the review did not
292
+ // run" rather than into a zero.
293
+ function countOf(value) {
294
+ if (Array.isArray(value)) return value.length
295
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.trunc(value)
296
+ return null
297
+ }
298
+
299
+ /**
300
+ * Every `#### Scenario:` in the artifacts, with the section text under it.
301
+ *
302
+ * Exported because `lib/conformance.mjs` needs exactly this parse and a second
303
+ * implementation would drift from it — the two would then disagree about what
304
+ * counts as a scenario, which is the kind of divergence nobody notices until a
305
+ * conformance report and a readiness gate contradict each other.
306
+ */
307
+ export function extractScenarios(artifacts) {
308
+ const found = []
309
+ for (const name of Object.keys(artifacts)) {
310
+ if (name === 'tasks.md') continue
311
+ const lines = artifacts[name].split('\n')
312
+ for (let i = 0; i < lines.length; i += 1) {
313
+ const m = SCENARIO_HEADING.exec(lines[i].trim())
314
+ if (!m) continue
315
+ const body = []
316
+ for (let j = i + 1; j < lines.length; j += 1) {
317
+ if (ANY_HEADING.test(lines[j])) break
318
+ body.push(lines[j])
319
+ }
320
+ found.push({ artifact: name, line: i + 1, title: m[1], body: body.join('\n') })
321
+ }
322
+ }
323
+ return found
324
+ }
325
+
326
+ // --- the checks ----------------------------------------------------------
327
+
328
+ function checkDecisions(run, root, change) {
329
+ const ids = ['decisions.ledger-present', 'decisions.needs-human', 'decisions.rows-valid']
330
+ const got = attempt(() => {
331
+ const parsed = readLedger(root, change)
332
+ return { parsed, summary: summarize(parsed) }
333
+ })
334
+ if (!got.ok) {
335
+ blockAll(run, ids, 'LEDGER_UNREADABLE', `the decision ledger could not be read: ${got.error}`, [got.error])
336
+ return null
337
+ }
338
+
339
+ const { parsed, summary } = got.value
340
+
341
+ if (!summary.exists) {
342
+ if (STRICTNESS.requireLedger) {
343
+ // The quiet failure mode this exists to catch: no ledger reads as
344
+ // "nothing needed a human" only if you assume explore and spec had
345
+ // nothing to record. Under fail-closed it reads as "unknown".
346
+ blockAll(
347
+ run,
348
+ ids,
349
+ 'LEDGER_MISSING',
350
+ `no decision ledger at ${parsed.path} — explore and spec never recorded their decisions, ` +
351
+ 'so there is nothing to prove no human is needed. Run /interlock:spec again to write decisions.md.',
352
+ [parsed.path]
353
+ )
354
+ return summary
355
+ }
356
+ for (const id of ids) skip(run, id, 'no decision ledger, and the current strictness does not require one')
357
+ return summary
358
+ }
359
+ pass(run, ids[0], `decision ledger present with ${summary.total} row(s)`)
360
+
361
+ if (summary.needsHuman > STRICTNESS.maxNeedsHuman) {
362
+ const rows = (parsed.rows || [])
363
+ .filter(r => r.valid && r.class === 'needs_human')
364
+ .map(r => `${r.id || `line ${r.line}`}: ${clip(r.question)}`)
365
+ block(
366
+ run,
367
+ ids[1],
368
+ 'DECISIONS_NEED_HUMAN',
369
+ `${summary.needsHuman} decision(s) still need a human (max ${STRICTNESS.maxNeedsHuman})`,
370
+ rows
371
+ )
372
+ } else {
373
+ pass(run, ids[1], `no needs_human rows (${summary.agentResolved} agent_resolved)`)
374
+ }
375
+
376
+ if (summary.invalid > STRICTNESS.maxInvalidDecisions) {
377
+ const rows = (parsed.invalid || []).map(({ row, reason }) => `${row.id || `line ${row.line}`}: ${clip(reason)}`)
378
+ block(
379
+ run,
380
+ ids[2],
381
+ 'DECISIONS_INVALID',
382
+ `${summary.invalid} ledger row(s) are malformed or unsubstantiated — an unreadable row counts as unresolved`,
383
+ rows
384
+ )
385
+ } else {
386
+ pass(run, ids[2], 'every ledger row parses and substantiates its class')
387
+ }
388
+
389
+ return summary
390
+ }
391
+
392
+ function checkArtifactsImplementable(run, inspection) {
393
+ if (inspection.ready) {
394
+ pass(
395
+ run,
396
+ 'artifacts.implementable',
397
+ `proposal.md, design.md and tasks.md present and non-empty; ${inspection.tasks.total} task(s)`
398
+ )
399
+ return
400
+ }
401
+ block(
402
+ run,
403
+ 'artifacts.implementable',
404
+ 'ARTIFACTS_INCOMPLETE',
405
+ `change artifacts are not implementable as written (interlock validate would fail)`,
406
+ inspection.problems || []
407
+ )
408
+ }
409
+
410
+ function checkReview(run, findings, review) {
411
+ const derived = readFindings(findings)
412
+ const transcribed = readReview(review)
413
+
414
+ // Both supplied and disagreeing: the findings decide, and the disagreement is
415
+ // reported rather than resolved silently in either direction. A silent
416
+ // resolution here would hide exactly the case this change exists for — the
417
+ // gated agent's number differing from the review's own.
418
+ if (derived.ran && transcribed.ran && derived.blockers !== transcribed.blockers) {
419
+ warn(
420
+ run,
421
+ 'REVIEW_COUNT_DISAGREEMENT',
422
+ `the findings file yields ${derived.blockers} blocker(s) but the supplied count says ` +
423
+ `${transcribed.blockers} — the findings decide, because the transcribed count's ` +
424
+ `provenance is the agent being gated`
425
+ )
426
+ }
427
+
428
+ if (!derived.ran) {
429
+ if (transcribed.ran) {
430
+ // The deprecated input, on its own. Not a pass: a count written by the
431
+ // party being gated is a self-report, not a review result.
432
+ block(
433
+ run,
434
+ 'artifacts.review-blockers',
435
+ 'REVIEW_SELF_REPORTED',
436
+ `${derived.reason}, and the only artifact-review input is a transcribed count whose ` +
437
+ `provenance is the gated agent itself — pass the review's findings file to ` +
438
+ `--findings so the count is derived from the review rather than reported by it`,
439
+ []
440
+ )
441
+ return
442
+ }
443
+ // An unrun check is not a clean check. Without this branch the whole
444
+ // artifact-review arm of §F1 C is bypassed by omitting one argument.
445
+ block(
446
+ run,
447
+ 'artifacts.review-blockers',
448
+ 'REVIEW_NOT_RUN',
449
+ `${derived.reason} — an artifact review that did not run is not an artifact review with zero blockers`,
450
+ []
451
+ )
452
+ return
453
+ }
454
+
455
+ const r = derived
456
+ if (r.malformed > 0) {
457
+ block(
458
+ run,
459
+ 'artifacts.review-findings-malformed',
460
+ 'REVIEW_FINDINGS_MALFORMED',
461
+ `${r.malformed} artifact-review finding(s) carry a severity outside the defined set — ` +
462
+ 'a finding the gate cannot interpret is not a finding the gate may ignore'
463
+ )
464
+ }
465
+ if (r.warnings > 0) {
466
+ if (STRICTNESS.artifactWarningsBlock) {
467
+ block(
468
+ run,
469
+ 'artifacts.review-warnings',
470
+ 'REVIEW_WARNINGS',
471
+ `${r.warnings} artifact-review warning(s), and strictness "${STRICTNESS.name}" blocks on warnings`
472
+ )
473
+ } else {
474
+ warn(run, 'REVIEW_WARNINGS', `${r.warnings} artifact-review warning(s) — not blocking under "${STRICTNESS.name}"`)
475
+ }
476
+ }
477
+ if (r.blockers > STRICTNESS.maxArtifactBlockers) {
478
+ block(
479
+ run,
480
+ 'artifacts.review-blockers',
481
+ 'REVIEW_BLOCKERS',
482
+ `${r.blockers} artifact-review blocker(s) (max ${STRICTNESS.maxArtifactBlockers})`
483
+ )
484
+ return
485
+ }
486
+ pass(
487
+ run,
488
+ 'artifacts.review-blockers',
489
+ `artifact review ran: ${r.blockers} blocker(s), ${r.warnings} warning(s), ` +
490
+ `derived from the review's own findings (${r.total} reportable finding(s))`
491
+ )
492
+ }
493
+
494
+ function checkScenarioCoverage(run, artifacts, tasks) {
495
+ const scenarios = extractScenarios(artifacts)
496
+ if (scenarios.length === 0) {
497
+ // Not a blocker: a change may legitimately carry no delta specs. Loud
498
+ // enough that a missing specs/ directory is visible at the checkpoint.
499
+ warn(run, 'NO_SCENARIOS', 'no "#### Scenario:" blocks found in the change artifacts — nothing to map to tasks')
500
+ pass(run, 'specs.scenarios-mapped', 'no scenarios to map')
501
+ return
502
+ }
503
+
504
+ const taskTexts = tasks.map(t => normalize(t.text)).filter(Boolean)
505
+ const unmapped = []
506
+ for (const s of scenarios) {
507
+ const title = normalize(s.title)
508
+ const explicit = TASK_COVERAGE_MARKER.test(s.title) || TASK_COVERAGE_MARKER.test(s.body)
509
+ const named = title.length >= MIN_TITLE_CHARS && taskTexts.some(t => t.includes(title))
510
+ if (!explicit && !named) {
511
+ unmapped.push(`${s.artifact}:${s.line} Scenario: ${clip(s.title)}`)
512
+ }
513
+ }
514
+
515
+ if (unmapped.length) {
516
+ block(
517
+ run,
518
+ 'specs.scenarios-mapped',
519
+ 'SCENARIO_UNMAPPED',
520
+ `${unmapped.length} of ${scenarios.length} scenario(s) map to no task — name the scenario in a task, ` +
521
+ 'or write "covered by task N" under the scenario',
522
+ unmapped
523
+ )
524
+ return
525
+ }
526
+ pass(run, 'specs.scenarios-mapped', `all ${scenarios.length} scenario(s) map to at least one task`)
527
+ }
528
+
529
+ function checkWeaselPhrases(run, tasks) {
530
+ const offenders = []
531
+ for (const task of tasks) {
532
+ const text = String(task.text ?? '')
533
+ if (ACCEPTANCE_MARKER.test(text)) continue
534
+ for (const { phrase, re } of WEASEL_PATTERNS) {
535
+ if (re.test(text)) {
536
+ offenders.push(`tasks.md:${task.line} "${phrase}" — ${clip(text)}`)
537
+ break
538
+ }
539
+ }
540
+ }
541
+ if (offenders.length) {
542
+ block(
543
+ run,
544
+ 'tasks.no-weasel-phrases',
545
+ 'TASK_UNSPECIFIC',
546
+ `${offenders.length} task(s) describe an intention rather than a change — ` +
547
+ 'rewrite them, or state what done means with "acceptance: …"',
548
+ offenders
549
+ )
550
+ return
551
+ }
552
+ pass(run, 'tasks.no-weasel-phrases', `${tasks.length} task(s) carry no unqualified vague phrasing`)
553
+ }
554
+
555
+ function checkRisk(run, risk) {
556
+ if (!risk.ok) {
557
+ blockAll(run, ['risk.class-allowed'], 'RISK_UNCLASSIFIABLE', `risk could not be classified: ${risk.error}`, [
558
+ risk.error
559
+ ])
560
+ return
561
+ }
562
+ const result = risk.value
563
+ // `continuityAllowed` is computed by lib/risk.mjs from the allowlist we hand
564
+ // it; the explicit membership test is deliberate redundancy, so a future
565
+ // caller passing its own allowedClasses cannot widen this gate by accident.
566
+ const allowed = STRICTNESS.allowedRiskClasses.includes(result.riskClass) && result.continuityAllowed === true
567
+ if (!allowed) {
568
+ block(
569
+ run,
570
+ 'risk.class-allowed',
571
+ 'RISK_CLASS_BLOCKED',
572
+ `risk class "${result.riskClass}" is outside {${STRICTNESS.allowedRiskClasses.join(', ')}} — human checkpoint required`,
573
+ result.signals.filter(s => s.class === result.riskClass).map(s => `${s.signal}: ${clip(s.evidence)}`)
574
+ )
575
+ return
576
+ }
577
+ pass(run, 'risk.class-allowed', `risk class "${result.riskClass}" may continue under "${STRICTNESS.name}"`)
578
+ }
579
+
580
+ function checkTestProfile(run, root) {
581
+ if (!STRICTNESS.requireTestProfile) {
582
+ skip(run, 'runtime.test-profile', 'test profile not required by the current strictness')
583
+ return
584
+ }
585
+ const path = join(root, TEST_PROFILE_PATH)
586
+ const got = attempt(() => {
587
+ if (!existsSync(path)) return { present: false, parsed: false }
588
+ if (statSync(path).size === 0) return { present: true, parsed: false }
589
+ JSON.parse(readFileSync(path, 'utf8'))
590
+ return { present: true, parsed: true }
591
+ })
592
+ if (!got.ok) {
593
+ block(run, 'runtime.test-profile', 'TEST_PROFILE_UNREADABLE', `${TEST_PROFILE_PATH} could not be read: ${got.error}`, [
594
+ got.error
595
+ ])
596
+ return
597
+ }
598
+ if (!got.value.present) {
599
+ block(
600
+ run,
601
+ 'runtime.test-profile',
602
+ 'TEST_PROFILE_MISSING',
603
+ `no ${TEST_PROFILE_PATH} — ship would have to guess how to run the tests. Run /interlock:fix-tests --reconfigure once.`,
604
+ [path]
605
+ )
606
+ return
607
+ }
608
+ if (!got.value.parsed) {
609
+ block(run, 'runtime.test-profile', 'TEST_PROFILE_UNREADABLE', `${TEST_PROFILE_PATH} is empty or not valid JSON`, [path])
610
+ return
611
+ }
612
+ pass(run, 'runtime.test-profile', `${TEST_PROFILE_PATH} present and parseable`)
613
+ }
614
+
615
+ // §F1 items this release deliberately does not check. Listed rather than
616
+ // omitted so the printed checklist is the whole checklist, and so nobody reads
617
+ // a short green list as "everything in F1 passed".
618
+ function addSkips(run) {
619
+ skip(
620
+ run,
621
+ 'intent.lock',
622
+ 'not machine-checkable from artifacts (§F0): whether this is the *right* change, and whether a bug-fix ' +
623
+ 'carries repro evidence, stay with the human. Continuity knowingly accepts this residual risk.'
624
+ )
625
+ skip(
626
+ run,
627
+ 'tasks.wave-classifiable',
628
+ 'not implemented in this release — wave classification runs in lib/waves.mjs at ship time, not here'
629
+ )
630
+ skip(
631
+ run,
632
+ 'invariants.consumers-enumerated',
633
+ 'not implemented in this release — enumerating sweep consumers needs the graph, which this gate does not read'
634
+ )
635
+ skip(
636
+ run,
637
+ 'runtime.agent-tool',
638
+ 'not observable from this process — the ship workflow reports the Agent tool being unavailable at run time'
639
+ )
640
+ skip(
641
+ run,
642
+ 'learning.failure-streak',
643
+ 'eligibility gating is deliberately out of scope for this release (§4.15a / F6.6): outcomes are recorded, nothing gates on them'
644
+ )
645
+ skip(
646
+ run,
647
+ 'learning.path-autonomy',
648
+ 'eligibility gating is deliberately out of scope for this release (§4.15a / F6.6): autonomy level never implies continuity (§F7)'
649
+ )
650
+ }
651
+
652
+ // --- public API ----------------------------------------------------------
653
+
654
+ /**
655
+ * Assess whether one change may continue past the human checkpoint (§F1).
656
+ *
657
+ * Composes `lib/artifacts.mjs`, `lib/ledger.mjs` and `lib/risk.mjs` under the
658
+ * §4.14 "balanced" policy in `STRICTNESS`. Nothing here re-derives what those
659
+ * modules already decide.
660
+ *
661
+ * @param {string} root repo root
662
+ * @param {string} [change] change name; resolved via `resolveChange` when omitted
663
+ * @param {{
664
+ * findings?: unknown,
665
+ * review?: {blockers: number|Array, warnings?: number|Array},
666
+ * changedPaths?: string[],
667
+ * plannedPaths?: string[],
668
+ * artifacts?: Record<string,string>
669
+ * }} [opts]
670
+ * `findings` is the artifact review's own findings output — parsed, or the
671
+ * raw text of the file. The blocker count is derived from it by
672
+ * `evaluateGate`. **Omitting it blocks**, and so does supplying only
673
+ * `review`, which is the deprecated hand-written count: its provenance is
674
+ * the agent being gated. `changedPaths`/`plannedPaths` feed `classifyRisk`.
675
+ * `artifacts` overrides reading the change directory (mostly for tests).
676
+ * @returns {{
677
+ * ready: boolean,
678
+ * change: string,
679
+ * strictness: string,
680
+ * blockers: Array<{code: string, message: string, evidence: string[]}>,
681
+ * warnings: Array<{code: string, message: string}>,
682
+ * decisions: {needsHuman: number|null, agentResolved: number|null, invalid: number|null, exists: boolean|null},
683
+ * riskClass: string|null,
684
+ * continuityAllowed: boolean,
685
+ * // `continuityAllowed` is the risk module's answer ANDed with `ready`, not
686
+ * // the raw risk verdict: an allowed risk class is necessary but nowhere
687
+ * // near sufficient, and a caller reading this field as the gate must not
688
+ * // end up bypassing the ledger and artifact checks. `riskClass` and the
689
+ * // `risk.class-allowed` check carry the risk-only answer.
690
+ * checks: Array<{id: string, status: 'pass'|'fail'|'skip', detail: string}>,
691
+ * scores: object
692
+ * }}
693
+ */
694
+ export function assessReadiness(root, change, opts = {}) {
695
+ if (typeof root !== 'string' || !root.trim()) fail('readiness requires a repo root')
696
+ const options = opts && typeof opts === 'object' ? opts : {}
697
+
698
+ const resolved = resolveChange(root, change)
699
+ if (resolved.error) {
700
+ fail(
701
+ `${resolved.error}${resolved.candidates && resolved.candidates.length ? `\n candidates: ${resolved.candidates.join(', ')}` : ''}`
702
+ )
703
+ }
704
+ const name = resolved.name
705
+
706
+ const run = { checks: [], blockers: [], warnings: [] }
707
+
708
+ // 1. Artifacts. Everything downstream of them (scenarios, tasks, risk text)
709
+ // fails together when they cannot be read.
710
+ const inspected = attempt(() => inspectChange(root, name))
711
+ const artifactsGot = attempt(() => {
712
+ if (!inspected.ok) throw new Error(inspected.error)
713
+ return options.artifacts !== undefined
714
+ ? normalizeArtifacts(options.artifacts)
715
+ : readArtifactsFromDisk(inspected.value)
716
+ })
717
+
718
+ const artifactDependents = [
719
+ 'artifacts.implementable',
720
+ 'specs.scenarios-mapped',
721
+ 'tasks.no-weasel-phrases',
722
+ 'risk.class-allowed'
723
+ ]
724
+
725
+ let riskResult = null
726
+
727
+ if (!inspected.ok || !artifactsGot.ok) {
728
+ const reason = inspected.ok ? artifactsGot.error : inspected.error
729
+ blockAll(run, artifactDependents, 'ARTIFACTS_UNREADABLE', `change artifacts could not be read: ${reason}`, [reason])
730
+ } else {
731
+ const inspection = inspected.value
732
+ const artifacts = artifactsGot.value
733
+ const tasks = typeof artifacts['tasks.md'] === 'string' ? parseTasks(artifacts['tasks.md']) : inspection.tasks.items
734
+
735
+ checkArtifactsImplementable(run, inspection)
736
+ checkScenarioCoverage(run, artifacts, tasks)
737
+ checkWeaselPhrases(run, tasks)
738
+
739
+ const paths = Array.isArray(options.changedPaths)
740
+ ? options.changedPaths
741
+ : Array.isArray(options.plannedPaths)
742
+ ? options.plannedPaths
743
+ : []
744
+ const risk = attempt(() =>
745
+ classifyRisk({ changedPaths: paths, artifacts }, { allowedClasses: STRICTNESS.allowedRiskClasses })
746
+ )
747
+ checkRisk(run, risk)
748
+ riskResult = risk.ok ? risk.value : null
749
+ }
750
+
751
+ // 2. The ledger, and the artifact review the caller ran for us.
752
+ const decisions = checkDecisions(run, root, name)
753
+ checkReview(run, options.findings, options.review)
754
+
755
+ // 3. Runtime readiness, then the checklist items we chose not to automate.
756
+ checkTestProfile(run, root)
757
+ addSkips(run)
758
+
759
+ const failed = run.checks.filter(c => c.status === 'fail').length
760
+ const passed = run.checks.filter(c => c.status === 'pass').length
761
+ const skipped = run.checks.filter(c => c.status === 'skip').length
762
+
763
+ // `ready` is an AND of affirmative passes, not the absence of a complaint.
764
+ const ready = run.blockers.length === 0 && failed === 0 && passed > 0
765
+
766
+ return {
767
+ ready,
768
+ change: name,
769
+ strictness: STRICTNESS.name,
770
+ blockers: run.blockers,
771
+ warnings: run.warnings,
772
+ decisions: {
773
+ needsHuman: decisions ? decisions.needsHuman : null,
774
+ agentResolved: decisions ? decisions.agentResolved : null,
775
+ invalid: decisions ? decisions.invalid : null,
776
+ exists: decisions ? decisions.exists : null
777
+ },
778
+ riskClass: riskResult ? riskResult.riskClass : null,
779
+ continuityAllowed: Boolean(ready && riskResult && riskResult.continuityAllowed),
780
+ checks: run.checks,
781
+ scores: {
782
+ checksTotal: run.checks.length,
783
+ checksPassed: passed,
784
+ checksFailed: failed,
785
+ checksSkipped: skipped,
786
+ blockers: run.blockers.length,
787
+ warnings: run.warnings.length
788
+ }
789
+ }
790
+ }
791
+
792
+ const STATUS_MARK = { pass: 'ok ', fail: 'FAIL', skip: 'skip' }
793
+
794
+ /** Human-readable readiness, for a skill to echo before it decides. */
795
+ export function formatReadiness(result) {
796
+ const lines = []
797
+ lines.push(
798
+ result.ready
799
+ ? `READY — ${result.change} may continue (risk ${result.riskClass}, strictness ${result.strictness})`
800
+ : `NOT READY — ${result.change}: ${result.blockers.length} blocker(s) ` +
801
+ `(risk ${result.riskClass ?? 'unknown'}, strictness ${result.strictness})`
802
+ )
803
+ for (const c of result.checks) {
804
+ lines.push(` [${STATUS_MARK[c.status] || c.status}] ${c.id}: ${c.detail}`)
805
+ }
806
+ for (const b of result.blockers) {
807
+ lines.push(` blocker ${b.code}: ${b.message}`)
808
+ for (const e of b.evidence || []) lines.push(` ${e}`)
809
+ }
810
+ for (const w of result.warnings) lines.push(` warning ${w.code}: ${w.message}`)
811
+ return lines.join('\n') + '\n'
812
+ }