@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/risk.mjs ADDED
@@ -0,0 +1,370 @@
1
+ // Deterministic risk classifier for one OpenSpec change.
2
+ //
3
+ // The continuity path (WS-F) can skip the human checkpoint between spec and
4
+ // ship. Whether it *may* skip is a blast-radius question — "does this change
5
+ // touch auth, money, schemas, or data we cannot get back?" — and that question
6
+ // was previously answered nowhere, or in prose inside a skill, which means it
7
+ // was answered differently on every run by whichever model happened to read
8
+ // the proposal. A gate that can bypass a human cannot rest on a re-derived
9
+ // judgement. So the rules live here, in code, and every classification carries
10
+ // the signals that produced it.
11
+ //
12
+ // Two properties are load-bearing:
13
+ //
14
+ // 1. FAIL CLOSED. Absence of evidence is never evidence of safety. When the
15
+ // caller supplies nothing usable, the class is UNCLASSIFIABLE_CLASS
16
+ // ('high') — not 'low'. When paths are present but nothing matched, the
17
+ // floor is 'medium', not 'low': "no rule fired" means "we did not
18
+ // recognise it", not "it is safe". 'low' is only ever reached by a
19
+ // positive signal (every changed path is docs or tests). An
20
+ // unclassifiable change must never come out 'low', because 'low' is the
21
+ // class that lets a machine skip a person.
22
+ //
23
+ // 2. MAXIMUM, NEVER AVERAGE. The final class is the maximum over every
24
+ // matched signal. A change that is 90% docs and 10% payment code is a
25
+ // payment change. Averaging risk is how a critical signal gets diluted by
26
+ // volume.
27
+ //
28
+ // Signals come from two independent sources — changed (or planned) paths, and
29
+ // the raw text of the change artifacts. Text matching is deliberately
30
+ // generous: a false positive costs one human read, a false negative ships an
31
+ // auth change nobody looked at.
32
+ //
33
+ // Pure: no fs, no git, no agent, no I/O, no clock. Input is never mutated.
34
+ // Paths are repo-relative; '\' is normalized to '/'.
35
+ //
36
+ // Exposed to skills as `interlock risk --change <name> --json`.
37
+
38
+ // Ordered least to most severe. Order is the API: callers compare by index.
39
+ export const RISK_CLASSES = Object.freeze(['low', 'medium', 'high', 'critical'])
40
+
41
+ const RANK = { low: 0, medium: 1, high: 2, critical: 3 }
42
+
43
+ // §4.14 "balanced": a change may auto-continue past the human checkpoint at
44
+ // `low` or `medium` only. This is THE continuity policy knob — re-tuning §4.14
45
+ // to strict (['low']) or loose (['low','medium','high']) is this one edit.
46
+ export const CONTINUITY_ALLOWED_CLASSES = Object.freeze(['low', 'medium'])
47
+
48
+ // Where an input with no usable signal at all lands. Deliberately outside
49
+ // CONTINUITY_ALLOWED_CLASSES: "we could not tell" must stop the machine.
50
+ export const UNCLASSIFIABLE_CLASS = 'high'
51
+
52
+ // Floor for any change that touches at least one non-docs, non-test path but
53
+ // matched no severity rule. Above 'low' on purpose — see fail-closed, above.
54
+ export const UNMATCHED_SOURCE_CLASS = 'medium'
55
+
56
+ /** Higher of two risk classes. Unknown values are treated as the maximum. */
57
+ export function maxClass(a, b) {
58
+ const ra = RANK[a] ?? RANK.critical
59
+ const rb = RANK[b] ?? RANK.critical
60
+ return ra >= rb ? (RISK_CLASSES[ra] ?? 'critical') : (RISK_CLASSES[rb] ?? 'critical')
61
+ }
62
+
63
+ // --- rule tables ---------------------------------------------------------
64
+ //
65
+ // One named constant per signal group so the rules stay greppable: when a
66
+ // classification surprises someone, they should be able to find the exact
67
+ // regex from the signal name printed in the output.
68
+
69
+ // critical — irreversible, or money.
70
+ const DATA_DESTRUCTION_PATH =
71
+ /(^|\/)(migrations?|db|database|sql)\/[^/]*(drop|delete|purge|truncate|destroy|prune|backfill)/
72
+ const DATA_DESTRUCTION_TEXT =
73
+ /\b(drop\s+(table|column|database|index)|delete\s+from|truncate\s+table|hard[\s-]?delete\w*|purge\w*|cascade\s+delete|destructive\s+migration|data\s+loss|irreversibl\w+|permanently\s+(delete|remove)\w*|wipe\w*)\b/
74
+
75
+ const PAYMENT_PATH =
76
+ /(^|\/)(payments?|billing|checkout|invoices?|stripe|paypal|braintree|subscriptions?|pricing|wallet|payouts?)([/\-_.]|$)/
77
+ const PAYMENT_TEXT =
78
+ /\b(payment\w*|billing|checkout|invoice\w*|refund\w*|payout\w*|charge\s+the\s+(card|customer)|stripe|paypal|braintree|price\s+in\s+cents|credit\s+card|money\s+transfer)\b/
79
+
80
+ const IDEMPOTENCY_TEXT =
81
+ /\b(idempoten\w*|exactly[\s-]once|at[\s-]least[\s-]once|de[\s-]?duplicat\w*|retry\s+semantics|replay\s+(protection|attack)|double[\s-](charge|submit|write)\w*)\b/
82
+
83
+ // high — auth/tenancy, and anything that changes a contract or a schema.
84
+ const AUTH_PATH =
85
+ /(^|\/)(auth|authn|authz|oauth|session|sessions|login|logout|signin|signup|password\w*|credentials?|permissions?|roles?|rbac|acl|policy|policies|tenant|tenants|tenancy|security)([/\-_.]|$)/
86
+ const AUTH_TEXT =
87
+ /\b(authenticat\w+|authoriz\w+|authn|authz|sign[\s-]?(in|up|out)|log\s?in\b|logout|session\s+(token|cookie|store|fixation|id)|password\w*|credential\w*|api\s+keys?|jwt|oauth\d?|saml|sso|permission\w*|role[\s-]based|rbac\b|acl\b|access\s+control|multi[\s-]?tenan\w*|tenant\s+(boundary|isolation|id)|impersonat\w+|csrf|privilege\s+escalation)\b/
88
+
89
+ const API_SCHEMA_PATH =
90
+ /(^|\/)(migrations?|migrate|schemas?|openapi|swagger|proto|prisma)([/\-_.]|$)|(^|\/)schema\.(prisma|graphql|sql|json|ts)$|\.(sql|proto)$|(^|\/)openapi\.(ya?ml|json)$/
91
+ const API_SCHEMA_TEXT =
92
+ /\b(new\s+(public\s+)?(api|endpoint|route)|public\s+api|api\s+contract|breaking\s+change|backwards?[\s-]incompatible|database\s+(migration|schema)|migration\b|migrat(e|ing)\s+(the\s+)?(data|table|column|schema)|schema\s+change|alter\s+table|add\s+column|drop\s+index|new\s+table|openapi|graphql\s+schema|wire\s+format|serialization\s+format)\b/
93
+
94
+ // medium — the invariant-sweep / shared-value-transform floor from §F3.
95
+ const SHARED_TRANSFORM_PATH =
96
+ /(^|\/)(shared|common)\/|(^|\/)(utils?|helpers?|lib)\/[^/]*(format|currency|money|amount|date|time|timezone|tz|unit|round|serializ|transform|convert|normaliz)/
97
+ const SHARED_TRANSFORM_TEXT =
98
+ /\b(invariant\s+sweep|invariant\w*|every\s+call\s?site|all\s+call\s?sites|all\s+consumers|every\s+consumer|cross[\s-]cutting|repo[\s-]wide|codebase[\s-]wide|shared\s+(helper|util\w*|value|transform\w*|format\w*|constant)|rename\s+across|sweep\s+(all|every))\b/
99
+
100
+ // low — the only positive route to 'low'.
101
+ export const DOC_PATH = /\.(md|mdx|txt|rst|adoc)$|(^|\/)(docs?|openspec|\.claude)\//
102
+ const TEST_PATH =
103
+ /(^|\/)(tests?|__tests__|e2e|cypress|playwright|fixtures|__mocks__)\/|\.(test|spec)\.[a-z]+$/
104
+
105
+ /** Documentation path — shared with the wave checkpoint skip and verify plan. */
106
+ export function isDocsPath(path) {
107
+ const lower = String(path || '').replace(/\\/g, '/').toLowerCase()
108
+ return DOC_PATH.test(lower)
109
+ }
110
+
111
+ // Evaluated in this order; output is sorted by severity afterwards.
112
+ const SIGNALS = Object.freeze([
113
+ { signal: 'shared-value-transform', class: 'medium', path: SHARED_TRANSFORM_PATH, text: SHARED_TRANSFORM_TEXT },
114
+ { signal: 'api-schema-migration', class: 'high', path: API_SCHEMA_PATH, text: API_SCHEMA_TEXT },
115
+ { signal: 'auth-session-permissions-tenancy', class: 'high', path: AUTH_PATH, text: AUTH_TEXT },
116
+ { signal: 'data-destruction', class: 'critical', path: DATA_DESTRUCTION_PATH, text: DATA_DESTRUCTION_TEXT },
117
+ { signal: 'payment', class: 'critical', path: PAYMENT_PATH, text: PAYMENT_TEXT },
118
+ { signal: 'idempotency', class: 'critical', path: null, text: IDEMPOTENCY_TEXT }
119
+ ])
120
+
121
+ // How many matches to name per signal before collapsing to a count.
122
+ const MAX_EVIDENCE = 3
123
+
124
+ // --- helpers -------------------------------------------------------------
125
+
126
+ /**
127
+ * The one canonical form of a repo-relative path.
128
+ *
129
+ * Every module that compares two paths — the wave planner's collision key, its
130
+ * changed-file dedup, the docs-only test, the review evidence gate — reads this
131
+ * and nothing else. The transform used to be private here and `lib/waves.mjs`
132
+ * simply did not have it, which is how `src/a.ts` and `./src/a.ts` became two
133
+ * keys for one file and two implementers ended up writing it concurrently.
134
+ *
135
+ * Lexical on purpose. A predicted path names a file that may not exist yet, so
136
+ * `realpath` and `existsSync` are both the wrong instrument.
137
+ *
138
+ * Absolute paths and paths that escape the repository root are **rejected**
139
+ * rather than rewritten. Rewriting `../outside/a.ts` into something in scope
140
+ * would silently attribute an edit to a file the task never named; returning
141
+ * null lets the caller report it as unusable, which is what the spec asks for.
142
+ *
143
+ * @param {unknown} entry
144
+ * @returns {string|null} the canonical form, or null when the input cannot
145
+ * name a file inside this repository
146
+ */
147
+ export function canonicalizePath(entry) {
148
+ if (typeof entry !== 'string') return null
149
+ const trimmed = entry.trim().replace(/\\/g, '/')
150
+ if (!trimmed) return null
151
+ // Rejected, never normalized into scope: POSIX absolute, Windows drive, UNC.
152
+ if (trimmed.startsWith('/') || /^[a-zA-Z]:\//.test(trimmed)) return null
153
+
154
+ // posixNormalize collapses '.', '..' and duplicate separators lexically.
155
+ const collapsed = posixNormalize(trimmed).replace(/\/+$/, '')
156
+ if (!collapsed || collapsed === '.' || collapsed === '..') return null
157
+ if (collapsed.startsWith('../')) return null
158
+ return collapsed
159
+ }
160
+
161
+ // `node:path`'s posix.normalize, inlined so this module keeps its "pure, no
162
+ // imports beyond what it needs" shape and behaves identically on Windows.
163
+ function posixNormalize(path) {
164
+ const absolute = path.startsWith('/')
165
+ const out = []
166
+ for (const segment of path.split('/')) {
167
+ if (!segment || segment === '.') continue
168
+ if (segment === '..') {
169
+ if (out.length && out[out.length - 1] !== '..') out.pop()
170
+ else if (!absolute) out.push('..')
171
+ continue
172
+ }
173
+ out.push(segment)
174
+ }
175
+ const joined = out.join('/')
176
+ return absolute ? `/${joined}` : joined
177
+ }
178
+
179
+ // Tolerate junk entries: non-strings drop out, surrounding space is trimmed,
180
+ // leading './' is stripped, '\' becomes '/'.
181
+ //
182
+ // Deliberately *not* `canonicalizePath`. Risk classification and collision
183
+ // keying want opposite things from an unusable path: keying must reject it,
184
+ // because a path we cannot place in the repo cannot be an identity, while
185
+ // classification must keep it, because dropping `/srv/payments/charge.ts`
186
+ // would lower the blast radius of a change that touches payments. Fail closed
187
+ // means the opposite thing in each place, so each gets its own reader.
188
+ function normalizePath(entry) {
189
+ if (typeof entry !== 'string') return ''
190
+ const canonical = canonicalizePath(entry)
191
+ if (canonical) return canonical
192
+ let p = entry.trim().replace(/\\/g, '/')
193
+ while (p.startsWith('./')) p = p.slice(2)
194
+ return p
195
+ }
196
+
197
+ function normalizePaths(input) {
198
+ const list = Array.isArray(input) ? input : []
199
+ const out = []
200
+ for (const entry of list) {
201
+ const path = normalizePath(entry)
202
+ if (path) out.push({ path, lower: path.toLowerCase() })
203
+ }
204
+ return out
205
+ }
206
+
207
+ // artifacts is { 'proposal.md': '<text>', ... } — whatever the caller read.
208
+ // Non-string and blank values are dropped rather than throwing: a caller that
209
+ // could not read design.md still deserves a classification.
210
+ function normalizeArtifacts(input) {
211
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return []
212
+ const out = []
213
+ for (const name of Object.keys(input)) {
214
+ const value = input[name]
215
+ if (typeof value !== 'string') continue
216
+ if (!value.trim()) continue
217
+ out.push({ name, text: value, lower: value.toLowerCase() })
218
+ }
219
+ return out
220
+ }
221
+
222
+ function snippet(text, index, length) {
223
+ const start = Math.max(0, index - 24)
224
+ const end = Math.min(text.length, index + length + 24)
225
+ let s = text.slice(start, end).replace(/\s+/g, ' ').trim()
226
+ if (start > 0) s = `…${s}`
227
+ if (end < text.length) s = `${s}…`
228
+ return s
229
+ }
230
+
231
+ function joinEvidence(items) {
232
+ if (items.length <= MAX_EVIDENCE) return items.join('; ')
233
+ return `${items.slice(0, MAX_EVIDENCE).join('; ')} (+${items.length - MAX_EVIDENCE} more)`
234
+ }
235
+
236
+ // --- public API ----------------------------------------------------------
237
+
238
+ /**
239
+ * Classify one change from its changed (or planned) paths plus artifact text.
240
+ *
241
+ * @param {{ changedPaths?: string[], artifacts?: Record<string,string> }} input
242
+ * @param {{ allowedClasses?: string[] }} [opts]
243
+ * @returns {{
244
+ * riskClass: 'low'|'medium'|'high'|'critical',
245
+ * signals: Array<{signal: string, class: string, evidence: string}>,
246
+ * continuityAllowed: boolean,
247
+ * reasons: string[],
248
+ * basis: 'no-input'|'signals',
249
+ * pathCount: number,
250
+ * artifactNames: string[]
251
+ * }}
252
+ */
253
+ export function classifyRisk(input, opts = {}) {
254
+ const source = input && typeof input === 'object' ? input : {}
255
+ const allowed = Array.isArray(opts.allowedClasses) ? opts.allowedClasses : CONTINUITY_ALLOWED_CLASSES
256
+
257
+ const paths = normalizePaths(source.changedPaths)
258
+ const artifacts = normalizeArtifacts(source.artifacts)
259
+
260
+ const signals = []
261
+ const reasons = []
262
+
263
+ // 1. Nothing usable at all. Fail closed, loudly.
264
+ if (paths.length === 0 && artifacts.length === 0) {
265
+ signals.push({
266
+ signal: 'no-input',
267
+ class: UNCLASSIFIABLE_CLASS,
268
+ evidence: 'no changed paths and no artifact text were supplied'
269
+ })
270
+ reasons.push(
271
+ `unclassifiable: no signals available, so the class fails closed to ${UNCLASSIFIABLE_CLASS} rather than low`
272
+ )
273
+ return finish(UNCLASSIFIABLE_CLASS, signals, reasons, allowed, 'no-input', paths, artifacts)
274
+ }
275
+
276
+ // 2. Severity signals over both sources. Every group is evaluated — the
277
+ // result is the maximum, so an early match never short-circuits a later,
278
+ // worse one.
279
+ for (const def of SIGNALS) {
280
+ const evidence = []
281
+ if (def.path) {
282
+ for (const p of paths) {
283
+ if (def.path.test(p.lower)) evidence.push(`path ${p.path}`)
284
+ }
285
+ }
286
+ if (def.text) {
287
+ for (const a of artifacts) {
288
+ const m = def.text.exec(a.lower)
289
+ if (m) evidence.push(`${a.name}: "${snippet(a.text, m.index, m[0].length)}"`)
290
+ }
291
+ }
292
+ if (evidence.length) {
293
+ signals.push({ signal: def.signal, class: def.class, evidence: joinEvidence(evidence) })
294
+ }
295
+ }
296
+
297
+ // 3. Baseline floor. This is where fail-closed does its real work: the floor
298
+ // is only 'low' when a positive docs/tests-only signal fired.
299
+ const docLike = paths.filter(p => isDocsPath(p.path) || TEST_PATH.test(p.lower))
300
+ if (paths.length > 0 && docLike.length === paths.length) {
301
+ signals.push({
302
+ signal: 'docs-tests-only',
303
+ class: 'low',
304
+ evidence: `all ${paths.length} changed path(s) are docs or tests: ${joinEvidence(docLike.map(p => p.path))}`
305
+ })
306
+ reasons.push('every changed path is documentation or test-only')
307
+ } else if (paths.length > 0) {
308
+ const sourcePaths = paths.filter(p => !isDocsPath(p.path) && !TEST_PATH.test(p.lower))
309
+ signals.push({
310
+ signal: 'unmatched-source-change',
311
+ class: UNMATCHED_SOURCE_CLASS,
312
+ evidence: `${sourcePaths.length} non-docs path(s) with no matching risk rule: ${joinEvidence(sourcePaths.map(p => p.path))}`
313
+ })
314
+ reasons.push(
315
+ `source paths changed with no rule match — floored at ${UNMATCHED_SOURCE_CLASS}, not low (unrecognised is not safe)`
316
+ )
317
+ } else {
318
+ // Artifact text only. Blast radius is unbounded because nobody told us
319
+ // which files move, so the floor stays above 'low'.
320
+ signals.push({
321
+ signal: 'no-changed-paths',
322
+ class: UNMATCHED_SOURCE_CLASS,
323
+ evidence: `artifact text only (${artifacts.map(a => a.name).join(', ')}); no changed paths supplied to bound the blast radius`
324
+ })
325
+ reasons.push(
326
+ `no changed paths supplied — blast radius unbounded, floored at ${UNMATCHED_SOURCE_CLASS}`
327
+ )
328
+ }
329
+
330
+ // 4. Maximum, never average.
331
+ let riskClass = 'low'
332
+ for (const s of signals) riskClass = maxClass(riskClass, s.class)
333
+
334
+ const driving = signals.filter(s => s.class === riskClass).map(s => s.signal)
335
+ reasons.push(`class ${riskClass} = max over ${signals.length} signal(s), driven by: ${driving.join(', ')}`)
336
+
337
+ return finish(riskClass, signals, reasons, allowed, 'signals', paths, artifacts)
338
+ }
339
+
340
+ function finish(riskClass, signals, reasons, allowed, basis, paths, artifacts) {
341
+ const continuityAllowed = allowed.includes(riskClass)
342
+ reasons.push(
343
+ continuityAllowed
344
+ ? `continuity allowed: ${riskClass} ∈ {${allowed.join(', ')}} (§4.14 balanced)`
345
+ : `continuity refused: ${riskClass} ∉ {${allowed.join(', ')}} (§4.14 balanced) — human checkpoint required`
346
+ )
347
+ // Severity-first so the driving signal reads first.
348
+ const ordered = signals.slice().sort((a, b) => (RANK[b.class] ?? 3) - (RANK[a.class] ?? 3))
349
+ return {
350
+ riskClass,
351
+ signals: ordered,
352
+ continuityAllowed,
353
+ reasons,
354
+ basis,
355
+ pathCount: paths.length,
356
+ artifactNames: artifacts.map(a => a.name)
357
+ }
358
+ }
359
+
360
+ /** Human-readable classification, for a skill to echo before it decides. */
361
+ export function formatRisk(result) {
362
+ const lines = []
363
+ lines.push(
364
+ `RISK ${result.riskClass.toUpperCase()} — continuity ${result.continuityAllowed ? 'allowed' : 'NOT allowed'} ` +
365
+ `(${result.pathCount} path(s), artifacts: ${result.artifactNames.length ? result.artifactNames.join(', ') : 'none'})`
366
+ )
367
+ for (const s of result.signals) lines.push(` [${s.class}] ${s.signal}: ${s.evidence}`)
368
+ for (const r of result.reasons) lines.push(` reason: ${r}`)
369
+ return lines.join('\n') + '\n'
370
+ }