@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/report.mjs ADDED
@@ -0,0 +1,1124 @@
1
+ // The reader the corpora never had.
2
+ //
3
+ // Interlock writes three corpora and, until this module, read none of them.
4
+ // `lib/outcomes.mjs` says so in its own header — per §4.15(a) it "records and
5
+ // nothing else" — `lib/run-log.mjs` accumulates a trajectory per ship run, and
6
+ // `lib/metrics.mjs` writes a review's counts. All three were write-only, which
7
+ // left the §4.16 / F6.6 question ("should we have skipped the human that time?")
8
+ // unanswerable not because the data was absent but because nothing computed
9
+ // over it.
10
+ //
11
+ // WHY THIS NEVER BLOCKS, AND NEVER WILL. Every rate here is one comparison away
12
+ // from a threshold, and §4.15(a) forbids exactly that comparison. So this module
13
+ // is an instrument: it exits nowhere, it labels nothing pass or fail, and no
14
+ // gate, readiness check, risk classification or workflow step may consume it.
15
+ // `interlock drift` and `interlock conformance` set the precedent for a
16
+ // subcommand that reports and does not block; this one carries the stronger
17
+ // version of that promise because the temptation is stronger.
18
+ //
19
+ // Four properties are load-bearing:
20
+ //
21
+ // 1. NEVER THROWS. A missing corpus, an unreadable file, a torn line and an
22
+ // unreadable directory each become a reported condition carrying its
23
+ // reason. A reader whose own crash is the output is worse than no reader.
24
+ //
25
+ // 2. WRITES NOTHING. Not even a cache. `lib/metrics.mjs` isolates the write
26
+ // paths in `lib/` deliberately, and a reader that writes would join that
27
+ // exception for no policy reason. The mitigation for a slow scan is
28
+ // `--since`, not an index.
29
+ //
30
+ // 3. A DENOMINATOR TRAVELS WITH EVERY VALUE. `lib/doctor.mjs` holds that an
31
+ // unknown is not a pass; `lib/outcomes.mjs` holds that reading absence as
32
+ // cleanliness would flatter exactly the runs a corpus exists to explain. A
33
+ // rate over an unstated denominator commits both errors at once, so a zero
34
+ // denominator yields `null` and a reason — never `0`.
35
+ //
36
+ // 4. PROVENANCE SURVIVES THE ARITHMETIC. `lib/outcomes.mjs` partitions what a
37
+ // run observed from what an agent reported. Averaging across that boundary
38
+ // would launder a report into a measurement, so every indicator declares
39
+ // the corpus it came from and the two are never summed.
40
+ //
41
+ // A FOURTH CORPUS JOINED LATER AND IS NOT ONE OF THE THREE. The outcome-eval
42
+ // history (`evals/history/ship-outcomes.jsonl`) records what a fixture run
43
+ // produced, not what real work produced. It is read under the same four
44
+ // properties above, and under one more: its indicators live in their own group,
45
+ // name their own source, carry their own denominators, and are never summed with
46
+ // a figure derived from a real run. A fixture result folded into a real-run
47
+ // figure would be a number about neither.
48
+ //
49
+ // THE SHARPEST INDICATOR IS COMPUTED FROM TWO RECORDED PATH SETS. "Does the
50
+ // merged diff still match the plan" was published as a declared gap for as long
51
+ // as nothing recorded the comparison. The receipt now records both halves — the
52
+ // paths the run's commit touched, read from version control, and the paths its
53
+ // executed plan predicted — so `planFidelity.diffMatchesPlan` is a share with a
54
+ // denominator, like every other value here.
55
+ //
56
+ // Its two near misses stay separate and are never merged into it: `planStatus`
57
+ // is about whether a *stored plan* was reused given its planning inputs, and the
58
+ // replan rate is about the plan changing mid-run. Both are about the plan;
59
+ // neither observes the diff, and publishing either under this name would be the
60
+ // restatement defect the old gap text existed to prevent.
61
+ //
62
+ // A run contributes only when both sets were observed, the prediction was
63
+ // complete, and neither set was truncated. Every other run is EXCLUDED AND
64
+ // COUNTED BY REASON rather than absorbed. A plan that declined to predict for
65
+ // some task is excluded rather than imputed as predicting nothing: imputing
66
+ // would depress the share for a planner's silence, and read as an implementer
67
+ // going off-plan.
68
+ //
69
+ // Exposed to skills as `interlock report [--json] [--since <iso>] [--change <name>]`.
70
+
71
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
72
+ import { join } from 'node:path'
73
+ import { LEARNING_DIR, OUTCOMES_FILE, readOutcomes } from './outcomes.mjs'
74
+ import { METRICS_DIR, REVIEW_METRICS_SCHEMA } from './metrics.mjs'
75
+ import { RUN_LOG_DIR, readRunLog, runLogDir } from './run-log.mjs'
76
+ import { EVAL_HISTORY_DIR, SHIP_OUTCOMES_FILE, readShipOutcomes } from './eval-history.mjs'
77
+ import { REPORT_CAPS } from './limits.mjs'
78
+
79
+ /** Bump when an indicator's definition changes, so two reports stay comparable. */
80
+ export const REPORT_SCHEMA = 'interlock.report/1'
81
+
82
+ /**
83
+ * Where each indicator's numbers came from. Kept as an explicit vocabulary
84
+ * rather than a free string so a reader can tell a measurement from a report
85
+ * without reading this file.
86
+ */
87
+ export const SOURCES = Object.freeze({
88
+ RECEIPT: 'receipt',
89
+ TRAJECTORY: 'trajectory',
90
+ OUTCOME_OBSERVED: 'outcome.observed',
91
+ OUTCOME_REPORTED: 'outcome.reported',
92
+ REVIEW_METRICS: 'review-metrics',
93
+ /**
94
+ * The outcome-eval history — results from fixture runs, not from real work.
95
+ * Its own source name is what keeps it out of every other indicator: a figure
96
+ * that summed a fixture result with a real run would be reporting a number
97
+ * about neither.
98
+ */
99
+ EVAL_HISTORY: 'eval-history'
100
+ })
101
+
102
+ /**
103
+ * The change name a trajectory carries when nothing told it which change it was
104
+ * shipping. Runs carrying it are counted and reported as unattributed rather
105
+ * than folded into a per-change tally — `a8e0db5` fixed the cause, so the share
106
+ * is a fact about when a run happened, not a defect in this reader.
107
+ */
108
+ export const UNATTRIBUTED = 'unnamed'
109
+
110
+ /**
111
+ * What every surface says when the outcome-eval history holds nothing.
112
+ *
113
+ * Exported so the text and HTML renderings read it from one place. Two surfaces
114
+ * wording the same absence differently is how a reader comes to believe they are
115
+ * looking at two different facts.
116
+ */
117
+ export const NO_EVAL_RESULT = 'no outcome-eval result has been recorded'
118
+
119
+ function messageOf(err) {
120
+ return (err && err.message) || String(err)
121
+ }
122
+
123
+ function isObject(value) {
124
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
125
+ }
126
+
127
+ /**
128
+ * One indicator. `value` is `null` whenever the denominator is zero, and
129
+ * `reason` then says why — the whole point of property 3 in the header.
130
+ */
131
+ function indicator(source, { value = null, observedOf = 0, notObserved = 0, reason = null } = {}) {
132
+ return {
133
+ source,
134
+ value: observedOf > 0 ? value : null,
135
+ observedOf,
136
+ notObserved,
137
+ reason: observedOf > 0 ? null : reason || 'nothing observed this value'
138
+ }
139
+ }
140
+
141
+ /** A share, rounded to four places so a rate reads as a rate rather than a float artefact. */
142
+ function share(numerator, denominator) {
143
+ if (!denominator) return null
144
+ return Math.round((numerator / denominator) * 10000) / 10000
145
+ }
146
+
147
+ // A tri-state count, matching `lib/run-log.mjs`'s receipt fields: `null` means
148
+ // the run never found out, which is not zero.
149
+ function observedCount(value) {
150
+ if (value === null || value === undefined) return null
151
+ const n = Number(value)
152
+ return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : null
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Corpus reading
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /**
160
+ * Enumerate trajectory files newest-first and bound how many are opened.
161
+ *
162
+ * Ordered by mtime rather than by the timestamps inside, because the bound has
163
+ * to be applied *before* reading — sorting by recorded start time would require
164
+ * opening every file to decide which files not to open. mtime is the best
165
+ * available proxy for "most recent" without a read, and the truncation is
166
+ * reported rather than sampled silently.
167
+ */
168
+ function enumerateTrajectories(root, maxRuns) {
169
+ const dir = runLogDir(root)
170
+ let names
171
+ try {
172
+ names = existsSync(dir) ? readdirSync(dir) : []
173
+ } catch (err) {
174
+ return { total: 0, runIds: [], truncated: false, notScanned: 0, reason: messageOf(err) }
175
+ }
176
+ const files = names.filter(name => name.endsWith('.jsonl'))
177
+ const stamped = files.map(name => {
178
+ let mtime = 0
179
+ try {
180
+ mtime = statSync(join(dir, name)).mtimeMs
181
+ } catch {
182
+ // An unstattable file sorts last and is still eligible to be read; the
183
+ // read reports its own failure.
184
+ mtime = 0
185
+ }
186
+ return { runId: name.slice(0, -'.jsonl'.length), mtime }
187
+ })
188
+ stamped.sort((a, b) => b.mtime - a.mtime)
189
+ const kept = stamped.slice(0, maxRuns)
190
+ return {
191
+ total: files.length,
192
+ runIds: kept.map(s => s.runId),
193
+ truncated: files.length > kept.length,
194
+ notScanned: files.length - kept.length,
195
+ reason: null
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Read the trajectories, keeping one digest per run rather than every event —
201
+ * the indicators need tallies, and holding 4 000 event objects to compute six
202
+ * numbers would be the only unbounded allocation in this module.
203
+ *
204
+ * A run is in the window if its earliest record is at or after `since`. One rule
205
+ * for run-level and event-level indicators alike, so they share a denominator
206
+ * basis; two rules would make the two halves of the report quietly
207
+ * incomparable.
208
+ */
209
+ function digestTrajectories(root, { since, change, maxRuns }) {
210
+ const listing = enumerateTrajectories(root, maxRuns)
211
+ const runs = []
212
+ const unreadable = []
213
+ let skippedLines = 0
214
+ let filteredOut = 0
215
+
216
+ for (const runId of listing.runIds) {
217
+ const read = readRunLog(root, runId)
218
+ if (read.reason) {
219
+ unreadable.push({ runId, reason: read.reason })
220
+ continue
221
+ }
222
+ skippedLines += read.skipped.length
223
+ const records = read.records
224
+ if (!records.length) {
225
+ // A file that parsed to nothing is not an unreadable file and not a run.
226
+ filteredOut++
227
+ continue
228
+ }
229
+
230
+ const firstTs = records.map(r => (typeof r.ts === 'string' ? r.ts : '')).filter(Boolean).sort()[0] || ''
231
+ if (since && (!firstTs || firstTs < since)) {
232
+ filteredOut++
233
+ continue
234
+ }
235
+
236
+ const start = records.find(r => r.type === 'run-start')
237
+ const receipt = records.find(r => r.type === 'run-receipt')
238
+ const runChange =
239
+ (start && typeof start.change === 'string' && start.change) ||
240
+ (typeof records[0].change === 'string' && records[0].change) ||
241
+ UNATTRIBUTED
242
+ if (change && runChange !== change) {
243
+ filteredOut++
244
+ continue
245
+ }
246
+
247
+ const waveActions = records.filter(r => r.type === 'wave-action')
248
+ const cliExits = records.filter(r => r.type === 'cli-exit')
249
+
250
+ runs.push({
251
+ runId,
252
+ change: runChange,
253
+ startedAt: firstTs || null,
254
+ hasRunStart: Boolean(start),
255
+ hasTerminal: records.some(r => r.type === 'run-complete' || r.type === 'run-halt'),
256
+ hasReceipt: Boolean(receipt),
257
+ // Receipt-derived, tri-state throughout: a receipt written by a run that
258
+ // halted before its review step reports the review counts as unobserved.
259
+ halted: receipt ? receipt.halted === true : null,
260
+ remediationRounds: receipt ? observedCount(receipt.remediationRounds) : null,
261
+ planStatus: receipt && typeof receipt.planStatus === 'string' ? receipt.planStatus : null,
262
+ reviewRaised: receipt ? observedCount(receipt.reviewRaised) : null,
263
+ reviewSurviving: receipt ? observedCount(receipt.reviewSurviving) : null,
264
+ reviewBlockers: receipt ? observedCount(receipt.reviewBlockers) : null,
265
+ reviewWarnings: receipt ? observedCount(receipt.reviewWarnings) : null,
266
+ // The two path sets, carried as `null` unless the receipt recorded an
267
+ // actual array. `committed` comes with them because it is what separates
268
+ // the two ways a touched set can be missing: a run that made no commit
269
+ // from a commit whose paths could not be read.
270
+ committed: receipt ? (receipt.committed === true ? true : receipt.committed === false ? false : null) : null,
271
+ touchedPaths: receipt && Array.isArray(receipt.touchedPaths) ? receipt.touchedPaths : null,
272
+ predictedPaths: receipt && Array.isArray(receipt.predictedPaths) ? receipt.predictedPaths : null,
273
+ predictedPathsComplete: receipt && receipt.predictedPathsComplete === true,
274
+ pathsTruncated:
275
+ Boolean(receipt) &&
276
+ (receipt.touchedPathsTruncated === true || receipt.predictedPathsTruncated === true),
277
+ // Trajectory-derived, and available on runs with no receipt at all — which
278
+ // is why these two indicators carry the report before the receipt path has
279
+ // ever fired.
280
+ replanned: waveActions.some(r => r.source === 'replan'),
281
+ hasWaveActions: waveActions.length > 0,
282
+ exits: cliExits.map(r => ({
283
+ command: typeof r.command === 'string' && r.command ? r.command : '(unnamed)',
284
+ exitCode: Number(r.exitCode) || 0
285
+ }))
286
+ })
287
+ }
288
+
289
+ return { listing, runs, unreadable, skippedLines, filteredOut }
290
+ }
291
+
292
+ /**
293
+ * Read `.claude/metrics/`, classifying by the declared `schema` and never by
294
+ * filename.
295
+ *
296
+ * `lib/metrics.mjs` writes `review-<change>-<stamp>.json`; `skills/review-
297
+ * artifacts/SKILL.md` writes `review-artifacts-<change>-<stamp>.json`, which the
298
+ * glob `review-*.json` also matches while carrying full finding bodies instead
299
+ * of counts. A filename reader would mix a skill's prose into a counts
300
+ * indicator and nothing would notice, so an unrecognized shape is named and
301
+ * excluded rather than parsed hopefully.
302
+ */
303
+ function digestMetrics(root, { since, change }) {
304
+ const dir = join(root, METRICS_DIR)
305
+ const empty = { exists: false, total: 0, recognized: [], unrecognized: [], unreadable: [], filteredOut: 0 }
306
+ let names
307
+ try {
308
+ if (!existsSync(dir)) return empty
309
+ names = readdirSync(dir).filter(n => n.endsWith('.json'))
310
+ } catch (err) {
311
+ return { ...empty, exists: true, unreadable: [{ file: METRICS_DIR, reason: messageOf(err) }] }
312
+ }
313
+
314
+ const recognized = []
315
+ const unrecognized = []
316
+ const unreadable = []
317
+ let filteredOut = 0
318
+
319
+ for (const name of names.sort()) {
320
+ let parsed
321
+ try {
322
+ parsed = JSON.parse(readFileSync(join(dir, name), 'utf8'))
323
+ } catch (err) {
324
+ unreadable.push({ file: name, reason: messageOf(err) })
325
+ continue
326
+ }
327
+ if (!isObject(parsed) || parsed.schema !== REVIEW_METRICS_SCHEMA) {
328
+ unrecognized.push({ file: name, schema: isObject(parsed) && typeof parsed.schema === 'string' ? parsed.schema : null })
329
+ continue
330
+ }
331
+ const ts = typeof parsed.timestamp === 'string' ? parsed.timestamp : ''
332
+ if (since && (!ts || ts < since)) {
333
+ filteredOut++
334
+ continue
335
+ }
336
+ if (change && parsed.change !== change) {
337
+ filteredOut++
338
+ continue
339
+ }
340
+ const counts = isObject(parsed.counts) ? parsed.counts : {}
341
+ recognized.push({
342
+ file: name,
343
+ change: typeof parsed.change === 'string' ? parsed.change : null,
344
+ raised: observedCount(counts.raised) || 0,
345
+ dismissed: observedCount(counts.dismissed) || 0,
346
+ droppedByQuality: observedCount(counts.droppedByQuality) || 0,
347
+ surviving: observedCount(counts.surviving) || 0
348
+ })
349
+ }
350
+
351
+ return { exists: true, total: names.length, recognized, unrecognized, unreadable, filteredOut }
352
+ }
353
+
354
+ /**
355
+ * Read `evals/history/ship-outcomes.jsonl` — the fourth corpus.
356
+ *
357
+ * Under exactly the rules the other three are read by: never throws, a missing
358
+ * or unreadable file is a reported condition carrying its reason, a torn line
359
+ * costs that record and not the file, and a line is classified by its declared
360
+ * schema rather than by where it was found.
361
+ *
362
+ * THE CHANGE FILTER CANNOT APPLY HERE, and that is stated rather than worked
363
+ * around. Every other corpus keys on the change a run was shipping; an
364
+ * outcome-eval result keys on a FIXTURE, which is not a change in this
365
+ * repository and never will be. Returning these rows unfiltered under a
366
+ * `--change` view would put fixture results into a page about one change, and
367
+ * matching them against the change name would silently return nothing. So the
368
+ * corpus is excluded and says so — the quiet degradation this project forbids,
369
+ * refused in the smallest place it could have happened.
370
+ */
371
+ function digestEvalHistory(root, { since, change }) {
372
+ const path = join(root, EVAL_HISTORY_DIR, SHIP_OUTCOMES_FILE)
373
+ if (change) {
374
+ return {
375
+ path,
376
+ exists: null,
377
+ excluded: true,
378
+ reason:
379
+ `excluded from a change-filtered view: an outcome-eval result is keyed by fixture and ` +
380
+ `version, not by a change name, so the filter "${change}" cannot be applied to it`,
381
+ records: [],
382
+ unrecognized: [],
383
+ skipped: [],
384
+ filteredOut: 0
385
+ }
386
+ }
387
+
388
+ const read = readShipOutcomes(root)
389
+ const kept = []
390
+ let filteredOut = 0
391
+ for (const record of read.records) {
392
+ const ts = typeof record.ts === 'string' ? record.ts : ''
393
+ // A window filter narrows this corpus like any other: an excluded row
394
+ // contributes to no value AND to no denominator.
395
+ if (since && (!ts || ts < since)) {
396
+ filteredOut++
397
+ continue
398
+ }
399
+ kept.push(record)
400
+ }
401
+ return {
402
+ path: read.path || path,
403
+ exists: read.exists,
404
+ excluded: false,
405
+ reason: read.reason,
406
+ records: kept,
407
+ unrecognized: read.unrecognized,
408
+ skipped: read.skipped,
409
+ filteredOut
410
+ }
411
+ }
412
+
413
+ function digestOutcomes(root, { since, change }) {
414
+ const read = readOutcomes(root)
415
+ const kept = []
416
+ let filteredOut = 0
417
+ for (const record of read.records) {
418
+ const ts = typeof record.ts === 'string' ? record.ts : ''
419
+ if (since && (!ts || ts < since)) {
420
+ filteredOut++
421
+ continue
422
+ }
423
+ if (change && record.change !== change) {
424
+ filteredOut++
425
+ continue
426
+ }
427
+ kept.push(record)
428
+ }
429
+ return { ...read, records: kept, filteredOut }
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Indicators
434
+ // ---------------------------------------------------------------------------
435
+
436
+ function coverageOf(trajectories, outcomes, metrics, evalHistory) {
437
+ const byMode = {}
438
+ for (const record of outcomes.records) {
439
+ const mode = typeof record.mode === 'string' ? record.mode : 'unknown'
440
+ byMode[mode] = (byMode[mode] || 0) + 1
441
+ }
442
+ return {
443
+ trajectories: {
444
+ total: trajectories.listing.total,
445
+ scanned: trajectories.runs.length,
446
+ truncated: trajectories.listing.truncated,
447
+ notScanned: trajectories.listing.notScanned,
448
+ filteredOut: trajectories.filteredOut,
449
+ withRunStart: trajectories.runs.filter(r => r.hasRunStart).length,
450
+ withTerminal: trajectories.runs.filter(r => r.hasTerminal).length,
451
+ withReceipt: trajectories.runs.filter(r => r.hasReceipt).length,
452
+ unreadable: trajectories.unreadable,
453
+ skippedLines: trajectories.skippedLines,
454
+ reason: trajectories.listing.reason
455
+ },
456
+ outcomes: {
457
+ path: outcomes.path,
458
+ exists: outcomes.exists,
459
+ records: outcomes.records.length,
460
+ byMode,
461
+ filteredOut: outcomes.filteredOut,
462
+ skippedLines: outcomes.skipped.length,
463
+ reason: outcomes.reason
464
+ },
465
+ metrics: {
466
+ exists: metrics.exists,
467
+ total: metrics.total,
468
+ recognized: metrics.recognized.length,
469
+ unrecognized: metrics.unrecognized,
470
+ unreadable: metrics.unreadable,
471
+ filteredOut: metrics.filteredOut
472
+ },
473
+ // The fourth corpus. Fixture runs, not real work — kept in its own block for
474
+ // the same reason its indicators are kept in their own group.
475
+ evalHistory: {
476
+ path: evalHistory.path,
477
+ exists: evalHistory.exists,
478
+ excluded: evalHistory.excluded,
479
+ records: evalHistory.records.length,
480
+ unrecognized: evalHistory.unrecognized,
481
+ skippedLines: evalHistory.skipped.length,
482
+ filteredOut: evalHistory.filteredOut,
483
+ reason: evalHistory.reason
484
+ }
485
+ }
486
+ }
487
+
488
+ /**
489
+ * The share of runs that reached their close without a remediation round.
490
+ *
491
+ * Deliberately NOT called a CI success rate: no CI result is recorded anywhere
492
+ * in these corpora, and borrowing the name would claim an observation nothing
493
+ * made. A receipt whose remediation count is unobserved is counted as not
494
+ * observed and leaves the denominator short — the alternative, reading absence
495
+ * as zero rounds, would score exactly the runs that died early as the cleanest.
496
+ */
497
+ function firstPassShipOf(runs) {
498
+ const receipts = runs.filter(r => r.hasReceipt)
499
+ const observed = receipts.filter(r => r.halted !== null && r.remediationRounds !== null)
500
+ const firstPass = observed.filter(r => r.halted === false && r.remediationRounds === 0)
501
+ return {
502
+ rate: indicator(SOURCES.RECEIPT, {
503
+ value: share(firstPass.length, observed.length),
504
+ observedOf: observed.length,
505
+ notObserved: receipts.length - observed.length,
506
+ reason: receipts.length
507
+ ? 'no receipt observed both the halt state and the remediation count'
508
+ : 'no run has written a receipt'
509
+ }),
510
+ firstPassRuns: firstPass.length,
511
+ note: 'not a CI success rate — no CI result is recorded in any corpus'
512
+ }
513
+ }
514
+
515
+ function reworkOf(runs) {
516
+ const receipts = runs.filter(r => r.hasReceipt && r.remediationRounds !== null)
517
+ const distribution = {}
518
+ let total = 0
519
+ for (const run of receipts) {
520
+ const key = String(run.remediationRounds)
521
+ distribution[key] = (distribution[key] || 0) + 1
522
+ total += run.remediationRounds
523
+ }
524
+
525
+ const attributed = runs.filter(r => r.change !== UNATTRIBUTED)
526
+ const perChange = {}
527
+ for (const run of attributed) perChange[run.change] = (perChange[run.change] || 0) + 1
528
+ const changes = Object.keys(perChange)
529
+ .sort()
530
+ .map(change => ({ change, runs: perChange[change] }))
531
+
532
+ return {
533
+ // Kept apart from attempts-per-change on purpose: one counts repair inside a
534
+ // run, the other counts runs against one change. Summing them would invent a
535
+ // "rework" number neither corpus supports.
536
+ remediationRounds: {
537
+ ...indicator(SOURCES.RECEIPT, {
538
+ value: receipts.length ? Math.round((total / receipts.length) * 100) / 100 : null,
539
+ observedOf: receipts.length,
540
+ notObserved: runs.filter(r => r.hasReceipt).length - receipts.length,
541
+ reason: 'no receipt observed a remediation count'
542
+ }),
543
+ distribution
544
+ },
545
+ attemptsPerChange: {
546
+ ...indicator(SOURCES.TRAJECTORY, {
547
+ value: changes.length ? Math.round((attributed.length / changes.length) * 100) / 100 : null,
548
+ observedOf: attributed.length,
549
+ notObserved: runs.length - attributed.length,
550
+ reason: `every scanned run is attributed to "${UNATTRIBUTED}"`
551
+ }),
552
+ changes,
553
+ unattributed: runs.length - attributed.length
554
+ }
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Why a run kept out of the diff-vs-plan share was kept out. Each is counted and
560
+ * printed beside the figure, so a reader can see which recording gap to close
561
+ * first rather than inferring one from a thin denominator.
562
+ */
563
+ export const PLAN_FIDELITY_EXCLUSIONS = Object.freeze({
564
+ noCommit: 'the run made no commit, so no touched set was observed',
565
+ unreadableSet: 'a path set was recorded unobserved — the read failed',
566
+ incompletePrediction: 'the executed plan did not predict paths for every task',
567
+ truncatedSet: 'a path set exceeded its recorded bound, so it is a prefix rather than the set'
568
+ })
569
+
570
+ /**
571
+ * The share of touched paths the executed plan predicted, pooled over paths.
572
+ *
573
+ * POOLED, NOT AVERAGED PER RUN: a run touching one file would otherwise weigh
574
+ * the same as a run touching forty, and the question ("how much of what we
575
+ * changed was foreseen") is about paths, not about runs.
576
+ *
577
+ * THE DIRECTION IS FIXED. Numerator: touched paths that were predicted.
578
+ * Denominator: touched paths. The converse — predicted paths that were touched
579
+ * — answers a different question (a plan that predicted work the run never did)
580
+ * and is never published under this name.
581
+ */
582
+ function diffMatchesPlanOf(runs) {
583
+ const receipts = runs.filter(r => r.hasReceipt)
584
+ const excluded = { noCommit: 0, unreadableSet: 0, incompletePrediction: 0, truncatedSet: 0 }
585
+ const qualifying = []
586
+
587
+ for (const run of receipts) {
588
+ if (!Array.isArray(run.touchedPaths)) {
589
+ // Which of the two absences this is, decided from the recorded commit
590
+ // state rather than by parsing the recorded reason string.
591
+ if (run.committed !== true) excluded.noCommit++
592
+ else excluded.unreadableSet++
593
+ continue
594
+ }
595
+ if (!Array.isArray(run.predictedPaths)) {
596
+ excluded.unreadableSet++
597
+ continue
598
+ }
599
+ if (run.pathsTruncated) {
600
+ excluded.truncatedSet++
601
+ continue
602
+ }
603
+ if (!run.predictedPathsComplete) {
604
+ excluded.incompletePrediction++
605
+ continue
606
+ }
607
+ qualifying.push(run)
608
+ }
609
+
610
+ let matched = 0
611
+ let touched = 0
612
+ for (const run of qualifying) {
613
+ const predicted = new Set(run.predictedPaths)
614
+ touched += run.touchedPaths.length
615
+ matched += run.touchedPaths.filter(p => predicted.has(p)).length
616
+ }
617
+
618
+ const excludedRuns = Object.values(excluded).reduce((a, b) => a + b, 0)
619
+
620
+ return {
621
+ // A zero denominator yields `null` with its reason, never `0` — a `0` here
622
+ // would assert that nothing the runs touched was predicted.
623
+ ...indicator(SOURCES.RECEIPT, {
624
+ value: share(matched, touched),
625
+ observedOf: touched,
626
+ notObserved: excludedRuns,
627
+ reason: qualifying.length
628
+ ? 'the qualifying runs committed no paths, so there is nothing to have predicted'
629
+ : receipts.length
630
+ ? 'no scanned run recorded both path sets complete and untruncated'
631
+ : 'no run has written a receipt'
632
+ }),
633
+ direction:
634
+ 'the share of paths the run TOUCHED that the executed plan predicted — the touched set is ' +
635
+ 'the denominator; the converse figure is a different question and is not published here',
636
+ runs: qualifying.length,
637
+ paths: { matched, touched },
638
+ excluded,
639
+ excludedRuns,
640
+ exclusionReasons: PLAN_FIDELITY_EXCLUSIONS
641
+ }
642
+ }
643
+
644
+ function planFidelityOf(runs) {
645
+ const withStatus = runs.filter(r => r.planStatus)
646
+ const statusCounts = {}
647
+ for (const run of withStatus) statusCounts[run.planStatus] = (statusCounts[run.planStatus] || 0) + 1
648
+
649
+ const withWaves = runs.filter(r => r.hasWaveActions)
650
+ const revised = withWaves.filter(r => r.replanned)
651
+
652
+ return {
653
+ planStatus: {
654
+ ...indicator(SOURCES.RECEIPT, {
655
+ value: null,
656
+ observedOf: withStatus.length,
657
+ notObserved: runs.filter(r => r.hasReceipt).length - withStatus.length,
658
+ reason: 'no receipt recorded a plan-reuse status'
659
+ }),
660
+ // A distribution, not a rate: the statuses are the `REUSE_*` vocabulary in
661
+ // lib/plan-fingerprint.mjs and are not ordered, so there is no numerator.
662
+ counts: statusCounts
663
+ },
664
+ midRunRevision: indicator(SOURCES.TRAJECTORY, {
665
+ value: share(revised.length, withWaves.length),
666
+ observedOf: withWaves.length,
667
+ notObserved: runs.length - withWaves.length,
668
+ reason: 'no scanned run recorded a wave action'
669
+ }),
670
+ // Computed from the receipt's two path sets. See the module header: the two
671
+ // figures above are about the plan, this one is about the diff, and none of
672
+ // the three may be published under another's name.
673
+ diffMatchesPlan: diffMatchesPlanOf(runs)
674
+ }
675
+ }
676
+
677
+ function reviewFindingsOf(runs, metrics) {
678
+ const files = metrics.recognized
679
+ const fromMetrics = files.reduce(
680
+ (acc, f) => ({
681
+ raised: acc.raised + f.raised,
682
+ dismissed: acc.dismissed + f.dismissed,
683
+ droppedByQuality: acc.droppedByQuality + f.droppedByQuality,
684
+ surviving: acc.surviving + f.surviving
685
+ }),
686
+ { raised: 0, dismissed: 0, droppedByQuality: 0, surviving: 0 }
687
+ )
688
+
689
+ const receipts = runs.filter(r => r.hasReceipt && r.reviewRaised !== null)
690
+ const fromReceipts = receipts.reduce(
691
+ (acc, r) => ({
692
+ raised: acc.raised + r.reviewRaised,
693
+ surviving: acc.surviving + (r.reviewSurviving || 0),
694
+ blockers: acc.blockers + (r.reviewBlockers || 0),
695
+ warnings: acc.warnings + (r.reviewWarnings || 0)
696
+ }),
697
+ { raised: 0, surviving: 0, blockers: 0, warnings: 0 }
698
+ )
699
+
700
+ return {
701
+ // Two series, never summed: different writers, different runs, and one of
702
+ // them counts a dismissal the other does not record at all.
703
+ fromMetrics: {
704
+ files: files.length,
705
+ counts: fromMetrics,
706
+ dismissalShare: indicator(SOURCES.REVIEW_METRICS, {
707
+ value: share(fromMetrics.dismissed, fromMetrics.raised),
708
+ observedOf: fromMetrics.raised,
709
+ notObserved: files.length,
710
+ reason: files.length
711
+ ? 'no finding was raised in the recognized review-metrics files'
712
+ : 'no review-metrics file was recognized'
713
+ })
714
+ },
715
+ fromReceipts: {
716
+ runs: receipts.length,
717
+ counts: fromReceipts,
718
+ // The receipt records what survived, not what was dismissed, so the share
719
+ // it supports is survival — naming it a dismissal share would assert a
720
+ // count nothing wrote.
721
+ survivalShare: indicator(SOURCES.RECEIPT, {
722
+ value: share(fromReceipts.surviving, fromReceipts.raised),
723
+ observedOf: fromReceipts.raised,
724
+ notObserved: runs.filter(r => r.hasReceipt).length - receipts.length,
725
+ reason: 'no receipt observed a raised-finding count'
726
+ })
727
+ }
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Which deterministic gate actually blocks, and how often. Derived from
733
+ * `cli-exit` events, which exist on runs that never wrote a receipt — this and
734
+ * `midRunRevision` are why the report says something useful before the receipt
735
+ * path has ever fired.
736
+ *
737
+ * A command with no recorded exits is absent rather than zero: an unexercised
738
+ * command and a command that never failed are different facts.
739
+ */
740
+ function gateExitHealthOf(runs) {
741
+ const byCommand = new Map()
742
+ let totalExits = 0
743
+ let totalNonZero = 0
744
+ for (const run of runs) {
745
+ for (const exit of run.exits) {
746
+ const entry = byCommand.get(exit.command) || { command: exit.command, exits: 0, nonZero: 0 }
747
+ entry.exits++
748
+ totalExits++
749
+ if (exit.exitCode !== 0) {
750
+ entry.nonZero++
751
+ totalNonZero++
752
+ }
753
+ byCommand.set(exit.command, entry)
754
+ }
755
+ }
756
+ const commands = [...byCommand.values()]
757
+ .map(e => ({ ...e, nonZeroShare: share(e.nonZero, e.exits) }))
758
+ .sort((a, b) => b.nonZero - a.nonZero || a.command.localeCompare(b.command))
759
+ return {
760
+ nonZeroShare: indicator(SOURCES.TRAJECTORY, {
761
+ value: share(totalNonZero, totalExits),
762
+ observedOf: totalExits,
763
+ notObserved: 0,
764
+ reason: 'no scanned run recorded a command exit'
765
+ }),
766
+ totalExits,
767
+ totalNonZero,
768
+ commands
769
+ }
770
+ }
771
+
772
+ /**
773
+ * What the outcome-eval history records, per arm.
774
+ *
775
+ * THREE RULES, and they are the whole reason this is a separate function rather
776
+ * than more entries in the groups above:
777
+ *
778
+ * 1. Its indicators are sourced from `eval-history` and from nothing else. No
779
+ * figure here counts a real run, and no figure above counts a fixture
780
+ * result. The two are never summed.
781
+ * 2. Every figure carries its own denominator, and a zero denominator yields
782
+ * `null` with a reason rather than a `0`.
783
+ * 3. There is no verdict. A pass share here is a count over a stated whole; it
784
+ * is not a grade, and the thinness of the record is stated beside it
785
+ * because one run per fixture per arm licenses nothing on its own.
786
+ *
787
+ * A row missing its model or its eval-agent identity is counted `incomparable`
788
+ * and excluded: without the instrument, a row cannot be paired with another for
789
+ * a version comparison, and pairing it anyway is how an apparatus change gets
790
+ * read as a product change.
791
+ */
792
+ function shipOutcomeEvalOf(history) {
793
+ const base = {
794
+ excluded: Boolean(history.excluded),
795
+ reason: history.excluded ? history.reason : null,
796
+ records: history.records.length,
797
+ byArm: {},
798
+ fixtures: [],
799
+ versions: {},
800
+ incomparable: 0,
801
+ note:
802
+ 'fixture results, never real runs — no figure here is summed with another corpus, and one ' +
803
+ 'run per fixture per arm licenses no conclusion on its own'
804
+ }
805
+ if (history.excluded) return base
806
+
807
+ const fixtures = new Set()
808
+ const byArm = {}
809
+ for (const record of history.records) {
810
+ const arm = typeof record.arm === 'string' ? record.arm : 'unknown'
811
+ if (typeof record.fixture === 'string') fixtures.add(record.fixture)
812
+ if (typeof record.version === 'string') {
813
+ base.versions[record.version] = (base.versions[record.version] || 0) + 1
814
+ }
815
+ // The instrument, or its absence. A row that cannot say which model and
816
+ // which agent produced it is not comparable across versions, and says so.
817
+ const comparable =
818
+ typeof record.model === 'string' &&
819
+ record.model &&
820
+ record.model !== 'unknown' &&
821
+ typeof record.evalAgent === 'string' &&
822
+ record.evalAgent &&
823
+ record.evalAgent !== 'unknown'
824
+ if (!comparable) base.incomparable++
825
+
826
+ const entry = (byArm[arm] = byArm[arm] || { rows: 0, met: 0, applicable: 0, notApplicable: 0, unobserved: 0 })
827
+ entry.rows++
828
+ const tally = record.tally && typeof record.tally === 'object' ? record.tally : {}
829
+ entry.met += observedCount(tally.met) || 0
830
+ entry.applicable += observedCount(tally.applicable) || 0
831
+ entry.notApplicable += observedCount(tally.notApplicable) || 0
832
+ entry.unobserved += observedCount(tally.unobserved) || 0
833
+ }
834
+
835
+ for (const [arm, entry] of Object.entries(byArm)) {
836
+ base.byArm[arm] = {
837
+ rows: entry.rows,
838
+ criteriaMet: indicator(SOURCES.EVAL_HISTORY, {
839
+ value: share(entry.met, entry.applicable),
840
+ observedOf: entry.applicable,
841
+ notObserved: entry.notApplicable,
842
+ reason: entry.rows
843
+ ? 'no recorded result carried an applicable criterion'
844
+ : 'no result has been recorded for this arm'
845
+ }),
846
+ // Kept beside the share rather than folded into it: a criterion that could
847
+ // not be decided is not a criterion that failed, and a criterion the arm
848
+ // cannot have is neither.
849
+ notApplicable: entry.notApplicable,
850
+ unobserved: entry.unobserved
851
+ }
852
+ }
853
+ base.fixtures = [...fixtures].sort()
854
+ return base
855
+ }
856
+
857
+ // ---------------------------------------------------------------------------
858
+ // Report
859
+ // ---------------------------------------------------------------------------
860
+
861
+ /**
862
+ * Compute every indicator the corpora support. Never throws, writes nothing,
863
+ * and produces no verdict.
864
+ *
865
+ * @param {string} [root] repository root
866
+ * @param {{since?: string, change?: string, maxRuns?: number}} [opts]
867
+ * `since` is an ISO 8601 instant; a run is in the window when its earliest
868
+ * record is at or after it. `maxRuns` overrides the published scan cap.
869
+ * @returns {object} report
870
+ */
871
+ export function buildReport(root = '.', opts = {}) {
872
+ const since = typeof opts.since === 'string' && opts.since.trim() ? opts.since.trim() : null
873
+ const change = typeof opts.change === 'string' && opts.change.trim() ? opts.change.trim() : null
874
+ const requested = Number(opts.maxRuns)
875
+ const maxRuns =
876
+ Number.isFinite(requested) && requested > 0
877
+ ? Math.min(Math.trunc(requested), REPORT_CAPS.maxRunsScanned)
878
+ : REPORT_CAPS.maxRunsScanned
879
+
880
+ const filter = { since, change, maxRuns }
881
+
882
+ try {
883
+ const trajectories = digestTrajectories(root, filter)
884
+ const outcomes = digestOutcomes(root, filter)
885
+ const metrics = digestMetrics(root, filter)
886
+ const evalHistory = digestEvalHistory(root, filter)
887
+ const runs = trajectories.runs
888
+
889
+ return {
890
+ schema: REPORT_SCHEMA,
891
+ root,
892
+ filter,
893
+ coverage: coverageOf(trajectories, outcomes, metrics, evalHistory),
894
+ indicators: {
895
+ firstPassShip: firstPassShipOf(runs),
896
+ rework: reworkOf(runs),
897
+ planFidelity: planFidelityOf(runs),
898
+ reviewFindings: reviewFindingsOf(runs, metrics),
899
+ gateExitHealth: gateExitHealthOf(runs),
900
+ // Last, and in its own group: these are fixture results, and everything
901
+ // above is real work. Nothing crosses between the two.
902
+ shipOutcomeEval: shipOutcomeEvalOf(evalHistory)
903
+ },
904
+ // Restated on the payload, not only in the header, because the payload is
905
+ // what a reader who never opens this file will see.
906
+ gates: false,
907
+ note: 'reports only — nothing in the loop reads this output, and no value is compared against a threshold (§4.15a)'
908
+ }
909
+ } catch (err) {
910
+ // A reader that cannot read is still a reader that reports. Property 1.
911
+ return {
912
+ schema: REPORT_SCHEMA,
913
+ root,
914
+ filter,
915
+ coverage: null,
916
+ indicators: null,
917
+ gates: false,
918
+ reason: messageOf(err),
919
+ note: 'the report could not be computed; nothing is asserted about the corpora'
920
+ }
921
+ }
922
+ }
923
+
924
+ // ---------------------------------------------------------------------------
925
+ // Formatting
926
+ // ---------------------------------------------------------------------------
927
+
928
+ function pct(ind) {
929
+ if (!ind || ind.value === null) return `unobserved (${ind ? ind.reason : 'no indicator'})`
930
+ return `${(ind.value * 100).toFixed(1)}% of ${ind.observedOf} observed` +
931
+ (ind.notObserved ? `, ${ind.notObserved} not observed` : '')
932
+ }
933
+
934
+ function plain(ind, unit = '') {
935
+ if (!ind || ind.value === null) return `unobserved (${ind ? ind.reason : 'no indicator'})`
936
+ return `${ind.value}${unit} of ${ind.observedOf} observed` +
937
+ (ind.notObserved ? `, ${ind.notObserved} not observed` : '')
938
+ }
939
+
940
+ /** Human-readable report. Coverage first, per design.md D3. */
941
+ export function formatReport(report) {
942
+ const lines = []
943
+ lines.push('INTERLOCK REPORT — indicators over the recorded corpora. Gates nothing (§4.15a).')
944
+
945
+ if (report.filter && (report.filter.since || report.filter.change)) {
946
+ const bits = []
947
+ if (report.filter.since) bits.push(`since ${report.filter.since}`)
948
+ if (report.filter.change) bits.push(`change ${report.filter.change}`)
949
+ lines.push(` filter: ${bits.join(', ')}`)
950
+ }
951
+
952
+ if (!report.coverage) {
953
+ lines.push(` the report could not be computed: ${report.reason || 'unknown'}`)
954
+ return lines.join('\n') + '\n'
955
+ }
956
+
957
+ const { trajectories: t, outcomes: o, metrics: m, evalHistory: e } = report.coverage
958
+
959
+ lines.push('')
960
+ lines.push('COVERAGE — what the corpora can and cannot answer')
961
+ lines.push(
962
+ ` trajectories ${t.scanned} scanned of ${t.total} on disk` +
963
+ (t.truncated ? ` (scan capped; ${t.notScanned} not read)` : '') +
964
+ (t.filteredOut ? ` (${t.filteredOut} outside the filter)` : '')
965
+ )
966
+ lines.push(` with run-start ${t.withRunStart}`)
967
+ lines.push(` with terminal ${t.withTerminal} (run-complete or run-halt)`)
968
+ lines.push(` with receipt ${t.withReceipt} — every receipt-derived indicator below rests on this`)
969
+ if (t.skippedLines) lines.push(` torn lines ${t.skippedLines} skipped`)
970
+ for (const u of t.unreadable) lines.push(` unreadable ${u.runId}: ${u.reason}`)
971
+ if (t.reason) lines.push(` directory ${t.reason}`)
972
+
973
+ lines.push(
974
+ ` outcome corpus ${o.exists ? `${o.records} record(s)` : 'absent'}` +
975
+ (o.exists && Object.keys(o.byMode).length
976
+ ? `: ${Object.keys(o.byMode).sort().map(k => `${o.byMode[k]} ${k}`).join(', ')}`
977
+ : '') +
978
+ (o.filteredOut ? ` (${o.filteredOut} outside the filter)` : '')
979
+ )
980
+ if (!o.exists) lines.push(` ${o.path} does not exist — no ship run has recorded an outcome`)
981
+ if (o.skippedLines) lines.push(` ${o.skippedLines} unreadable line(s) skipped`)
982
+
983
+ lines.push(
984
+ ` review metrics ${m.recognized} recognized of ${m.total} file(s)` +
985
+ (m.filteredOut ? ` (${m.filteredOut} outside the filter)` : '')
986
+ )
987
+ for (const u of m.unrecognized) {
988
+ lines.push(` not counted ${u.file}${u.schema ? ` (schema ${u.schema})` : ' (no schema key)'}`)
989
+ }
990
+ for (const u of m.unreadable) lines.push(` unreadable ${u.file}: ${u.reason}`)
991
+
992
+ // The fourth corpus, and it says out loud when a filter has removed it rather
993
+ // than reporting an empty one.
994
+ if (e) {
995
+ lines.push(
996
+ ` outcome evals ` +
997
+ (e.excluded
998
+ ? 'excluded from this view'
999
+ : e.exists
1000
+ ? `${e.records} result(s)`
1001
+ : 'absent') +
1002
+ (!e.excluded && e.filteredOut ? ` (${e.filteredOut} outside the filter)` : '')
1003
+ )
1004
+ if (e.excluded) lines.push(` ${e.reason}`)
1005
+ else if (!e.exists) lines.push(` ${e.path} does not exist — no outcome eval has recorded a result`)
1006
+ if (e.reason && !e.excluded) lines.push(` unreadable ${e.reason}`)
1007
+ if (e.skippedLines) lines.push(` ${e.skippedLines} unreadable line(s) skipped`)
1008
+ for (const u of e.unrecognized || []) {
1009
+ lines.push(` not counted line ${u.line}${u.schema ? ` (schema ${u.schema})` : ' (no schema key)'}`)
1010
+ }
1011
+ }
1012
+
1013
+ const i = report.indicators
1014
+ lines.push('')
1015
+ lines.push('INDICATORS')
1016
+
1017
+ lines.push(` first-pass ship ${pct(i.firstPassShip.rate)}`)
1018
+ lines.push(` ${i.firstPassShip.note}`)
1019
+
1020
+ lines.push(` remediation rounds/run ${plain(i.rework.remediationRounds)}`)
1021
+ const dist = Object.keys(i.rework.remediationRounds.distribution).sort()
1022
+ if (dist.length) {
1023
+ lines.push(` distribution: ${dist.map(k => `${k}→${i.rework.remediationRounds.distribution[k]}`).join(' ')}`)
1024
+ }
1025
+ lines.push(` runs per change ${plain(i.rework.attemptsPerChange)}`)
1026
+ if (i.rework.attemptsPerChange.unattributed) {
1027
+ lines.push(` ${i.rework.attemptsPerChange.unattributed} run(s) attributed to "${UNATTRIBUTED}" and excluded`)
1028
+ }
1029
+ for (const c of i.rework.attemptsPerChange.changes) {
1030
+ lines.push(` ${c.change}: ${c.runs} run(s)`)
1031
+ }
1032
+
1033
+ const statuses = Object.keys(i.planFidelity.planStatus.counts).sort()
1034
+ lines.push(
1035
+ ` plan-reuse status ${statuses.length ? statuses.map(s => `${s}→${i.planFidelity.planStatus.counts[s]}`).join(' ') : `unobserved (${i.planFidelity.planStatus.reason})`}`
1036
+ )
1037
+ lines.push(` plan revised mid-run ${pct(i.planFidelity.midRunRevision)}`)
1038
+ const dmp = i.planFidelity.diffMatchesPlan
1039
+ lines.push(` diff matches plan ${pct(dmp)}`)
1040
+ lines.push(` ${dmp.direction}`)
1041
+ if (dmp.value !== null) {
1042
+ lines.push(
1043
+ ` ${dmp.paths.matched} of ${dmp.paths.touched} touched path(s) ` +
1044
+ `across ${dmp.runs} qualifying run(s)`
1045
+ )
1046
+ }
1047
+ // Named and counted, never absorbed: the exclusions are how a reader tells a
1048
+ // thin denominator from a fair one, and which recording gap to close first.
1049
+ for (const key of Object.keys(dmp.excluded)) {
1050
+ if (!dmp.excluded[key]) continue
1051
+ lines.push(` excluded ${dmp.excluded[key]} run(s): ${dmp.exclusionReasons[key]}`)
1052
+ }
1053
+
1054
+ const fm = i.reviewFindings.fromMetrics
1055
+ lines.push(
1056
+ ` review findings from ${fm.files} metrics file(s): ` +
1057
+ `${fm.counts.raised} raised, ${fm.counts.dismissed} dismissed, ` +
1058
+ `${fm.counts.droppedByQuality} dropped by quality, ${fm.counts.surviving} surviving`
1059
+ )
1060
+ lines.push(` dismissal share: ${pct(fm.dismissalShare)}`)
1061
+ const fr = i.reviewFindings.fromReceipts
1062
+ lines.push(
1063
+ ` from ${fr.runs} receipt(s): ` +
1064
+ `${fr.counts.raised} raised, ${fr.counts.surviving} surviving, ` +
1065
+ `${fr.counts.blockers} blocker(s), ${fr.counts.warnings} warning(s)`
1066
+ )
1067
+ lines.push(` survival share: ${pct(fr.survivalShare)}`)
1068
+ lines.push(' the two series are never summed — different writers, different runs')
1069
+
1070
+ const g = i.gateExitHealth
1071
+ lines.push(` gate exits non-zero ${pct(g.nonZeroShare)}`)
1072
+ for (const c of g.commands) {
1073
+ lines.push(` ${String(c.command).padEnd(26)} ${c.nonZero} of ${c.exits} non-zero`)
1074
+ }
1075
+
1076
+ // Its own block, after a rule, because everything above is real work and this
1077
+ // is fixtures. A reader who skims must not carry a fixture number away as a
1078
+ // fact about the loop's real runs.
1079
+ const s = i.shipOutcomeEval
1080
+ if (s) {
1081
+ lines.push('')
1082
+ lines.push(' OUTCOME EVALS (fixtures, never real runs — never summed with anything above)')
1083
+ if (s.excluded) {
1084
+ lines.push(` excluded: ${s.reason}`)
1085
+ } else if (!s.records) {
1086
+ // Worded exactly as the HTML surface words it, from one constant: the two
1087
+ // must not describe the same absence differently.
1088
+ lines.push(` unobserved (${NO_EVAL_RESULT})`)
1089
+ } else {
1090
+ for (const arm of Object.keys(s.byArm).sort()) {
1091
+ const a = s.byArm[arm]
1092
+ lines.push(` ${arm.padEnd(10)} ${a.rows} result(s), criteria met ${pct(a.criteriaMet)}`)
1093
+ if (a.notApplicable) {
1094
+ lines.push(` ${a.notApplicable} criterion/criteria not applicable to this arm, excluded`)
1095
+ }
1096
+ if (a.unobserved) lines.push(` ${a.unobserved} unobserved, counted as not met`)
1097
+ }
1098
+ const versions = Object.keys(s.versions).sort()
1099
+ if (versions.length) {
1100
+ lines.push(` versions ${versions.map(v => `${v}→${s.versions[v]}`).join(' ')}`)
1101
+ }
1102
+ if (s.fixtures.length) lines.push(` fixtures ${s.fixtures.join(', ')}`)
1103
+ if (s.incomparable) {
1104
+ lines.push(
1105
+ ` ${s.incomparable} row(s) carry no model or eval-agent identity and are not paired ` +
1106
+ `across versions`
1107
+ )
1108
+ }
1109
+ lines.push(` ${s.note}`)
1110
+ }
1111
+ }
1112
+
1113
+ lines.push('')
1114
+ lines.push(' Nothing above is a verdict, and nothing in the loop reads it. Exit status is always 0.')
1115
+ return lines.join('\n') + '\n'
1116
+ }
1117
+
1118
+ /** Re-exported so a caller need not import three modules to know where to look. */
1119
+ export const CORPORA = Object.freeze({
1120
+ outcomes: join(LEARNING_DIR, OUTCOMES_FILE),
1121
+ trajectories: RUN_LOG_DIR,
1122
+ metrics: METRICS_DIR,
1123
+ evalHistory: join(EVAL_HISTORY_DIR, SHIP_OUTCOMES_FILE)
1124
+ })