@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,1004 @@
1
+ // The ship-run trajectory (design.md decision 1) — one append-only JSON Lines
2
+ // file per `/interlock:ship` run, so a halt can be reconstructed later.
3
+ //
4
+ // This is a different corpus from `.claude/learning/outcomes.jsonl`
5
+ // (`lib/outcomes.mjs`): outcomes is one summary line per planning→ship
6
+ // attempt and is explicitly not a gate. This file is per-step — every
7
+ // wave-state action, load-bearing CLI exit, agent spawn the workflow
8
+ // requested, and verify judgement — so that a halted run can be replayed
9
+ // without reading git history. Mixing the two corpora would poison the
10
+ // continuity corpus outcomes.mjs exists to keep clean.
11
+ //
12
+ // Third impure module in `lib/`, copying `lib/outcomes.mjs`'s contract:
13
+ //
14
+ // 1. **It never throws.** A read-only checkout or an unwritable `.claude/`
15
+ // degrades to a reported no-op — see design.md's two-phase failure
16
+ // policy. (The later reconstructability *gate* that turns a write
17
+ // failure into a halt is a separate module, added in a later task.)
18
+ // 2. **It writes a fixed shape, not whatever it was handed.** Fields are
19
+ // copied by name per event `type`, so handing the writer a fat object
20
+ // (a verify result, a wave-state cursor) cannot leak suite logs, diffs,
21
+ // or finding bodies into a file future agents will read back.
22
+ //
23
+ // Append-only, one JSON object per line. The reader (added with
24
+ // session-query, a later task) tolerates a torn final line the same way
25
+ // `outcomes.mjs` does: a log truncated by a crash costs at most the one
26
+ // record being written, never the records before it.
27
+ //
28
+ // `seq` is assigned by this writer, never by the caller: it is derived from
29
+ // the highest `seq` already present in the run's file, so a caller cannot
30
+ // invent contiguity that never happened.
31
+ //
32
+ // Exposed to skills as `interlock run-log append` (later tasks add
33
+ // `list`/`show`/`query`/`check`).
34
+
35
+ import {
36
+ appendFileSync,
37
+ closeSync,
38
+ existsSync,
39
+ mkdirSync,
40
+ openSync,
41
+ readdirSync,
42
+ readFileSync,
43
+ readSync,
44
+ statSync
45
+ } from 'node:fs'
46
+ import { join } from 'node:path'
47
+
48
+ /** Where per-run trajectories live, relative to the repo root. */
49
+ export const SHIP_DIR = join('.claude', 'ship')
50
+
51
+ /** Directory holding one JSONL file per run, relative to the repo root. */
52
+ export const RUN_LOG_DIR = join(SHIP_DIR, 'runs')
53
+
54
+ /** Bump when the record shape changes, so old lines stay readable. */
55
+ export const RUN_LOG_SCHEMA = 'interlock.ship-run/1'
56
+
57
+ /** The event types a trajectory line may carry (design.md decision 2). */
58
+ export const RUN_LOG_TYPES = Object.freeze([
59
+ 'run-start',
60
+ 'wave-action',
61
+ 'cli-exit',
62
+ 'agent-spawn',
63
+ 'verify-judgement',
64
+ 'run-halt',
65
+ 'run-complete',
66
+ 'run-receipt'
67
+ ])
68
+
69
+ /** The two modes a run starts in — mirrors `lib/outcomes.mjs`'s `MODES`. */
70
+ export const RUN_MODES = Object.freeze(['checkpoint', 'continue'])
71
+
72
+ /** Verify contexts a `verify-judgement` event may report. */
73
+ export const VERIFY_CONTEXTS = Object.freeze(['inter-wave', 'final'])
74
+
75
+ /**
76
+ * Agent kinds a `agent-spawn` event may report.
77
+ *
78
+ * Every `kind` the run program spawns must be listed here: the field mapper
79
+ * coerces an unlisted value to `other` WITHOUT a warning, so a kind missing from
80
+ * this list reads back as an indistinguishable `other` agent and any later
81
+ * reader that segments spend or failure by kind cannot tell a remediation round
82
+ * from a commit. `test/spine/run-log.test.mjs` walks the `kind:` literals in
83
+ * `lib/run.mjs` against this list for exactly that reason.
84
+ */
85
+ export const AGENT_KINDS = Object.freeze([
86
+ 'implementer',
87
+ 'planner',
88
+ 'ping',
89
+ 'verify',
90
+ 'review',
91
+ 'remediate',
92
+ 'handoff',
93
+ 'commit',
94
+ 'other'
95
+ ])
96
+
97
+ /** Wave-state sources a `wave-action` event may report. */
98
+ export const WAVE_SOURCES = Object.freeze(['create', 'next', 'record-batch', 'record-verify', 'replan'])
99
+
100
+ const MAX_TEXT = 500
101
+ const MAX_NAME = 120
102
+ const MAX_LABEL = 200
103
+ const MAX_COMMAND = 120
104
+ const MAX_ACTION = 40
105
+ const MAX_HASH = 80
106
+
107
+ // Bounds for the two receipt path sets. Deliberately here rather than in
108
+ // `lib/limits.mjs`: that module publishes *policy* caps through
109
+ // `interlock limits`, and nothing halts on either of these. They bound what one
110
+ // receipt writes, exactly as `MAX_TEXT` and the `spill` bound already do, and a
111
+ // set that reaches the bound is recorded as truncated rather than silently
112
+ // shortened (see `pathSetTruncated`).
113
+ const MAX_PATH_SET = 200
114
+ const MAX_PATH = 300
115
+
116
+ function messageOf(err) {
117
+ return (err && err.message) || String(err)
118
+ }
119
+
120
+ function isObject(value) {
121
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
122
+ }
123
+
124
+ function text(value, max = MAX_TEXT) {
125
+ if (typeof value !== 'string') return ''
126
+ const s = value.replace(/\s+/g, ' ').trim()
127
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s
128
+ }
129
+
130
+ // Like `text`, but an empty result stays `null` rather than `''` — this file
131
+ // distinguishes "field not given" from "field given as empty string" for
132
+ // fields that are meaningfully absent (a task id, a wave label).
133
+ function nullableText(value, max = MAX_TEXT) {
134
+ const s = text(value, max)
135
+ return s ? s : null
136
+ }
137
+
138
+ function count(value) {
139
+ const n = Number(value)
140
+ if (!Number.isFinite(n) || n < 0) return 0
141
+ return Math.trunc(n)
142
+ }
143
+
144
+ function nullableCount(value) {
145
+ if (value === null || value === undefined) return null
146
+ const n = Number(value)
147
+ if (!Number.isFinite(n) || n < 0) return null
148
+ return Math.trunc(n)
149
+ }
150
+
151
+ // Judgements in this log are definite (a CLI already decided them), so unlike
152
+ // `outcomes.mjs`'s tri-state `bool`, an unrecognized value coerces to `false`
153
+ // rather than `null` — there is no "nobody said" here, only a caller that
154
+ // passed something malformed.
155
+ function boolStrict(value) {
156
+ return value === true
157
+ }
158
+
159
+ // Tri-state, and the exception to the paragraph above: a receipt field has to
160
+ // keep "did not" apart from "never found out". Flooring an unobserved value to
161
+ // `false` would file a run that died before its commit step as a run that
162
+ // chose not to commit — the same defect `nullableCount` exists to prevent one
163
+ // type down. Same reasoning as `lib/outcomes.mjs`'s tri-state `bool`.
164
+ function nullableBool(value) {
165
+ if (value === true) return true
166
+ if (value === false) return false
167
+ return null
168
+ }
169
+
170
+ // The per-wave tally group, built element by element with its own coercers.
171
+ // Passing the wave objects through — or spreading them — would defeat the
172
+ // whitelist one level down, and the summary a receipt is built from carries
173
+ // implementer handoffs and changed-file lists on those same objects.
174
+ function waveTallies(value) {
175
+ if (!Array.isArray(value)) return []
176
+ return value.filter(isObject).map(wave => ({
177
+ wave: nullableText(typeof wave.wave === 'number' ? String(wave.wave) : wave.wave, MAX_NAME),
178
+ ok: nullableCount(wave.ok),
179
+ failed: nullableCount(wave.failed),
180
+ notAttempted: nullableCount(
181
+ Array.isArray(wave.notAttempted) ? wave.notAttempted.length : wave.notAttempted
182
+ )
183
+ }))
184
+ }
185
+
186
+ // The per-wave spend group, built element by element for the same reason
187
+ // `waveTallies` is: it comes off the orchestrator's `summary`, and spreading a
188
+ // wave object through would defeat the whitelist one level down.
189
+ //
190
+ // `nullableCount` on the figure, never `count`. A host with no token accounting,
191
+ // a runtime that stops exposing it mid-run, and a malformed value must all read
192
+ // as unknown — a `0` would assert a wave that spent nothing, which is never true
193
+ // of a wave that ran agents, so the error would be silent and systematic.
194
+ function waveSpend(value) {
195
+ if (!Array.isArray(value)) return []
196
+ return value.filter(isObject).map(entry => ({
197
+ wave: nullableText(typeof entry.wave === 'number' ? String(entry.wave) : entry.wave, MAX_NAME),
198
+ outputTokens: nullableCount(entry.outputTokens),
199
+ cacheReadInputTokens: nullableCount(entry.cacheReadInputTokens),
200
+ cacheCreationInputTokens: cacheTiers(entry.cacheCreationInputTokens)
201
+ }))
202
+ }
203
+
204
+ // How many lifetime tiers one record may carry. A bound, not a policy cap: the
205
+ // host reports two today, and the tier names are read off the source rather
206
+ // than enumerated here, so this is what keeps a malformed payload from writing
207
+ // an unbounded object into a file agents read back.
208
+ const MAX_CACHE_TIERS = 8
209
+
210
+ /**
211
+ * The per-lifetime-tier cache-creation block, bounded and tri-state.
212
+ *
213
+ * The tier NAMES come from the host, so they are validated by shape rather than
214
+ * against a list this module would have to keep in step with an adapter. A
215
+ * malformed key is dropped; a malformed figure is `null`, never `0`.
216
+ *
217
+ * `null` for a record that carried no decomposition at all — a host with no
218
+ * cache accounting — which is a different fact from `{}`. Never summed into one
219
+ * total: the two tiers are priced differently and the flattening is
220
+ * irreversible (design D7).
221
+ */
222
+ function cacheTiers(value) {
223
+ if (!isObject(value)) return null
224
+ const out = {}
225
+ for (const [tier, figure] of Object.entries(value)) {
226
+ if (!/^[a-z][a-z0-9_]{0,39}$/.test(tier)) continue
227
+ out[tier] = nullableCount(figure)
228
+ if (Object.keys(out).length >= MAX_CACHE_TIERS) break
229
+ }
230
+ return out
231
+ }
232
+
233
+ /**
234
+ * The receipt's host block, bounded and tri-state throughout.
235
+ *
236
+ * Absent for a run whose close never named a host, which is a different fact
237
+ * from a host that declared it has no hooks: the first is a receipt this reader
238
+ * cannot compare, the second is a run that was deliberately weaker. Flooring
239
+ * either to `false` would file them as the same run.
240
+ */
241
+ function hostBlock(value) {
242
+ if (!isObject(value)) return null
243
+ return {
244
+ id: nullableText(value.id, MAX_NAME),
245
+ billing: nullableText(value.billing, MAX_NAME),
246
+ hooks: nullableBool(value.hooks),
247
+ usage: nullableBool(value.usage),
248
+ // Declared separately from `usage` and recorded separately, so a reader can
249
+ // partition a corpus by whether cache figures were obtainable at all rather
250
+ // than averaging a measured figure against an absent one.
251
+ cacheAccounting: nullableBool(value.cacheAccounting)
252
+ }
253
+ }
254
+
255
+ function enumOf(allowed, value, fallback = null) {
256
+ return typeof value === 'string' && allowed.includes(value) ? value : fallback
257
+ }
258
+
259
+ function stringArray(value, max = MAX_TEXT) {
260
+ if (!Array.isArray(value)) return []
261
+ return value.filter(v => typeof v === 'string').map(v => text(v, max))
262
+ }
263
+
264
+ // Tri-state array, and the reason `stringArray` could not be reused for the two
265
+ // receipt path sets: it floors an absent source to `[]`, which would file "this
266
+ // run made no commit" as "a commit touched nothing". That is the same collapse
267
+ // `nullableCount` and `nullableBool` exist to prevent one type down, and here it
268
+ // would be the denominator of an indicator — a measured zero invented out of an
269
+ // absent measurement. `null` means unobserved; `[]` means observed and empty.
270
+ //
271
+ // Bounded on both axes: at most `max` entries, each at most `maxLength`
272
+ // characters. A source past the bound is cut here and marked truncated by
273
+ // `pathSetTruncated` from the same source, so the two can never disagree.
274
+ function nullableStringArray(value, max = MAX_PATH_SET, maxLength = MAX_PATH) {
275
+ if (!Array.isArray(value)) return null
276
+ return value
277
+ .filter(v => typeof v === 'string' && v.trim())
278
+ .slice(0, max)
279
+ .map(v => text(v, maxLength))
280
+ }
281
+
282
+ // Whether a path set reached its bound, derived from the same source the set is
283
+ // derived from rather than reported by the caller. A truncated set is not the
284
+ // set the run touched or predicted, and a reader comparing over it would be
285
+ // comparing over a denominator nobody stated. `null` when the set itself is
286
+ // unobserved: an unread set was not truncated, it was never read.
287
+ function pathSetTruncated(value, max = MAX_PATH_SET) {
288
+ if (!Array.isArray(value)) return null
289
+ return value.filter(v => typeof v === 'string' && v.trim()).length > max
290
+ }
291
+
292
+ // Safe as a path segment: bounded, no separators, no traversal. `runId` is
293
+ // generated internally (design.md: a UUID stored on the frozen wave-state),
294
+ // but this module also accepts it from a CLI argument, so it is validated
295
+ // like any other externally-supplied path component.
296
+ function isSafeId(value) {
297
+ return typeof value === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(value) && !value.includes('..')
298
+ }
299
+
300
+ // Fields copied by name for each event `type`. Anything on the caller's input
301
+ // that is not listed here for the given `type` is dropped — this is the
302
+ // anti-leak guarantee from the module header, and it is what lets a caller
303
+ // hand this a whole verify result or wave-state cursor safely.
304
+ const TYPE_FIELDS = {
305
+ 'run-start': {
306
+ mode: source => enumOf(RUN_MODES, source.mode, null),
307
+ strict: source => boolStrict(source.strict),
308
+ // The host session this run executed under, so a trajectory joins to the
309
+ // host's own transcript by a key rather than by overlapping wall clocks.
310
+ // Timestamp overlap is not an identity: two sessions in one project produce
311
+ // overlapping windows that cannot be told apart without reading them.
312
+ //
313
+ // `nullableText`, never a synthesised substitute. A host that exposes no
314
+ // session id records absent; deriving one from the runId, the pid or the
315
+ // timestamp would put a key in the corpus that joins to nothing and looks
316
+ // exactly like one that joins to something.
317
+ sessionId: source => nullableText(source.sessionId, MAX_NAME)
318
+ },
319
+ 'wave-action': {
320
+ action: source => nullableText(source.action, MAX_ACTION),
321
+ wave: source => nullableText(source.wave, MAX_NAME),
322
+ waveIndex: source => nullableCount(source.waveIndex),
323
+ batchIndex: source => nullableCount(source.batchIndex),
324
+ phase: source => nullableText(source.phase, MAX_ACTION),
325
+ source: source => enumOf(WAVE_SOURCES, source.source, null)
326
+ },
327
+ 'cli-exit': {
328
+ command: source => text(source.command, MAX_COMMAND),
329
+ exitCode: source => count(source.exitCode),
330
+ // WHAT THIS MEASURES: the execution time of the CLI command that wrote this
331
+ // event — argument parsing, the lib/ call it made, and this append. Nothing
332
+ // else.
333
+ //
334
+ // WHAT IT DOES NOT MEASURE: how long the agent turn around that command
335
+ // took. `bin/interlock` runs BETWEEN agent turns and cannot observe one, so
336
+ // an interval here is CLI self-time and is typically a millisecond or two.
337
+ // Reading it as agent wall clock would understate a turn by three orders of
338
+ // magnitude — hence the sentence rather than a bare field name.
339
+ //
340
+ // Wave and run elapsed time are NOT recorded anywhere: they are derived
341
+ // from `ts` deltas by `deriveWaveElapsed` below. A recorded copy could
342
+ // disagree with the timestamps that produced it, and one of the two would
343
+ // be wrong in a way no reader could see.
344
+ //
345
+ // `nullableCount`, so a command that did not measure itself stays absent. A
346
+ // `0` would assert an instantaneous command.
347
+ durationMs: source => nullableCount(source.durationMs)
348
+ },
349
+ 'agent-spawn': {
350
+ label: source => text(source.label, MAX_LABEL),
351
+ model: source => nullableText(source.model, 60),
352
+ kind: source => enumOf(AGENT_KINDS, source.kind, 'other'),
353
+ taskId: source => nullableText(source.taskId, MAX_NAME)
354
+ },
355
+ 'verify-judgement': {
356
+ context: source => enumOf(VERIFY_CONTEXTS, source.context, null),
357
+ halt: source => boolStrict(source.halt),
358
+ reason: source => text(source.reason),
359
+ unitStatus: source => nullableText(source.unitStatus, MAX_ACTION),
360
+ spill: source => stringArray(source.spill, 300)
361
+ },
362
+ 'run-halt': {
363
+ reason: source => text(source.reason)
364
+ },
365
+ 'run-complete': {
366
+ leftoverTaskIds: source => stringArray(source.leftoverTaskIds, MAX_NAME)
367
+ },
368
+ // The receipt: what the run observed about itself, so a trajectory file is
369
+ // readable without the repository beside it. It is the largest payload in
370
+ // this table and it is built from the orchestrator's whole `summary` object,
371
+ // which holds a review result with finding bodies and a verify result with
372
+ // suite output — so every field here is named, and the two nested groups are
373
+ // built element by element rather than passed through.
374
+ //
375
+ // `nullable*` throughout, deliberately: a receipt written by a run that
376
+ // halted before its review step must report the review counts as unobserved,
377
+ // not as zero blockers. A corpus that reads absence as cleanliness would
378
+ // systematically flatter exactly the runs it exists to explain.
379
+ 'run-receipt': {
380
+ // Which host ran this and what it could not do. Copied by name and bounded
381
+ // like everything else: the id is the runner's adapter (or the Workflow
382
+ // host), and the three flags are the run's declared billing path, whether
383
+ // this repository's guards were in force, and whether the host reported any
384
+ // token usage at all. Absent on a receipt written before hosts declared
385
+ // capabilities, which reads as unknown rather than as the Workflow host.
386
+ host: source => hostBlock(source.host),
387
+ waves: source => waveTallies(source.waves),
388
+ planReused: source => nullableBool(source.planReused),
389
+ planStatus: source => nullableText(source.planStatus, MAX_ACTION),
390
+ planReason: source => nullableText(source.planReason),
391
+ planFingerprint: source => nullableText(source.planFingerprint, MAX_HASH),
392
+ reviewRaised: source => nullableCount(source.reviewRaised),
393
+ reviewSurviving: source => nullableCount(source.reviewSurviving),
394
+ reviewBlockers: source => nullableCount(source.reviewBlockers),
395
+ reviewWarnings: source => nullableCount(source.reviewWarnings),
396
+ remediationRounds: source => nullableCount(source.remediationRounds),
397
+ skippedVerifications: source => nullableCount(source.skippedVerifications),
398
+ capExhaustedVerifications: source => nullableCount(source.capExhaustedVerifications),
399
+ unresolvedErrors: source => nullableCount(source.unresolvedErrors),
400
+ leftoverTaskIds: source => stringArray(source.leftoverTaskIds, MAX_NAME),
401
+ // Output-token spend, per wave and for the run. The figure is a cumulative
402
+ // process-wide delta over the wave's span, so it includes the
403
+ // orchestrator's own turns as well as the implementers' — it is the wave's
404
+ // aggregate and never per-lane cost. No per-agent attribution is recorded,
405
+ // and none can be obtained by dividing this by the lane count: the runtime
406
+ // exposes one counter for the process, not one per agent.
407
+ //
408
+ // A host whose runtime exposes no accounting records these as absent (see
409
+ // `bin/interlock-ship-acp`), which is a different fact from a run that
410
+ // measured and found nothing.
411
+ spend: source => waveSpend(source.spend),
412
+ outputTokens: source => nullableCount(source.outputTokens),
413
+ // The input side of the same span, under the same unknown-not-zero rule.
414
+ // Cache creation stays split by the lifetime tier the host reported it
415
+ // under: a prefix written for five minutes and one written for an hour are
416
+ // priced differently, so a single total cannot be priced and no reader
417
+ // could split it back apart.
418
+ cacheReadInputTokens: source => nullableCount(source.cacheReadInputTokens),
419
+ cacheCreationInputTokens: source => cacheTiers(source.cacheCreationInputTokens),
420
+ halted: source => boolStrict(source.halted),
421
+ haltReason: source => nullableText(source.haltReason),
422
+ committed: source => nullableBool(source.committed),
423
+ commit: source => nullableText(source.commit, MAX_NAME),
424
+ // The two path sets `interlock report` compares to answer "did the merged
425
+ // diff match the plan". Copied by name and bounded like everything else
426
+ // here, which matters more than usual: the source object these come off
427
+ // carries the commit result and the executed plan, so a pass-through would
428
+ // put diff hunks and file contents in a file future agents read back.
429
+ //
430
+ // The touched set is read from version control, never from the commit
431
+ // agent's own account of what it committed — an agent that mis-listed its
432
+ // own commit would silently move the indicator's denominator.
433
+ touchedPaths: source => nullableStringArray(source.touchedPaths),
434
+ touchedPathsTruncated: source => pathSetTruncated(source.touchedPaths),
435
+ touchedPathsReason: source => nullableText(source.touchedPathsReason),
436
+ // Path prediction is optional per task by design (`lib/waves.mjs`: a
437
+ // classifier that cannot predict which files a task will touch must be able
438
+ // to say nothing rather than guess), so the set alone cannot tell a plan
439
+ // that predicted nothing extra from a plan that declined to predict. The
440
+ // completeness marker carries that difference; the report excludes an
441
+ // incomplete prediction rather than counting it as a run that went off-plan.
442
+ predictedPaths: source => nullableStringArray(source.predictedPaths),
443
+ predictedPathsTruncated: source => pathSetTruncated(source.predictedPaths),
444
+ predictedPathsComplete: source => nullableBool(source.predictedPathsComplete),
445
+ predictedPathsReason: source => nullableText(source.predictedPathsReason),
446
+ degradations: source => stringArray(source.degradations)
447
+ }
448
+ }
449
+
450
+ /** Directory a run's trajectories live under, absolute from `root`. */
451
+ export function runLogDir(root) {
452
+ return join(root, RUN_LOG_DIR)
453
+ }
454
+
455
+ /** Absolute path of one run's trajectory file. */
456
+ export function runLogPath(root, runId) {
457
+ return join(runLogDir(root), `${runId}.jsonl`)
458
+ }
459
+
460
+ // If a previous append was torn mid-line (crash, full disk), a naive append
461
+ // would splice the new record onto the broken one and destroy two records
462
+ // instead of one. Reading the final byte is cheap and makes the log
463
+ // self-healing at the seam. Copied from `lib/outcomes.mjs`.
464
+ function endsWithNewline(file) {
465
+ const size = statSync(file).size
466
+ if (size === 0) return true
467
+ const fd = openSync(file, 'r')
468
+ try {
469
+ const buf = Buffer.alloc(1)
470
+ readSync(fd, buf, 0, 1, size - 1)
471
+ return buf[0] === 0x0a
472
+ } finally {
473
+ closeSync(fd)
474
+ }
475
+ }
476
+
477
+ // The next `seq` for a run is one past the highest `seq` already on disk —
478
+ // never supplied by the caller. A line that fails to parse (e.g. a torn
479
+ // final line left by a prior crash) is skipped for this purpose: the heal in
480
+ // `appendRunLogEvent` repairs the seam, this just refuses to trust a broken
481
+ // line's claimed `seq`.
482
+ function nextSeq(file) {
483
+ if (!existsSync(file)) return 1
484
+ try {
485
+ const lines = readFileSync(file, 'utf8').split('\n')
486
+ let max = 0
487
+ for (const line of lines) {
488
+ if (!line.trim()) continue
489
+ try {
490
+ const parsed = JSON.parse(line)
491
+ if (isObject(parsed) && Number.isFinite(parsed.seq) && parsed.seq > max) {
492
+ max = parsed.seq
493
+ }
494
+ } catch {
495
+ // Torn or corrupt line: does not count toward seq. The append heal
496
+ // below still protects it from being merged with the new record.
497
+ }
498
+ }
499
+ return max + 1
500
+ } catch {
501
+ return 1
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Append one trajectory event to `.claude/ship/runs/<runId>.jsonl`.
507
+ *
508
+ * Only `schema`, `ts`, `runId`, `change`, `seq`, `type`, and the fields listed
509
+ * in `TYPE_FIELDS` for that `type` are ever written — anything else on
510
+ * `input` is ignored, so a caller may pass a whole verify result or
511
+ * wave-state cursor without leaking its text into the log.
512
+ *
513
+ * @param {string} root repo root; `.claude/ship/runs` is created beneath it
514
+ * @param {{
515
+ * runId?: string,
516
+ * type?: 'run-start'|'wave-action'|'cli-exit'|'agent-spawn'|'verify-judgement'|'run-halt'|'run-complete'|'run-receipt',
517
+ * change?: string,
518
+ * now?: Date|string,
519
+ * [field: string]: unknown
520
+ * }} [input] extra keys are read only for the fields the given `type` declares
521
+ * @returns {{written: boolean, path: string|null, reason: string|null, seq: number|null}}
522
+ * `reason` explains a `written: false` — it is never an exception
523
+ */
524
+ export function appendRunLogEvent(root, input = {}) {
525
+ try {
526
+ if (typeof root !== 'string' || !root.trim()) {
527
+ return { written: false, path: null, reason: 'no root directory given', seq: null }
528
+ }
529
+ if (!existsSync(root)) {
530
+ return { written: false, path: null, reason: `root does not exist: ${root}`, seq: null }
531
+ }
532
+ const source = isObject(input) ? input : {}
533
+
534
+ const runId = typeof source.runId === 'string' ? source.runId.trim() : ''
535
+ if (!isSafeId(runId)) {
536
+ return { written: false, path: null, reason: 'runId must be a non-empty safe identifier', seq: null }
537
+ }
538
+
539
+ const type = typeof source.type === 'string' ? source.type.trim() : ''
540
+ const fields = TYPE_FIELDS[type]
541
+ if (!fields) {
542
+ return { written: false, path: null, reason: `type must be one of ${RUN_LOG_TYPES.join(', ')}`, seq: null }
543
+ }
544
+
545
+ const when = source.now instanceof Date ? source.now : new Date(source.now || Date.now())
546
+ const iso = Number.isNaN(when.getTime()) ? new Date().toISOString() : when.toISOString()
547
+
548
+ const dir = runLogDir(root)
549
+ mkdirSync(dir, { recursive: true })
550
+ const file = runLogPath(root, runId)
551
+
552
+ // Assigned here, from what is already on disk — never trusted from the
553
+ // caller (design.md decision 2).
554
+ const seq = nextSeq(file)
555
+
556
+ // Built key by key: see the module header. Nothing not named in
557
+ // `TYPE_FIELDS[type]` is written, whatever the caller passed.
558
+ const extra = {}
559
+ for (const key of Object.keys(fields)) extra[key] = fields[key](source)
560
+
561
+ const payload = {
562
+ schema: RUN_LOG_SCHEMA,
563
+ ts: iso,
564
+ runId,
565
+ change: text(source.change, MAX_NAME) || 'unnamed',
566
+ seq,
567
+ type,
568
+ ...extra
569
+ }
570
+
571
+ const prefix = existsSync(file) && !endsWithNewline(file) ? '\n' : ''
572
+ // JSON.stringify never emits a raw newline, so one record is always one line.
573
+ appendFileSync(file, `${prefix}${JSON.stringify(payload)}\n`)
574
+ return { written: true, path: file, reason: null, seq }
575
+ } catch (err) {
576
+ // Deliberately swallowed: see the module header. The caller gets a
577
+ // reason, never an exception — a run must not fail because its
578
+ // trajectory could not grow (until the later reconstructability gate).
579
+ return { written: false, path: null, reason: messageOf(err), seq: null }
580
+ }
581
+ }
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // Session-query (design.md decision 4) — read-only, never-throw, and tolerant
585
+ // of a torn or garbage line the same way `lib/outcomes.mjs`'s reader is:
586
+ // skip the bad line, keep the rest, report which line numbers were skipped.
587
+ // None of this interprets events as a new state machine; it reports the log.
588
+ // ---------------------------------------------------------------------------
589
+
590
+ /**
591
+ * Read one run's trajectory back, in order.
592
+ *
593
+ * @param {string} root repo root
594
+ * @param {string} runId
595
+ * @returns {{
596
+ * path: string|null, exists: boolean,
597
+ * records: object[], skipped: Array<{line: number, reason: string}>,
598
+ * reason: string|null
599
+ * }}
600
+ */
601
+ export function readRunLog(root, runId) {
602
+ if (typeof root !== 'string' || !root.trim()) {
603
+ return { path: null, exists: false, records: [], skipped: [], reason: 'no root directory given' }
604
+ }
605
+ if (!isSafeId(typeof runId === 'string' ? runId.trim() : '')) {
606
+ return { path: null, exists: false, records: [], skipped: [], reason: 'runId must be a non-empty safe identifier' }
607
+ }
608
+ const path = runLogPath(root, runId.trim())
609
+ try {
610
+ if (!existsSync(path)) {
611
+ return { path, exists: false, records: [], skipped: [], reason: null }
612
+ }
613
+ const lines = readFileSync(path, 'utf8').split('\n')
614
+ const records = []
615
+ const skipped = []
616
+ lines.forEach((line, i) => {
617
+ if (!line.trim()) return
618
+ try {
619
+ const parsed = JSON.parse(line)
620
+ if (isObject(parsed)) records.push(parsed)
621
+ else skipped.push({ line: i + 1, reason: 'line is not a JSON object' })
622
+ } catch (err) {
623
+ skipped.push({ line: i + 1, reason: messageOf(err) })
624
+ }
625
+ })
626
+ return { path, exists: true, records, skipped, reason: null }
627
+ } catch (err) {
628
+ return { path, exists: true, records: [], skipped: [], reason: messageOf(err) }
629
+ }
630
+ }
631
+
632
+ // Did either recorded path set reach its bound? Tri-state: `true` if either
633
+ // says so, `false` if both were observed untruncated, `null` if neither was
634
+ // observed — an unread set was not untruncated, it was never read.
635
+ function eitherTruncated(receipt) {
636
+ const a = nullableBool(receipt.touchedPathsTruncated)
637
+ const b = nullableBool(receipt.predictedPathsTruncated)
638
+ if (a === true || b === true) return true
639
+ if (a === null && b === null) return null
640
+ return false
641
+ }
642
+
643
+ // One run's list-level summary: enough to tell runs apart without reading
644
+ // every event out to the caller.
645
+ function summarizeRunLog(root, runId) {
646
+ const read = readRunLog(root, runId)
647
+ const records = read.records
648
+ const start = records.find(r => r.type === 'run-start')
649
+ const halt = records.find(r => r.type === 'run-halt')
650
+ const complete = records.find(r => r.type === 'run-complete')
651
+ const receipt = records.find(r => r.type === 'run-receipt')
652
+ return {
653
+ runId,
654
+ change: (start && start.change) || (records[0] && records[0].change) || null,
655
+ halted: Boolean(halt) || Boolean(receipt && receipt.halted === true),
656
+ haltReason: halt ? halt.reason : receipt ? nullableText(receipt.haltReason) : null,
657
+ complete: Boolean(complete),
658
+ // Reported separately from `complete`: a closed run with no receipt did not
659
+ // reach its own close, and that is a finding rather than missing data. A
660
+ // list that showed only `complete` would make the two indistinguishable.
661
+ receipt: Boolean(receipt),
662
+ committed: receipt ? nullableBool(receipt.committed) : null,
663
+ commit: receipt ? nullableText(receipt.commit, MAX_NAME) : null,
664
+ // Counts rather than the sets themselves — a list summary should not carry
665
+ // four hundred path strings — but tri-state like `committed`, because the
666
+ // whole value of these two fields is that "the run never found out" stays
667
+ // apart from "the run measured nothing". A count of 0 is an observed empty
668
+ // set; `null` is an unobserved one, and the reason says which.
669
+ touchedPathCount: receipt && Array.isArray(receipt.touchedPaths) ? receipt.touchedPaths.length : null,
670
+ touchedPathsReason: receipt ? nullableText(receipt.touchedPathsReason) : null,
671
+ predictedPathCount: receipt && Array.isArray(receipt.predictedPaths) ? receipt.predictedPaths.length : null,
672
+ predictedPathsReason: receipt ? nullableText(receipt.predictedPathsReason) : null,
673
+ predictedPathsComplete: receipt ? nullableBool(receipt.predictedPathsComplete) : null,
674
+ pathsTruncated: receipt ? eitherTruncated(receipt) : null,
675
+ events: records.length,
676
+ skipped: read.skipped.length,
677
+ startedAt: start ? start.ts : (records[0] ? records[0].ts : null)
678
+ }
679
+ }
680
+
681
+ /**
682
+ * List every run that has a trajectory file, most recently started last.
683
+ * Never throws: a missing or unreadable runs directory is an empty list, the
684
+ * same way an absent outcomes corpus is empty rather than an error.
685
+ *
686
+ * @param {string} root repo root
687
+ * @param {{change?: string}} [opts] filter to one change name
688
+ * @returns {Array<ReturnType<typeof summarizeRunLog>>}
689
+ */
690
+ export function listRunLogs(root, opts = {}) {
691
+ if (typeof root !== 'string' || !root.trim()) return []
692
+ const dir = runLogDir(root)
693
+ let entries
694
+ try {
695
+ entries = existsSync(dir) ? readdirSync(dir) : []
696
+ } catch {
697
+ return []
698
+ }
699
+ const runIds = entries.filter(name => name.endsWith('.jsonl')).map(name => name.slice(0, -'.jsonl'.length))
700
+ const summaries = runIds.map(runId => summarizeRunLog(root, runId))
701
+ summaries.sort((a, b) => String(a.startedAt || '').localeCompare(String(b.startedAt || '')))
702
+ const change = isObject(opts) && typeof opts.change === 'string' ? opts.change.trim() : ''
703
+ return change ? summaries.filter(s => s.change === change) : summaries
704
+ }
705
+
706
+ /**
707
+ * Read one run's trajectory, filtered by event type and/or halt-relevance.
708
+ * `halted: true` narrows to the events that explain a halt — the `run-halt`
709
+ * line itself, plus any `verify-judgement` that halted — rather than every
710
+ * line in the run; combine with `type` to narrow further.
711
+ *
712
+ * @param {string} root repo root
713
+ * @param {string} runId
714
+ * @param {{type?: string, halted?: boolean}} [opts]
715
+ * @returns {ReturnType<typeof readRunLog> } same shape as `readRunLog`, `records` filtered
716
+ */
717
+ export function queryRunLog(root, runId, opts = {}) {
718
+ const read = readRunLog(root, runId)
719
+ let records = read.records
720
+ const type = isObject(opts) && typeof opts.type === 'string' ? opts.type.trim() : ''
721
+ if (type) records = records.filter(r => r.type === type)
722
+ if (isObject(opts) && opts.halted === true) {
723
+ records = records.filter(r => r.type === 'run-halt' || r.halt === true)
724
+ }
725
+ return { ...read, records }
726
+ }
727
+
728
+ /**
729
+ * Per-wave elapsed time, derived from the timestamps the events already carry.
730
+ *
731
+ * There is deliberately no recorded wave-duration field for this to be checked
732
+ * against: the `ts` values bound the wave, so recording a second copy would
733
+ * create a value that can disagree with the one that produced it. This is the
734
+ * only answer, which is why it cannot be contradicted.
735
+ *
736
+ * A wave opens at the first `wave-action` naming it and runs until the next one
737
+ * names a different wave; everything in between is attributed to it. A wave
738
+ * whose events are interleaved with a later re-entry (a replan sending the run
739
+ * back through it) spans from its earliest event to its latest.
740
+ *
741
+ * The run's own close — its receipt and its halt/complete line — is attributed
742
+ * to no wave. Those events happen after the last wave finished, and folding them
743
+ * in would silently pad whichever wave happened to be last with the cost of
744
+ * closing the run.
745
+ *
746
+ * Read-only and never throws: an unparseable `ts` is skipped, not guessed at.
747
+ *
748
+ * @param {object[]} records as returned by `readRunLog`/`queryRunLog`
749
+ * @returns {Array<{wave: string, firstTs: string, lastTs: string, events: number, elapsedMs: number}>}
750
+ */
751
+ export function deriveWaveElapsed(records) {
752
+ const ordered = (Array.isArray(records) ? records : [])
753
+ .filter(isObject)
754
+ .slice()
755
+ .sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0))
756
+
757
+ const spans = new Map()
758
+ const CLOSING = new Set(['run-receipt', 'run-halt', 'run-complete'])
759
+ let current = null
760
+ for (const r of ordered) {
761
+ if (CLOSING.has(r.type)) {
762
+ current = null
763
+ continue
764
+ }
765
+ if (r.type === 'wave-action' && r.wave !== null && r.wave !== undefined && String(r.wave).trim()) {
766
+ current = String(r.wave)
767
+ }
768
+ if (current === null) continue
769
+ const at = Date.parse(r.ts)
770
+ if (!Number.isFinite(at)) continue
771
+ const found = spans.get(current)
772
+ if (!found) {
773
+ spans.set(current, { wave: current, firstTs: r.ts, lastTs: r.ts, first: at, last: at, events: 1 })
774
+ continue
775
+ }
776
+ found.events += 1
777
+ if (at < found.first) {
778
+ found.first = at
779
+ found.firstTs = r.ts
780
+ }
781
+ if (at > found.last) {
782
+ found.last = at
783
+ found.lastTs = r.ts
784
+ }
785
+ }
786
+
787
+ return [...spans.values()].map(s => ({
788
+ wave: s.wave,
789
+ firstTs: s.firstTs,
790
+ lastTs: s.lastTs,
791
+ events: s.events,
792
+ elapsedMs: s.last - s.first
793
+ }))
794
+ }
795
+
796
+ // One path set, rendered so an unobserved set can never be read as an empty
797
+ // one. `unknown` carries the recorded reason; an observed empty set says out
798
+ // loud that it was observed. Neither renders as blank and neither renders
799
+ // as `[]`, which a reader would take for a measured zero.
800
+ function pathSetLine(label, set, reason, truncated) {
801
+ if (!Array.isArray(set)) {
802
+ return `${label}: unknown (${text(reason) || 'no reason recorded'})`
803
+ }
804
+ const mark = truncated === true ? ', TRUNCATED at the recorded bound — a prefix, not the whole set' : ''
805
+ if (!set.length) return `${label}: none — observed and empty${mark}`
806
+ return `${label}: ${set.length} path(s)${mark}: ${set.join(', ')}`
807
+ }
808
+
809
+ // An absent receipt field is rendered as `unknown`, never as `0` or as blank.
810
+ // The generic `k=null` rendering below would be technically accurate and read
811
+ // as noise; `0 blockers` would read as a clean review that never ran.
812
+ function receiptLines(r) {
813
+ const show = v => (v === null || v === undefined ? 'unknown' : String(v))
814
+ const out = []
815
+ out.push(r.halted === true ? `halted: ${show(r.haltReason)}` : 'halted: no')
816
+ const reuse = r.planReused === true ? 'reused' : r.planReused === false ? 'rebuilt' : 'unknown'
817
+ out.push(`plan: ${reuse} (${show(r.planStatus)}), fingerprint ${show(r.planFingerprint)}`)
818
+ for (const wave of Array.isArray(r.waves) ? r.waves : []) {
819
+ out.push(
820
+ `wave ${show(wave.wave)}: ${show(wave.ok)} ok, ${show(wave.failed)} failed, ` +
821
+ `${show(wave.notAttempted)} not attempted`
822
+ )
823
+ }
824
+ out.push(
825
+ `review: ${show(r.reviewRaised)} raised, ${show(r.reviewSurviving)} surviving, ` +
826
+ `${show(r.reviewBlockers)} blockers, ${show(r.reviewWarnings)} warnings`
827
+ )
828
+ out.push(`remediation rounds: ${show(r.remediationRounds)}`)
829
+ out.push(
830
+ `verifications skipped: ${show(r.skippedVerifications)} ` +
831
+ `(cap exhausted: ${show(r.capExhaustedVerifications)})`
832
+ )
833
+ out.push(`unresolved errors carried past a wave: ${show(r.unresolvedErrors)}`)
834
+ // Framed on the line itself, not left to a schema comment nobody reading a
835
+ // terminal will open: the figure covers everything that ran in the wave's
836
+ // span, orchestrator turns included. A reader who takes it for implementer
837
+ // cost, or divides it by the lane count, gets a number the run never measured.
838
+ out.push('output tokens (aggregate over the wave span — orchestrator turns included, not implementer cost):')
839
+ const spend = Array.isArray(r.spend) ? r.spend : []
840
+ if (!spend.length) out.push(' per wave: none recorded')
841
+ for (const entry of spend) out.push(` wave ${show(entry.wave)}: ${show(entry.outputTokens)}`)
842
+ out.push(` run total: ${show(r.outputTokens)}`)
843
+ const leftover = Array.isArray(r.leftoverTaskIds) ? r.leftoverTaskIds : []
844
+ out.push(`leftover tasks: ${leftover.length ? leftover.join(', ') : 'none'}`)
845
+ out.push(
846
+ `commit: ${
847
+ r.commit
848
+ ? r.commit
849
+ : r.committed === true
850
+ ? 'created, no identifier recorded'
851
+ : r.committed === false
852
+ ? 'no commit was made'
853
+ : 'unknown'
854
+ }`
855
+ )
856
+ out.push(pathSetLine('paths touched (read from version control)', r.touchedPaths, r.touchedPathsReason, r.touchedPathsTruncated))
857
+ out.push(pathSetLine('paths predicted (from the executed plan)', r.predictedPaths, r.predictedPathsReason, r.predictedPathsTruncated))
858
+ out.push(
859
+ ` prediction complete: ${
860
+ r.predictedPathsComplete === true
861
+ ? 'yes — every executed task declared its paths'
862
+ : r.predictedPathsComplete === false
863
+ ? 'no — at least one executed task declared none'
864
+ : 'unknown'
865
+ }`
866
+ )
867
+ const degradations = Array.isArray(r.degradations) ? r.degradations : []
868
+ out.push(`degradations: ${degradations.length ? '' : 'none recorded'}`.trimEnd())
869
+ for (const line of degradations) out.push(` ${line}`)
870
+ return out
871
+ }
872
+
873
+ /** Human-readable rendering of `readRunLog` / `queryRunLog`, for the CLI. */
874
+ export function formatRunLog(result) {
875
+ if (!result.exists) return `RUN LOG — none recorded (${result.path ?? RUN_LOG_DIR})\n`
876
+ const lines = [`RUN LOG — ${result.records.length} event(s)`]
877
+ if (result.skipped.length) {
878
+ lines.push(` ${result.skipped.length} unreadable line(s) skipped: ${result.skipped.map(s => s.line).join(', ')}`)
879
+ }
880
+ const OMIT = new Set(['schema', 'ts', 'runId', 'change', 'seq', 'type'])
881
+ for (const r of result.records) {
882
+ if (r.type === 'run-receipt') {
883
+ lines.push(` #${r.seq}\t${r.type}`)
884
+ for (const line of receiptLines(r)) lines.push(` ${line}`)
885
+ continue
886
+ }
887
+ const extra = Object.keys(r)
888
+ .filter(k => !OMIT.has(k))
889
+ // `durationMs` is the one field the generic renderer would misreport: the
890
+ // fallback would print `durationMs=null`, and any tidier version of that
891
+ // ("", `0ms`) would read as a command that took no time. An unmeasured
892
+ // duration says so, and a measured one carries its unit so nobody reads
893
+ // CLI self-time as an agent turn.
894
+ .map(k =>
895
+ k === 'durationMs'
896
+ ? `durationMs=${r[k] === null || r[k] === undefined ? 'unknown' : `${r[k]}ms`}`
897
+ : `${k}=${JSON.stringify(r[k])}`
898
+ )
899
+ .join(' ')
900
+ lines.push(` #${r.seq}\t${r.type}${extra ? `\t${extra}` : ''}`)
901
+ }
902
+ // Derived here rather than read from a field, because there is no field: see
903
+ // `deriveWaveElapsed`. Said out loud so a reader knows which of the two kinds
904
+ // of number this is.
905
+ const elapsed = deriveWaveElapsed(result.records)
906
+ if (elapsed.length) {
907
+ lines.push(' WAVE ELAPSED — derived from event timestamps, not a recorded field')
908
+ for (const w of elapsed) {
909
+ lines.push(` wave ${w.wave}: ${w.elapsedMs}ms across ${w.events} event(s)`)
910
+ }
911
+ }
912
+ return lines.join('\n') + '\n'
913
+ }
914
+
915
+ // ---------------------------------------------------------------------------
916
+ // Reconstructability gate (design.md decision 4 / spec "Reconstructability is
917
+ // a gate invariant") — read-only itself; the CLI decides what a non-zero
918
+ // result means. Checks the three things the spec names: contiguous seq from
919
+ // 1, a run-start, a closing run-halt/run-complete, plus a logged cli-exit for
920
+ // every wave-state/verify-judge invocation. The last one is checkable purely
921
+ // from the log's own shape because both `wave-state` (via `wave-action`) and
922
+ // `verify judge` (via `verify-judgement`) always append their `cli-exit` in
923
+ // the same call that appends the semantic event — so a 1:1 count is exactly
924
+ // what "one per invocation" means here.
925
+ // ---------------------------------------------------------------------------
926
+
927
+ /**
928
+ * @param {string} root repo root
929
+ * @param {string} runId
930
+ * @returns {{ok: boolean, runId: string, problems: string[], events: number}}
931
+ */
932
+ export function checkRunLog(root, runId) {
933
+ const read = readRunLog(root, runId)
934
+ const problems = []
935
+
936
+ if (!read.exists) {
937
+ problems.push(`no trajectory file found for run ${runId}`)
938
+ return { ok: false, runId, problems, events: 0 }
939
+ }
940
+ if (read.reason) problems.push(`trajectory unreadable: ${read.reason}`)
941
+ if (read.skipped.length) {
942
+ problems.push(`${read.skipped.length} unreadable line(s): ${read.skipped.map(s => s.line).join(', ')}`)
943
+ }
944
+
945
+ const records = read.records
946
+ if (!records.length) problems.push('trajectory has no events')
947
+
948
+ const seqs = records.map(r => r.seq).filter(n => Number.isFinite(n)).sort((a, b) => a - b)
949
+ for (let i = 0; i < seqs.length; i++) {
950
+ if (seqs[i] !== i + 1) {
951
+ problems.push(`sequence gap: expected seq ${i + 1}, found ${seqs[i]}`)
952
+ break
953
+ }
954
+ }
955
+
956
+ if (!records.some(r => r.type === 'run-start')) problems.push('missing a run-start event')
957
+ if (!records.some(r => r.type === 'run-halt' || r.type === 'run-complete')) {
958
+ problems.push('missing a run-halt or run-complete event')
959
+ }
960
+
961
+ // `run-receipt` is a valid type and deliberately NOT in the required set: a
962
+ // run that halted before reaching its own close could not have written one,
963
+ // and refusing to reconstruct such a run would withhold exactly the
964
+ // trajectory a reader most needs. Two of them is a different matter — a run
965
+ // has one close, so it has one receipt.
966
+ const receipts = records.filter(r => r.type === 'run-receipt').length
967
+ if (receipts > 1) {
968
+ problems.push(`${receipts} run-receipt events: a run has one close and therefore one receipt`)
969
+ }
970
+
971
+ const invocations = records.filter(r => r.type === 'wave-action' || r.type === 'verify-judgement').length
972
+ const exits = records.filter(r => r.type === 'cli-exit').length
973
+ if (exits < invocations) {
974
+ problems.push(`${invocations - exits} wave-state/verify-judge invocation(s) missing a cli-exit`)
975
+ }
976
+
977
+ return { ok: problems.length === 0, runId, problems, events: records.length }
978
+ }
979
+
980
+ /** Human-readable rendering of `checkRunLog`, for the CLI. */
981
+ export function formatRunLogCheck(result) {
982
+ return result.ok
983
+ ? `RECONSTRUCTABLE — run ${result.runId}, ${result.events} event(s)\n`
984
+ : `INCOMPLETE — run ${result.runId}: ${result.problems.join('; ')}\n`
985
+ }
986
+
987
+ /** Human-readable rendering of `listRunLogs`, for the CLI. */
988
+ export function formatRunLogList(runs) {
989
+ if (!runs.length) return '(no runs recorded)\n'
990
+ return (
991
+ runs
992
+ .map(
993
+ r =>
994
+ `${r.runId}\tchange=${r.change ?? '(unknown)'}\t` +
995
+ `${r.halted ? `HALTED (${r.haltReason || 'no reason recorded'})` : r.complete ? 'complete' : 'in-progress'}\t` +
996
+ `commit=${r.commit ? r.commit : r.committed === false ? 'none' : r.committed === true ? 'created' : 'unknown'}\t` +
997
+ `events=${r.events}${r.skipped ? ` skipped=${r.skipped}` : ''}` +
998
+ // Said out loud rather than left to inference: a closed run with no
999
+ // receipt did not reach its own close, which is a finding.
1000
+ `${r.receipt ? '' : '\tno receipt recorded'}`
1001
+ )
1002
+ .join('\n') + '\n'
1003
+ )
1004
+ }