@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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
package/lib/run-log.mjs
ADDED
|
@@ -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
|
+
}
|