@renzrollon/interlock 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
@@ -0,0 +1,623 @@
1
+ // The third renderer.
2
+ //
3
+ // `formatReport` renders the report object to text and `--json` emits it
4
+ // directly; this renders the same object to one self-contained HTML document.
5
+ // It is a pure function of that object — no filesystem, no corpus read, no
6
+ // indicator computed, nothing re-derived. That is not tidiness: it is what
7
+ // makes "the three surfaces cannot disagree" a test rather than an aspiration,
8
+ // since one object can be rendered to all three and the values compared.
9
+ //
10
+ // Three constraints run through every line below, and each one is a
11
+ // requirement rather than a preference:
12
+ //
13
+ // 1. **Absence renders at full weight.** An unobserved indicator gets the same
14
+ // type, size and position as one carrying a number, and its reason is the
15
+ // cell's content. Every dashboard idiom — the grey cell, the dash, the zero,
16
+ // the collapsed row — translates "we never measured this" into "this
17
+ // measured zero". On these corpora that mistranslation would be near-total.
18
+ // 2. **The document issues no verdict.** No threshold, target, goal line, trend
19
+ // arrow, pass/fail label, or colour that encodes health. Colour and weight
20
+ // carry structure and reading order only, so two reports with wildly
21
+ // different figures are indistinguishable in palette.
22
+ // 3. **Everything is inline.** No stylesheet, script, font, image or data
23
+ // source is fetched, so the document renders with no network and can be
24
+ // attached to an issue or handed to someone without the repo.
25
+ //
26
+ // Corpus content is untrusted input: a metrics file name and an unreadable
27
+ // file's reason both come off disk. Every interpolated value therefore passes
28
+ // through the `html` tagged template below, which escapes unless a fragment is
29
+ // explicitly marked `raw`. There is no second interpolation path.
30
+
31
+ // The one thing this renderer imports: the wording of an absence it shares with
32
+ // the text surface. Two surfaces describing the same absence in different words
33
+ // is how a reader comes to believe they are looking at two different facts.
34
+ import { NO_EVAL_RESULT } from './report.mjs'
35
+
36
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
37
+
38
+ const RAW = Symbol('raw html')
39
+
40
+ /**
41
+ * Mark an already-rendered fragment so the template does not escape it again.
42
+ *
43
+ * It carries a `toString` so that concatenating a marked fragment with `+`
44
+ * yields its markup rather than `[object Object]`. Without it the two ways of
45
+ * assembling a fragment — interpolation and concatenation — would disagree, and
46
+ * the failure mode is a silently mangled document rather than an error.
47
+ */
48
+ function raw(value) {
49
+ const markup = String(value)
50
+ return { [RAW]: markup, toString: () => markup }
51
+ }
52
+
53
+ /** The one escape. Everything interpolated reaches the document through here. */
54
+ function escape(value) {
55
+ if (value === null || value === undefined) return ''
56
+ if (Array.isArray(value)) return value.map(escape).join('')
57
+ if (typeof value === 'object' && RAW in value) return value[RAW]
58
+ return String(value).replace(/[&<>"']/g, c => ESCAPES[c])
59
+ }
60
+
61
+ /** Tagged template that escapes every interpolation. `raw()` opts a fragment out. */
62
+ function html(strings, ...values) {
63
+ let out = strings[0]
64
+ for (let i = 0; i < values.length; i += 1) out += escape(values[i]) + strings[i + 1]
65
+ return out
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Readings
70
+ // ---------------------------------------------------------------------------
71
+
72
+ // A reading is a headline and a basis. The headline is a numeral or one of two
73
+ // words; the basis is either the denominator or the reason there is none. The
74
+ // two absence words are typed exactly like a numeral — same face, same size,
75
+ // same weight — which is requirement 1 above expressed in markup.
76
+
77
+ const UNOBSERVED = 'UNOBSERVED'
78
+ const NOT_COMPUTABLE = 'NOT COMPUTABLE'
79
+
80
+ function basisOf(ind) {
81
+ return `of ${ind.observedOf} observed` + (ind.notObserved ? `, ${ind.notObserved} not observed` : '')
82
+ }
83
+
84
+ /** A share, formatted exactly as the text surface formats it. */
85
+ function pctReading(ind) {
86
+ if (!ind || ind.value === null) {
87
+ return { headline: UNOBSERVED, basis: ind ? ind.reason : 'no indicator' }
88
+ }
89
+ return { headline: `${(ind.value * 100).toFixed(1)}%`, basis: basisOf(ind) }
90
+ }
91
+
92
+ /** A bare number, formatted exactly as the text surface formats it. */
93
+ function plainReading(ind) {
94
+ if (!ind || ind.value === null) {
95
+ return { headline: UNOBSERVED, basis: ind ? ind.reason : 'no indicator' }
96
+ }
97
+ return { headline: String(ind.value), basis: basisOf(ind) }
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Marks
102
+ // ---------------------------------------------------------------------------
103
+
104
+ const MARK_WIDTH = 260
105
+ const MARK_HEIGHT = 9
106
+
107
+ /**
108
+ * A part-of-whole mark for a coverage proportion. The denominator is printed
109
+ * beside it in every case, so the bar is read against a stated whole rather
110
+ * than against an implied target — and the track is drawn at the same weight
111
+ * whatever the fill, so a low proportion is not styled as a deficiency.
112
+ */
113
+ function proportionMark(numerator, denominator) {
114
+ const n = Number(numerator) || 0
115
+ const d = Number(denominator) || 0
116
+ const width = d > 0 ? Math.max(0, Math.min(1, n / d)) * MARK_WIDTH : 0
117
+ return raw(
118
+ html`<svg class="mark" width="${MARK_WIDTH}" height="${MARK_HEIGHT}" viewBox="0 0 ${MARK_WIDTH} ${MARK_HEIGHT}" role="presentation" aria-hidden="true">` +
119
+ html`<rect x="0" y="0" width="${MARK_WIDTH}" height="${MARK_HEIGHT}" class="track"/>` +
120
+ (width > 0 ? html`<rect x="0" y="0" width="${width}" height="${MARK_HEIGHT}" class="fill"/>` : '') +
121
+ '</svg>'
122
+ )
123
+ }
124
+
125
+ /**
126
+ * The gate-exit distribution: one row per command, two bars on ONE shared count
127
+ * scale from a common origin, so a bar's length reads as a count and never as
128
+ * progress toward a full width. The scale is the largest count present; no
129
+ * axis, no threshold line, no marker.
130
+ */
131
+ function exitDistribution(commands) {
132
+ const rows = commands.filter(c => c && Number(c.exits) >= 0)
133
+ if (!rows.length) return ''
134
+ const scale = rows.reduce((max, c) => Math.max(max, Number(c.exits) || 0), 0)
135
+ if (!scale) return ''
136
+
137
+ const barWidth = count => (Math.max(0, Number(count) || 0) / scale) * MARK_WIDTH
138
+ const body = rows
139
+ .map(
140
+ c =>
141
+ html`<div class="dist-row"><div class="dist-name">${c.command}</div>` +
142
+ html`<div class="dist-marks">` +
143
+ html`<svg class="mark" width="${MARK_WIDTH}" height="20" viewBox="0 0 ${MARK_WIDTH} 20" role="presentation" aria-hidden="true">` +
144
+ html`<rect x="0" y="0" width="${barWidth(c.exits)}" height="8" class="fill-soft"/>` +
145
+ html`<rect x="0" y="11" width="${barWidth(c.nonZero)}" height="8" class="fill"/>` +
146
+ html`</svg></div>` +
147
+ html`<div class="dist-figures"><span>${c.nonZero}</span> of <span>${c.exits}</span> non-zero</div></div>`
148
+ )
149
+ .join('')
150
+
151
+ return (
152
+ html`<div class="dist"><div class="dist-legend">bars share one count scale, common origin — upper: exits recorded, lower: exits non-zero. Largest count drawn is ${scale}.</div>` +
153
+ body +
154
+ '</div>'
155
+ )
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Sections
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /**
163
+ * The fourth corpus's coverage block.
164
+ *
165
+ * Rendered even when it is excluded or absent, and it says which: a corpus that
166
+ * silently vanished from a filtered page would be indistinguishable from one
167
+ * holding nothing.
168
+ */
169
+ function evalHistoryCorpus(e) {
170
+ if (!e) return ''
171
+ const count = e.excluded
172
+ ? raw('<span class="word">EXCLUDED</span>')
173
+ : e.exists
174
+ ? raw(html`<span class="num">${e.records}</span> result(s)`)
175
+ : raw('<span class="word">ABSENT</span>')
176
+
177
+ const notes = []
178
+ if (e.excluded && e.reason) notes.push(e.reason)
179
+ if (!e.excluded && !e.exists) notes.push(`${e.path} does not exist — no outcome eval has recorded a result`)
180
+ if (!e.excluded && e.reason) notes.push(`unreadable — ${e.reason}`)
181
+ if (e.filteredOut) notes.push(`${e.filteredOut} outside the filter`)
182
+ if (e.skippedLines) notes.push(`${e.skippedLines} unreadable line(s) skipped`)
183
+ for (const u of e.unrecognized || []) {
184
+ notes.push(`line ${u.line} not counted${u.schema ? ` (schema ${u.schema})` : ' (no schema key)'}`)
185
+ }
186
+
187
+ return (
188
+ html`<div class="corpus"><div class="corpus-head"><h3>Outcome-eval results</h3>
189
+ <div class="corpus-count">${count}</div></div>
190
+ <p class="reason">Fixture runs, not real work. Nothing here is counted in any figure above.</p>` +
191
+ (notes.length
192
+ ? html`<ul class="named">${raw(notes.map(n => html`<li>${n}</li>`).join(''))}</ul>`
193
+ : '') +
194
+ '</div>'
195
+ )
196
+ }
197
+
198
+ function coverageSection(coverage) {
199
+ const { trajectories: t, outcomes: o, metrics: m } = coverage
200
+
201
+ const trajectoryNotes = []
202
+ if (t.truncated) trajectoryNotes.push(`scan capped — ${t.notScanned} not read`)
203
+ if (t.filteredOut) trajectoryNotes.push(`${t.filteredOut} outside the filter`)
204
+ if (t.skippedLines) trajectoryNotes.push(`${t.skippedLines} torn line(s) skipped`)
205
+ if (t.reason) trajectoryNotes.push(t.reason)
206
+
207
+ const outcomeModes = Object.keys(o.byMode || {}).sort()
208
+
209
+ return html`<section class="block"><h2>Coverage — what the corpora can and cannot answer</h2>
210
+ <p class="lede">Every figure in the next section rests on what is scanned here. A corpus that holds
211
+ nothing is reported as holding nothing; it is never reported as a zero.</p>` +
212
+ // --- trajectories
213
+ html`<div class="corpus"><div class="corpus-head"><h3>Run trajectories</h3>
214
+ <div class="corpus-count"><span class="num">${t.scanned}</span> scanned of <span class="num">${t.total}</span> on disk</div></div>
215
+ ${proportionMark(t.scanned, t.total)}
216
+ <dl class="facets">
217
+ <div><dt>with run-start</dt><dd class="num">${t.withRunStart}</dd></div>
218
+ <div><dt>with terminal</dt><dd class="num">${t.withTerminal}</dd><dd class="aside">run-complete or run-halt</dd></div>
219
+ <div><dt>with receipt</dt><dd class="num">${t.withReceipt}</dd><dd class="aside">every receipt-derived indicator rests on this</dd></div>
220
+ </dl>` +
221
+ (trajectoryNotes.length
222
+ ? html`<ul class="named">${raw(trajectoryNotes.map(n => html`<li>${n}</li>`).join(''))}</ul>`
223
+ : '') +
224
+ (t.unreadable && t.unreadable.length
225
+ ? html`<div class="named-head">Unreadable, named rather than counted</div><ul class="named">` +
226
+ raw(t.unreadable.map(u => html`<li><span class="file">${u.runId}</span> — ${u.reason}</li>`).join('')) +
227
+ '</ul>'
228
+ : '') +
229
+ '</div>' +
230
+ // --- outcomes
231
+ html`<div class="corpus"><div class="corpus-head"><h3>Outcome records</h3>
232
+ <div class="corpus-count">${o.exists ? raw(html`<span class="num">${o.records}</span> record(s)`) : raw('<span class="word">ABSENT</span>')}</div></div>` +
233
+ (o.exists
234
+ ? html`<dl class="facets">${raw(
235
+ outcomeModes.map(k => html`<div><dt>${k}</dt><dd class="num">${o.byMode[k]}</dd></div>`).join('')
236
+ )}</dl>`
237
+ : html`<p class="reason">${o.path} does not exist — no ship run has recorded an outcome</p>`) +
238
+ (o.filteredOut ? html`<ul class="named"><li>${o.filteredOut} outside the filter</li></ul>` : '') +
239
+ (o.skippedLines ? html`<ul class="named"><li>${o.skippedLines} unreadable line(s) skipped</li></ul>` : '') +
240
+ '</div>' +
241
+ // --- review metrics
242
+ html`<div class="corpus"><div class="corpus-head"><h3>Review metrics</h3>
243
+ <div class="corpus-count"><span class="num">${m.recognized}</span> recognized of <span class="num">${m.total}</span> file(s)</div></div>
244
+ ${proportionMark(m.recognized, m.total)}` +
245
+ (m.filteredOut ? html`<ul class="named"><li>${m.filteredOut} outside the filter</li></ul>` : '') +
246
+ (m.unrecognized && m.unrecognized.length
247
+ ? html`<div class="named-head">Not counted, named rather than summarised</div><ul class="named">` +
248
+ raw(
249
+ m.unrecognized
250
+ .map(
251
+ u =>
252
+ html`<li><span class="file">${u.file}</span> — ${
253
+ u.schema ? `schema ${u.schema}` : 'no schema key'
254
+ }</li>`
255
+ )
256
+ .join('')
257
+ ) +
258
+ '</ul>'
259
+ : '') +
260
+ (m.unreadable && m.unreadable.length
261
+ ? html`<div class="named-head">Unreadable, named rather than counted</div><ul class="named">` +
262
+ raw(m.unreadable.map(u => html`<li><span class="file">${u.file}</span> — ${u.reason}</li>`).join('')) +
263
+ '</ul>'
264
+ : '') +
265
+ '</div>' +
266
+ evalHistoryCorpus(coverage.evalHistory) +
267
+ '</section>'
268
+ }
269
+
270
+ /** One indicator row. `detail` is pre-rendered markup or ''. */
271
+ function row(no, name, definition, reading, detail) {
272
+ return html`<div class="ind"><div class="ind-no">${no}</div>
273
+ <div class="ind-name"><div class="ind-title">${name}</div><div class="ind-def">${definition}</div></div>
274
+ <div class="ind-reading"><div class="ind-headline">${reading.headline}</div><div class="ind-basis">${reading.basis}</div></div>
275
+ <div class="ind-detail">${raw(detail || '')}</div></div>`
276
+ }
277
+
278
+ function breakdown(entries) {
279
+ if (!entries.length) return ''
280
+ return html`<div class="breakdown">${raw(
281
+ entries.map(e => html`<div><span class="bk-label">${e.label}</span><span class="bk-value num">${e.value}</span></div>`).join('')
282
+ )}</div>`
283
+ }
284
+
285
+ function indicatorsSection(indicators) {
286
+ const i = indicators
287
+ const rows = []
288
+
289
+ rows.push(
290
+ row(
291
+ '01',
292
+ 'First-pass ship',
293
+ 'Runs that reached a receipt with no halt and no remediation round, over the runs whose receipt observed both.',
294
+ pctReading(i.firstPassShip.rate),
295
+ html`<p class="finding">${i.firstPassShip.note}</p>`
296
+ )
297
+ )
298
+
299
+ const dist = Object.keys(i.rework.remediationRounds.distribution || {}).sort()
300
+ rows.push(
301
+ row(
302
+ '02',
303
+ 'Remediation rounds per run',
304
+ 'The mean number of remediation rounds a run recorded on its receipt.',
305
+ plainReading(i.rework.remediationRounds),
306
+ dist.length
307
+ ? breakdown(dist.map(k => ({ label: `${k} round(s)`, value: i.rework.remediationRounds.distribution[k] })))
308
+ : ''
309
+ )
310
+ )
311
+
312
+ const changes = i.rework.attemptsPerChange.changes || []
313
+ rows.push(
314
+ row(
315
+ '03',
316
+ 'Runs per change',
317
+ 'How many runs each named change took. Runs the corpus could not attribute are excluded and reported separately.',
318
+ plainReading(i.rework.attemptsPerChange),
319
+ (i.rework.attemptsPerChange.unattributed
320
+ ? html`<p class="finding">${i.rework.attemptsPerChange.unattributed} run(s) unattributed and excluded from this figure.</p>`
321
+ : '') + breakdown(changes.map(c => ({ label: c.change, value: `${c.runs} run(s)` })))
322
+ )
323
+ )
324
+
325
+ const statuses = Object.keys(i.planFidelity.planStatus.counts || {}).sort()
326
+ rows.push(
327
+ row(
328
+ '04',
329
+ 'Plan-reuse status',
330
+ 'Whether a run reused a stored wave plan, over the runs that recorded a status.',
331
+ statuses.length
332
+ ? { headline: String(statuses.reduce((n, s) => n + i.planFidelity.planStatus.counts[s], 0)), basis: 'run(s) recorded a status' }
333
+ : { headline: UNOBSERVED, basis: i.planFidelity.planStatus.reason },
334
+ statuses.length
335
+ ? breakdown(statuses.map(s => ({ label: s, value: i.planFidelity.planStatus.counts[s] })))
336
+ : ''
337
+ )
338
+ )
339
+
340
+ rows.push(
341
+ row(
342
+ '05',
343
+ 'Plan revised mid-run',
344
+ 'Runs that recorded a wave action changing the plan after the run began.',
345
+ pctReading(i.planFidelity.midRunRevision),
346
+ ''
347
+ )
348
+ )
349
+
350
+ rows.push(
351
+ row(
352
+ '06',
353
+ 'Committed diff matches plan',
354
+ 'The share of paths a run committed that its executed plan predicted. The touched set is the denominator.',
355
+ pctReading(i.planFidelity.diffMatchesPlan),
356
+ breakdown([
357
+ { label: 'qualifying run(s)', value: i.planFidelity.diffMatchesPlan.runs },
358
+ { label: 'touched path(s) predicted', value: i.planFidelity.diffMatchesPlan.paths.matched },
359
+ { label: 'touched path(s)', value: i.planFidelity.diffMatchesPlan.paths.touched },
360
+ // Every exclusion is shown, zero included: a reason absent from the list
361
+ // and a reason that excluded nobody would otherwise read the same.
362
+ ...Object.keys(i.planFidelity.diffMatchesPlan.excluded).map(key => ({
363
+ label: `excluded — ${i.planFidelity.diffMatchesPlan.exclusionReasons[key]}`,
364
+ value: i.planFidelity.diffMatchesPlan.excluded[key]
365
+ }))
366
+ ])
367
+ )
368
+ )
369
+
370
+ const fm = i.reviewFindings.fromMetrics
371
+ rows.push(
372
+ row(
373
+ '07',
374
+ 'Findings dismissed at review',
375
+ 'Findings the skeptics verified away, over the findings raised — from the recognized review-metrics files.',
376
+ pctReading(fm.dismissalShare),
377
+ breakdown([
378
+ { label: 'metrics file(s) read', value: fm.files },
379
+ { label: 'raised', value: fm.counts.raised },
380
+ { label: 'dismissed', value: fm.counts.dismissed },
381
+ { label: 'dropped by quality', value: fm.counts.droppedByQuality },
382
+ { label: 'surviving', value: fm.counts.surviving }
383
+ ])
384
+ )
385
+ )
386
+
387
+ const fr = i.reviewFindings.fromReceipts
388
+ rows.push(
389
+ row(
390
+ '08',
391
+ 'Findings surviving to the verdict',
392
+ 'Findings that survived review, over the findings raised — from run receipts.',
393
+ pctReading(fr.survivalShare),
394
+ breakdown([
395
+ { label: 'receipt(s) read', value: fr.runs },
396
+ { label: 'raised', value: fr.counts.raised },
397
+ { label: 'surviving', value: fr.counts.surviving },
398
+ { label: 'blocker(s)', value: fr.counts.blockers },
399
+ { label: 'warning(s)', value: fr.counts.warnings }
400
+ ]) + html`<p class="finding">The two series above are never summed — different writers, different runs.</p>`
401
+ )
402
+ )
403
+
404
+ const g = i.gateExitHealth
405
+ rows.push(
406
+ row(
407
+ '09',
408
+ 'Gate exits non-zero',
409
+ 'Command exits the trajectory recorded as non-zero, over the exits it recorded.',
410
+ pctReading(g.nonZeroShare),
411
+ exitDistribution(g.commands || [])
412
+ )
413
+ )
414
+
415
+ // The outcome-eval rows come last and are numbered on from the real-run
416
+ // indicators rather than interleaved with them, so a reader cannot pick up a
417
+ // fixture figure believing it says something about real work. One row per arm,
418
+ // each with its own denominator.
419
+ const s = i.shipOutcomeEval
420
+ if (s) {
421
+ if (s.excluded) {
422
+ rows.push(
423
+ row(
424
+ '10',
425
+ 'Outcome evals (fixtures)',
426
+ 'Criteria met over criteria applicable, from the committed outcome-eval history.',
427
+ { headline: UNOBSERVED, basis: s.reason },
428
+ ''
429
+ )
430
+ )
431
+ } else {
432
+ const arms = Object.keys(s.byArm).sort()
433
+ if (!arms.length) {
434
+ rows.push(
435
+ row(
436
+ '10',
437
+ 'Outcome evals (fixtures)',
438
+ 'Criteria met over criteria applicable, from the committed outcome-eval history.',
439
+ { headline: UNOBSERVED, basis: NO_EVAL_RESULT },
440
+ ''
441
+ )
442
+ )
443
+ }
444
+ arms.forEach((arm, index) => {
445
+ const a = s.byArm[arm]
446
+ rows.push(
447
+ row(
448
+ String(10 + index).padStart(2, '0'),
449
+ `Outcome evals — ${arm} arm`,
450
+ 'Criteria met over criteria applicable, from the committed outcome-eval history. Fixture runs, never real runs.',
451
+ pctReading(a.criteriaMet),
452
+ breakdown([
453
+ { label: 'result(s) recorded', value: a.rows },
454
+ { label: 'not applicable to this arm', value: a.notApplicable },
455
+ { label: 'unobserved, counted as not met', value: a.unobserved }
456
+ ]) + html`<p class="finding">${s.note}</p>`
457
+ )
458
+ )
459
+ })
460
+ }
461
+ }
462
+
463
+ return html`<section class="block"><h2>Indicators</h2>
464
+ <div class="ind-head"><div></div><div>Indicator</div><div>Reading</div><div>Detail</div></div>
465
+ ${raw(rows.join(''))}</section>`
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Document
470
+ // ---------------------------------------------------------------------------
471
+
472
+ const STYLE = `
473
+ :root {
474
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
475
+ --sans: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
476
+ --ground: #e6eeea; --panel: #f4f9f6; --ink: #0e1c17; --ink2: #4d6a5d;
477
+ --rule: rgba(14,28,23,0.22); --hair: rgba(14,28,23,0.11); --mark: #0e7a56;
478
+ }
479
+ @media (prefers-color-scheme: dark) {
480
+ :root {
481
+ --ground: #08110e; --panel: #0c1613; --ink: #e4efe9; --ink2: #8aa89a;
482
+ --rule: rgba(228,239,233,0.20); --hair: rgba(228,239,233,0.10); --mark: #34d399;
483
+ }
484
+ }
485
+ * { box-sizing: border-box; }
486
+ body { margin: 0; background: var(--ground); color: var(--ink); font-family: var(--sans);
487
+ font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
488
+ .sheet { max-width: 1180px; margin: 0 auto; background: var(--panel); padding: 44px 52px 38px; }
489
+ .num, .word { font-family: var(--mono); font-variant-numeric: tabular-nums; }
490
+ h1 { font-size: 34px; font-weight: 500; letter-spacing: -0.02em; margin: 8px 0 0; }
491
+ h2 { font-family: var(--mono); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
492
+ color: var(--ink2); font-weight: 400; margin: 0 0 12px; }
493
+ h3 { font-size: 15px; font-weight: 500; margin: 0; }
494
+ .kicker { font-family: var(--mono); font-size: 11px; letter-spacing: 0.18em;
495
+ text-transform: uppercase; color: var(--ink2); }
496
+ .head { display: flex; justify-content: space-between; align-items: flex-start; gap: 48px;
497
+ flex-wrap: wrap; }
498
+ .meta { display: grid; grid-template-columns: auto auto; gap: 3px 18px; font-family: var(--mono);
499
+ font-size: 11px; line-height: 1.5; padding-top: 6px; }
500
+ .meta .k { color: var(--ink2); }
501
+ .rule { height: 1px; background: var(--rule); margin: 24px 0 20px; border: 0; }
502
+ .intro { display: grid; grid-template-columns: 1fr 1fr; gap: 44px; }
503
+ @media (max-width: 820px) { .intro { grid-template-columns: 1fr; } }
504
+ .intro p { margin: 0; max-width: 54ch; }
505
+ .legend { display: grid; gap: 6px; font-size: 12.5px; }
506
+ .legend > div { display: grid; grid-template-columns: 132px 1fr; gap: 14px; }
507
+ .legend .word { font-size: 11px; }
508
+ .legend span:last-child { color: var(--ink2); }
509
+ .block { margin-top: 34px; }
510
+ .lede { color: var(--ink2); max-width: 74ch; margin: 0 0 20px; }
511
+ .corpus { padding: 14px 0; border-top: 1px solid var(--hair); }
512
+ .corpus-head { display: flex; justify-content: space-between; align-items: baseline; gap: 24px; }
513
+ .corpus-count { font-family: var(--mono); font-size: 12px; color: var(--ink2);
514
+ font-variant-numeric: tabular-nums; }
515
+ .corpus-count .num { font-size: 15px; color: var(--ink); }
516
+ .facets { display: flex; flex-wrap: wrap; gap: 8px 34px; margin: 10px 0 0; }
517
+ .facets > div { display: flex; align-items: baseline; gap: 8px; }
518
+ .facets dt { font-size: 12.5px; color: var(--ink2); }
519
+ .facets dd { margin: 0; font-size: 14px; }
520
+ .facets .aside { font-size: 12px; color: var(--ink2); font-family: var(--sans); }
521
+ .named-head { font-family: var(--mono); font-size: 10px; letter-spacing: 0.13em;
522
+ text-transform: uppercase; color: var(--ink2); margin-top: 12px; }
523
+ .named { list-style: none; margin: 6px 0 0; padding: 0; font-size: 12.5px; color: var(--ink2); }
524
+ .named li { padding: 2px 0; overflow-wrap: anywhere; }
525
+ .named .file { font-family: var(--mono); font-size: 11.5px; color: var(--ink); }
526
+ .reason { margin: 8px 0 0; font-size: 13px; color: var(--ink2); max-width: 78ch; }
527
+ .mark { display: block; margin: 12px 0 2px; max-width: 100%; }
528
+ .track { fill: var(--hair); }
529
+ .fill { fill: var(--mark); }
530
+ .fill-soft { fill: var(--rule); }
531
+ .ind-head, .ind { display: grid; grid-template-columns: 30px 264px 214px 1fr; gap: 24px; }
532
+ @media (max-width: 940px) { .ind-head { display: none; } .ind { grid-template-columns: 1fr; } }
533
+ .ind-head { padding: 9px 0; font-family: var(--mono); font-size: 10px; letter-spacing: 0.13em;
534
+ text-transform: uppercase; color: var(--ink2); border-bottom: 1px solid var(--rule); }
535
+ .ind { padding: 15px 0; border-bottom: 1px solid var(--hair); }
536
+ .ind-no { font-family: var(--mono); font-size: 11px; color: var(--ink2); padding-top: 6px; }
537
+ .ind-title { font-size: 14.5px; font-weight: 500; }
538
+ .ind-def { font-size: 12.5px; color: var(--ink2); margin-top: 4px; }
539
+ .ind-headline { font-family: var(--mono); font-size: 20px; font-weight: 500; line-height: 1.2;
540
+ font-variant-numeric: tabular-nums; }
541
+ .ind-basis { font-family: var(--mono); font-size: 10.5px; color: var(--ink2); margin-top: 6px;
542
+ line-height: 1.5; overflow-wrap: anywhere; }
543
+ .finding { font-size: 13px; line-height: 1.55; max-width: 64ch; margin: 0 0 8px; }
544
+ .breakdown { display: grid; gap: 2px; }
545
+ .breakdown > div { display: grid; grid-template-columns: 1fr 96px; gap: 14px; }
546
+ .bk-label { font-size: 13px; }
547
+ .bk-value { font-size: 13px; text-align: right; }
548
+ .requires { display: grid; grid-template-columns: 104px 1fr; gap: 12px; margin-top: 9px;
549
+ font-size: 12.5px; max-width: 72ch; }
550
+ .requires-label { font-family: var(--mono); font-size: 10px; letter-spacing: 0.1em;
551
+ text-transform: uppercase; color: var(--ink2); }
552
+ .dist { display: grid; gap: 8px; }
553
+ .dist-legend { font-size: 11.5px; color: var(--ink2); max-width: 62ch; }
554
+ .dist-row { display: grid; grid-template-columns: 1fr; gap: 2px; }
555
+ .dist-name { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
556
+ .dist-figures { font-family: var(--mono); font-size: 11px; color: var(--ink2);
557
+ font-variant-numeric: tabular-nums; }
558
+ .dist-figures span { color: var(--ink); }
559
+ .foot { display: flex; justify-content: space-between; gap: 32px; flex-wrap: wrap; margin-top: 24px;
560
+ font-family: var(--mono); font-size: 11px; color: var(--ink2); }
561
+ `
562
+
563
+ /**
564
+ * Render a `buildReport()` result to one self-contained HTML document.
565
+ *
566
+ * Pure: no I/O, no corpus read, no indicator computed or re-derived. Every
567
+ * figure comes off the object exactly as the text and machine-readable surfaces
568
+ * take it.
569
+ *
570
+ * @param {object} report a `buildReport()` result
571
+ * @returns {string} a complete HTML document
572
+ */
573
+ export function renderReportHtml(report) {
574
+ const r = report && typeof report === 'object' ? report : {}
575
+ const filter = r.filter || {}
576
+ const filterBits = []
577
+ if (filter.since) filterBits.push(`since ${filter.since}`)
578
+ if (filter.change) filterBits.push(`change ${filter.change}`)
579
+
580
+ const body = r.coverage
581
+ ? coverageSection(r.coverage) + (r.indicators ? indicatorsSection(r.indicators) : '')
582
+ : html`<section class="block"><h2>The report could not be computed</h2>
583
+ <p class="reason">${r.reason || 'unknown'}</p>
584
+ <p class="lede">Nothing is asserted about the corpora. No indicator below is rendered as a
585
+ zero, because none was measured.</p></section>`
586
+
587
+ return (
588
+ '<!doctype html>\n' +
589
+ html`<html lang="en"><head><meta charset="utf-8">
590
+ <meta name="viewport" content="width=device-width, initial-scale=1">
591
+ <title>Interlock report</title>
592
+ <style>${raw(STYLE)}</style>
593
+ </head>
594
+ <body><main class="sheet">
595
+ <div class="head">
596
+ <div><div class="kicker">Interlock · instrument readout</div><h1>Interlock Report</h1></div>
597
+ <div class="meta">
598
+ <span class="k">ROOT</span><span>${r.root || '.'}</span>
599
+ <span class="k">SCHEMA</span><span>${r.schema || 'unknown'}</span>
600
+ <span class="k">SCAN CAP</span><span>${filter.maxRuns === undefined ? 'unstated' : filter.maxRuns} run(s)</span>
601
+ <span class="k">FILTER</span><span>${filterBits.length ? filterBits.join(', ') : 'none'}</span>
602
+ <span class="k">GATES</span><span>off — reports only</span>
603
+ </div>
604
+ </div>
605
+ <hr class="rule">
606
+ <div class="intro">
607
+ <p>This page reports what the scanned corpora record about one development loop. It does not
608
+ evaluate the loop, compare it to a target, or issue a verdict. No threshold is applied to any
609
+ figure below.</p>
610
+ <div class="legend">
611
+ <div><span class="word">${UNOBSERVED}</span><span>The corpus was read and holds no events of this kind.</span></div>
612
+ <div><span class="word">${NOT_COMPUTABLE}</span><span>No scanned corpus records the quantity; it cannot be derived from what exists.</span></div>
613
+ <div><span></span><span>Both are findings, recorded at the same standing as a measured value.</span></div>
614
+ </div>
615
+ </div>
616
+ ${raw(body)}
617
+ <div class="foot"><span>${r.note || 'reports only'}</span><span>Exit status is always 0.</span></div>
618
+ </main></body></html>
619
+ `
620
+ )
621
+ }
622
+
623
+ export default renderReportHtml