@dzhechkov/harness-core 0.8.35 → 0.8.37

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 (135) hide show
  1. package/.dz-manifest.json +224 -104
  2. package/README.md +335 -10
  3. package/dist/agentdb-index.d.ts +87 -7
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +416 -57
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +57 -1
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +450 -52
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/codex-hooks-assets.d.ts.map +1 -1
  12. package/dist/codex-hooks-assets.js +67 -5
  13. package/dist/codex-hooks-assets.js.map +1 -1
  14. package/dist/codex-hooks.d.ts +13 -1
  15. package/dist/codex-hooks.d.ts.map +1 -1
  16. package/dist/codex-hooks.js +13 -1
  17. package/dist/codex-hooks.js.map +1 -1
  18. package/dist/codex-rollouts.d.ts +118 -0
  19. package/dist/codex-rollouts.d.ts.map +1 -0
  20. package/dist/codex-rollouts.js +297 -0
  21. package/dist/codex-rollouts.js.map +1 -0
  22. package/dist/cost-ledger.d.ts +56 -4
  23. package/dist/cost-ledger.d.ts.map +1 -1
  24. package/dist/cost-ledger.js +176 -20
  25. package/dist/cost-ledger.js.map +1 -1
  26. package/dist/cross-family-control.d.ts +345 -0
  27. package/dist/cross-family-control.d.ts.map +1 -0
  28. package/dist/cross-family-control.js +802 -0
  29. package/dist/cross-family-control.js.map +1 -0
  30. package/dist/debt-ratchet.d.ts +53 -0
  31. package/dist/debt-ratchet.d.ts.map +1 -0
  32. package/dist/debt-ratchet.js +107 -0
  33. package/dist/debt-ratchet.js.map +1 -0
  34. package/dist/embedding-config.d.ts +42 -0
  35. package/dist/embedding-config.d.ts.map +1 -1
  36. package/dist/embedding-config.js +106 -10
  37. package/dist/embedding-config.js.map +1 -1
  38. package/dist/feature-adr-checkpoints.d.ts +6 -0
  39. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  40. package/dist/feature-adr-checkpoints.js +29 -0
  41. package/dist/feature-adr-checkpoints.js.map +1 -1
  42. package/dist/feature-adr-decision-recall.d.ts +2 -2
  43. package/dist/feature-adr-decision-recall.d.ts.map +1 -1
  44. package/dist/feature-adr-decision-recall.js +5 -3
  45. package/dist/feature-adr-decision-recall.js.map +1 -1
  46. package/dist/feature-adr-envelope.d.ts +96 -0
  47. package/dist/feature-adr-envelope.d.ts.map +1 -0
  48. package/dist/feature-adr-envelope.js +183 -0
  49. package/dist/feature-adr-envelope.js.map +1 -0
  50. package/dist/feature-adr-routing.d.ts +64 -0
  51. package/dist/feature-adr-routing.d.ts.map +1 -1
  52. package/dist/feature-adr-routing.js +122 -2
  53. package/dist/feature-adr-routing.js.map +1 -1
  54. package/dist/feature-adr-stage-canon.d.ts +79 -0
  55. package/dist/feature-adr-stage-canon.d.ts.map +1 -0
  56. package/dist/feature-adr-stage-canon.js +117 -0
  57. package/dist/feature-adr-stage-canon.js.map +1 -0
  58. package/dist/index.d.ts +23 -12
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +15 -7
  61. package/dist/index.js.map +1 -1
  62. package/dist/loop-blobs.generated.js +4 -4
  63. package/dist/loop-blobs.generated.js.map +1 -1
  64. package/dist/mutation-gate.d.ts +51 -0
  65. package/dist/mutation-gate.d.ts.map +1 -1
  66. package/dist/mutation-gate.js +295 -0
  67. package/dist/mutation-gate.js.map +1 -1
  68. package/dist/operations.d.ts +1 -0
  69. package/dist/operations.d.ts.map +1 -1
  70. package/dist/operations.js +18 -2
  71. package/dist/operations.js.map +1 -1
  72. package/dist/publish.d.ts +59 -7
  73. package/dist/publish.d.ts.map +1 -1
  74. package/dist/publish.js +205 -32
  75. package/dist/publish.js.map +1 -1
  76. package/dist/qe-bridge.d.ts.map +1 -1
  77. package/dist/qe-bridge.js +4 -2
  78. package/dist/qe-bridge.js.map +1 -1
  79. package/dist/qe-findings.d.ts +107 -0
  80. package/dist/qe-findings.d.ts.map +1 -0
  81. package/dist/qe-findings.js +417 -0
  82. package/dist/qe-findings.js.map +1 -0
  83. package/dist/recap.d.ts +1 -1
  84. package/dist/recap.d.ts.map +1 -1
  85. package/dist/recap.js +4 -2
  86. package/dist/recap.js.map +1 -1
  87. package/dist/release-line.d.ts +16 -0
  88. package/dist/release-line.d.ts.map +1 -1
  89. package/dist/release-line.js +31 -0
  90. package/dist/release-line.js.map +1 -1
  91. package/dist/round.d.ts +74 -1
  92. package/dist/round.d.ts.map +1 -1
  93. package/dist/round.js +112 -4
  94. package/dist/round.js.map +1 -1
  95. package/dist/run-records.d.ts +60 -0
  96. package/dist/run-records.d.ts.map +1 -1
  97. package/dist/run-records.js +244 -2
  98. package/dist/run-records.js.map +1 -1
  99. package/dist/score.d.ts +44 -1
  100. package/dist/score.d.ts.map +1 -1
  101. package/dist/score.js +78 -5
  102. package/dist/score.js.map +1 -1
  103. package/dist/vector-tier.d.ts +34 -3
  104. package/dist/vector-tier.d.ts.map +1 -1
  105. package/dist/vector-tier.js +105 -14
  106. package/dist/vector-tier.js.map +1 -1
  107. package/package.json +2 -2
  108. package/sbom.json +403 -103
  109. package/src/agentdb-index.ts +423 -60
  110. package/src/apply-leg.ts +469 -50
  111. package/src/codex-hooks-assets.ts +67 -5
  112. package/src/codex-hooks.ts +13 -1
  113. package/src/codex-rollouts.ts +374 -0
  114. package/src/cost-ledger.ts +232 -24
  115. package/src/cross-family-control.ts +960 -0
  116. package/src/debt-ratchet.ts +143 -0
  117. package/src/embedding-config.ts +131 -10
  118. package/src/feature-adr-checkpoints.ts +29 -0
  119. package/src/feature-adr-decision-recall.ts +6 -4
  120. package/src/feature-adr-envelope.ts +242 -0
  121. package/src/feature-adr-routing.ts +139 -2
  122. package/src/feature-adr-stage-canon.ts +141 -0
  123. package/src/index.ts +66 -7
  124. package/src/loop-blobs.generated.ts +4 -4
  125. package/src/mutation-gate.ts +316 -0
  126. package/src/operations.ts +18 -3
  127. package/src/publish.ts +247 -30
  128. package/src/qe-bridge.ts +4 -2
  129. package/src/qe-findings.ts +463 -0
  130. package/src/recap.ts +10 -3
  131. package/src/release-line.ts +32 -0
  132. package/src/round.ts +165 -6
  133. package/src/run-records.ts +282 -2
  134. package/src/score.ts +115 -6
  135. package/src/vector-tier.ts +127 -14
@@ -141,6 +141,66 @@ function findProjectRoot(startDir) {
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * FR-1 (codex-hook-root-provenance): the ONE place both hooks compute their start directory and
146
+ * walk to a project root — replacing two independent copies of the same ternary. T1 (fix-round 1:
147
+ * the original reproducer had an unexported \`BASE\`, so it measured the wrong file; corrected and
148
+ * re-run — see the feature's change manifest for both) measured LIVE on \`codex-cli 0.154.0\`: three
149
+ * real \`codex exec\` sessions (project root, a nested subdirectory, a directory with no \`.dz\`
150
+ * anywhere in its ancestry), each with BOTH hook events (\`PreToolUse\` and \`UserPromptSubmit\`)
151
+ * captured SEPARATELY. \`payload.cwd\` was present and equal to both \`PWD\` and the hook's own
152
+ * \`process.cwd()\` in every one of the 6 captures. \`PWD\`/\`process.cwd()\` therefore stay only as a
153
+ * DEFENSIVE fallback for a payload shaped without \`cwd\` — not because that fallback was ever
154
+ * observed to fire. This is a SCOPED finding, not a claim that the "hook read the wrong project"
155
+ * defect class cannot exist: it was not observed on codex-cli 0.154.0 across these 3 scenarios / 6
156
+ * captures, and 01_requirements.md's own Ограничение C-3 is what permits cutting FR-3 (the explicit
157
+ * \`DZ_PROJECT_ROOT\` override) on a scoped finding like that — not a claim of nonexistence.
158
+ */
159
+ function resolveHookRoot(payload) {
160
+ const hasPayloadCwd = typeof payload.cwd === 'string' && payload.cwd !== '';
161
+ const startDir = hasPayloadCwd ? payload.cwd : (process.env.PWD || process.cwd());
162
+ const source = hasPayloadCwd ? 'payload-cwd' : (process.env.PWD ? 'env-pwd' : 'process-cwd');
163
+ return { root: findProjectRoot(startDir), source, startDir };
164
+ }
165
+
166
+ /**
167
+ * Fix-round 1, item 4: a path interpolated into the provenance line below can itself carry control
168
+ * characters — a \`payload.cwd\` from an untrusted producer, or a \`PWD\` set to something hostile —
169
+ * and a bare newline in the middle of it would defeat the "ONE line" promise the diagnostic makes.
170
+ * Escape the whole C0 range (0x00-0x1F) plus DEL (0x7F) into a visible \`\\n\`/\\r\`/\\t\`/\\xHH\`
171
+ * representation; every other byte, including non-ASCII path segments, passes through unchanged.
172
+ */
173
+ function escapeControlChars(value) {
174
+ return String(value).replace(/[\\x00-\\x1f\\x7f]/g, function (ch) {
175
+ var code = ch.charCodeAt(0);
176
+ if (code === 10) return '\\\\n';
177
+ if (code === 13) return '\\\\r';
178
+ if (code === 9) return '\\\\t';
179
+ return '\\\\x' + code.toString(16).padStart(2, '0');
180
+ });
181
+ }
182
+
183
+ /**
184
+ * FR-2: ONE provenance line, same shape as the Claude hook's (\`apply-leg.ts\`'s \`skip()\`), printed
185
+ * to stderr. When no root was found it ALWAYS prints (the walk's whole verdict was silent before
186
+ * this feature); when a root WAS found it prints only under \`DZ_CODEX_HOOK_DEBUG\`, so the found
187
+ * path stays byte-for-byte silent by default (NFR-2). Tradeoff, named plainly: this line discloses
188
+ * the absolute directory the hook was asked about (which can embed a username, a customer or
189
+ * repository name) to stderr — accepted because it is a diagnostic aimed at the person running the
190
+ * hook, not a return value, and redacting it would make the not-found case as silent as the bug
191
+ * this feature exists to fix. \`startDir\`/\`root\` are escaped via \`escapeControlChars\` first, so an
192
+ * adversarial value cannot itself defeat the "ONE line" guarantee.
193
+ */
194
+ function reportRootProvenance(resolved) {
195
+ if (resolved.root === null) {
196
+ process.stderr.write(\`[\${HELPER}] skipped reason=no-project-root start=\${escapeControlChars(resolved.startDir)} (\${resolved.source})\\n\`);
197
+ return;
198
+ }
199
+ if (process.env.DZ_CODEX_HOOK_DEBUG) {
200
+ process.stderr.write(\`[\${HELPER}] root=\${escapeControlChars(resolved.root)} start=\${escapeControlChars(resolved.startDir)} (\${resolved.source})\\n\`);
201
+ }
202
+ }
203
+
144
204
  function readProjectConfig(root) {
145
205
  try {
146
206
  return JSON.parse(fs.readFileSync(path.join(root, '.dz', 'config.json'), 'utf8'));
@@ -229,9 +289,10 @@ async function main() {
229
289
  const command = input && typeof input === 'object' ? input.command : undefined;
230
290
  if (typeof command !== 'string' || command === '') return 0;
231
291
 
232
- const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : process.env.PWD || process.cwd();
233
- const root = findProjectRoot(cwd);
234
- if (root === null) return 0; // inert outside an opted-in dz project: no decision, no output, no write
292
+ const resolved = resolveHookRoot(payload);
293
+ reportRootProvenance(resolved);
294
+ const root = resolved.root;
295
+ if (root === null) return 0; // inert outside an opted-in dz project: no DECISION and no WRITE — one diagnostic line on stderr (FR-2), nothing else
235
296
 
236
297
  // (1) The destructive-command guard. Never blocks on our own failure: an absent module, a throw,
237
298
  // or an \`undecidable\` verdict all fall through to the shell veto below (AC-10).
@@ -370,8 +431,9 @@ async function main() {
370
431
  const prompt = typeof payload.prompt === 'string' ? payload.prompt : '';
371
432
  if (prompt.trim() === '') return;
372
433
 
373
- const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : process.env.PWD || process.cwd();
374
- const root = findProjectRoot(cwd);
434
+ const resolved = resolveHookRoot(payload);
435
+ reportRootProvenance(resolved);
436
+ const root = resolved.root;
375
437
  if (root === null) return; // inert outside an opted-in dz project
376
438
 
377
439
  const policy = await loadCore(root, 'recall-hook-policy.js', (m) => typeof m.selectHookHits === 'function');
@@ -56,8 +56,20 @@ import { mergeManagedHookEntries } from './managed-hooks.js';
56
56
  * run was indistinguishable from a clean allow. The Claude hook already failed open loudly here.
57
57
  * Now it prints ONE line, `DZ-DESTRUCTIVE-WARN: classifier threw — <message>`, and still exits 0.
58
58
  * A changed body ⇒ re-trust.
59
+ * 8 — `codex-hook-root-provenance`: both hooks now share ONE `resolveHookRoot(payload)` instead of
60
+ * two copies of the same `payload.cwd || PWD || cwd()` ternary, and a silent `root === null` early
61
+ * return now prints one provenance line (`[dz-codex-<hook>] skipped reason=no-project-root
62
+ * start=<startDir> (<source>)`); the found-root path stays silent unless `DZ_CODEX_HOOK_DEBUG` is
63
+ * set. T1 (live probe, codex-cli 0.154.0) found `payload.cwd` always present and equal to `PWD`/
64
+ * `process.cwd()`, so no explicit-override knob was added. A changed body ⇒ re-trust.
65
+ * 9 — fix-round 1: the provenance line's interpolated paths are now escaped via
66
+ * `escapeControlChars` (C0 range + DEL) before printing, so a hostile `payload.cwd` cannot defeat
67
+ * the "ONE line" promise with an embedded newline; the corrected T1 re-run (both hook events
68
+ * captured separately, per-scenario — the original reproducer's `BASE` was never exported) reached
69
+ * the SAME conclusion, scoped honestly as "not observed on codex-cli 0.154.0 across 3 scenarios / 6
70
+ * captures", not "does not exist". A changed body ⇒ re-trust.
59
71
  */
60
- export const DZ_HOOK_HELPER_VERSION = 7;
72
+ export const DZ_HOOK_HELPER_VERSION = 9;
61
73
 
62
74
  /** Seconds. Probe-proven (spike S2): `timeout` is honored, the unset default is 600 s. */
63
75
  export const DZ_HOOK_TIMEOUT_SECONDS = 5;
@@ -0,0 +1,374 @@
1
+ /**
2
+ * A pure reader for Codex CLI rollout logs (feature `measurement-integrity`, ADR-001 D3).
3
+ *
4
+ * A `dz feature-adr-record --kind ledger` row for a Codex coder/reviewer stage carries
5
+ * `tokens: null` in 130 of 156 recorded rows (Step 0, 2026-09-16) even though the spend is sitting
6
+ * right there on disk: Codex writes one JSONL file per session at
7
+ * `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`, and nothing in the pipeline reads it. The
8
+ * pipeline dispatches Codex without an explicit session id (`codex exec -C <repo> -m <id> …`), so the
9
+ * only way to join a ledger row to the rollout that produced it is a WINDOW match: the stage's own
10
+ * start/end time, its `cwd`, and its model.
11
+ *
12
+ * PURE — this module never opens `~/.codex/sessions` itself; the CLI reads the files and hands their
13
+ * TEXT to {@link parseCodexRollout}. It must never gain a `node:fs` import (the `core-boundary`
14
+ * ratchet, `test/core-boundary.test.ts`, pins the current file/import count).
15
+ *
16
+ * ## A measured schema correction (read before touching the parser)
17
+ *
18
+ * Step 0's assessment described the usage record as `type: "token_count"`, keyed
19
+ * `payload.info.total_token_usage`. A live probe of this machine's `~/.codex/sessions` (2026-09-16,
20
+ * `cli_version: "0.154.0"`, every rollout from the last two days) found NO such record — the CURRENT
21
+ * shape is `type: "token_usage_record"`, keyed `payload.usage`, with the same five sub-fields
22
+ * (`input_tokens`, `cached_input_tokens`, `output_tokens`, `reasoning_output_tokens`,
23
+ * `total_tokens`). The model id lives on `type: "turn_context"`'s `payload.model` (not on
24
+ * `session_meta`, as Step 0 assumed), and `cwd` is carried by BOTH `session_meta.payload.cwd` and
25
+ * `turn_context.payload.cwd`. Rather than build against a shape that no longer exists on this
26
+ * machine, {@link parseCodexRollout} accepts BOTH the documented legacy shape and the measured
27
+ * current one — Codex CLI versions drift the schema (C-2: this module depends on no version beyond
28
+ * the fields it reads), and a reader that understands only a shape nothing on disk still emits would
29
+ * fail FR-5 at the exact thing it exists to fix.
30
+ *
31
+ * @packageDocumentation
32
+ */
33
+
34
+ interface RawRecord {
35
+ readonly type?: unknown;
36
+ readonly timestamp?: unknown;
37
+ readonly ts?: unknown;
38
+ readonly payload?: unknown;
39
+ }
40
+
41
+ function isRecord(v: unknown): v is Record<string, unknown> {
42
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
43
+ }
44
+
45
+ function nonEmptyString(v: unknown): string | null {
46
+ return typeof v === 'string' && v.length > 0 ? v : null;
47
+ }
48
+
49
+ function finiteNonNegative(v: unknown): number {
50
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0;
51
+ }
52
+
53
+ /** Epoch ms from a record's own `timestamp` (current schema) or `ts` (legacy/defensive), or `null`. */
54
+ function recordTimeMs(rec: Record<string, unknown>): number | null {
55
+ const raw = rec['timestamp'] ?? rec['ts'];
56
+ if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
57
+ if (typeof raw === 'string') {
58
+ const ms = Date.parse(raw);
59
+ return Number.isFinite(ms) ? ms : null;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function isoOrNull(ms: number | null): string | null {
65
+ if (ms === null || !Number.isFinite(ms) || Math.abs(ms) > 8.64e15) return null;
66
+ try {
67
+ return new Date(ms).toISOString();
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export interface CodexRolloutTotals {
74
+ readonly input: number;
75
+ readonly cachedInput: number;
76
+ readonly output: number;
77
+ readonly reasoning: number;
78
+ readonly total: number;
79
+ }
80
+
81
+ /**
82
+ * measurement-integrity fix-round-1/F5 (Codex r1 HIGH #5): one TURN of a session — the span between
83
+ * one `turn_context` record and the next (or the file's last record, for the final turn). A turn
84
+ * carries its OWN model/cwd (from ITS `turn_context`) and, when a usage-bearing record (`token_count`
85
+ * / `token_usage_record`) was seen while this turn was current, that record's totals — `null` when no
86
+ * such record fell inside this turn's interval (nothing to attribute to it).
87
+ */
88
+ export interface CodexRolloutTurn {
89
+ readonly model: string | null;
90
+ readonly cwd: string | null;
91
+ readonly startedAt: string | null;
92
+ readonly endedAt: string | null;
93
+ readonly totals: CodexRolloutTotals | null;
94
+ }
95
+
96
+ export interface CodexRollout {
97
+ readonly id: string;
98
+ readonly cwd: string | null;
99
+ readonly model: string | null;
100
+ /** ISO, or `null` when no record in the file carried a parseable timestamp. */
101
+ readonly startedAt: string | null;
102
+ readonly endedAt: string | null;
103
+ readonly totals: CodexRolloutTotals;
104
+ /** measurement-integrity fix-round-1/F5: `'turn'` when the file carried at least one `turn_context`
105
+ * record (the measured current schema always does) — {@link matchCodexRollouts} then matches at
106
+ * TURN granularity, never against this whole session's wide interval. `'session'` when the schema
107
+ * gave no turn boundaries at all (the legacy shape Step 0 documented) — matching honestly falls
108
+ * back to the whole-session interval, and that fact travels with the result rather than being
109
+ * silently assumed away. */
110
+ readonly granularity: 'turn' | 'session';
111
+ /** turns whose open or close boundary carried no timestamp — reported, never matched. */
112
+ readonly unmatchableTurns: number;
113
+ /** Empty when `granularity === 'session'`. */
114
+ readonly turns: readonly CodexRolloutTurn[];
115
+ }
116
+
117
+ export interface CodexRolloutParseError {
118
+ readonly error: string;
119
+ }
120
+
121
+ /** Pull `{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens, total_tokens}`
122
+ * (both schemas use these five field names) out of a usage-bearing sub-object. */
123
+ function totalsFrom(usage: Record<string, unknown>): CodexRolloutTotals {
124
+ return {
125
+ input: finiteNonNegative(usage['input_tokens']),
126
+ cachedInput: finiteNonNegative(usage['cached_input_tokens']),
127
+ output: finiteNonNegative(usage['output_tokens']),
128
+ reasoning: finiteNonNegative(usage['reasoning_output_tokens']),
129
+ total: finiteNonNegative(usage['total_tokens']),
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Parse ONE rollout file's full text into a {@link CodexRollout}. Pure, never-throws; a corrupt line
135
+ * is skipped exactly the way `extractCostSamples` (`cost-ledger.ts`) skips one.
136
+ *
137
+ * `fileName`, when given, is used ONLY as a last-resort `id` source (the `rollout-<ts>-<uuid>.jsonl`
138
+ * name's own uuid) when no `session_meta` record carried one — never trusted over the file's own
139
+ * content.
140
+ */
141
+ export function parseCodexRollout(text: string, fileName?: string): CodexRollout | CodexRolloutParseError {
142
+ if (typeof text !== 'string' || text.trim().length === 0) {
143
+ return { error: 'empty rollout text' };
144
+ }
145
+
146
+ let id: string | null = null;
147
+ let cwd: string | null = null;
148
+ let sessionMetaModel: string | null = null;
149
+ let turnContextModel: string | null = null;
150
+ let firstMs: number | null = null;
151
+ let lastMs: number | null = null;
152
+ let lastTotals: CodexRolloutTotals | null = null;
153
+ let sawAnyRecord = false;
154
+
155
+ // measurement-integrity fix-round-1/F5 (Codex r1 HIGH #5): each `turn_context` record OPENS a new
156
+ // turn, in file order. `open` is the turn currently being built; `turns` collects CLOSED ones. A
157
+ // turn closes when the NEXT `turn_context` is seen (its `endedAt` is that boundary's own
158
+ // timestamp) or, for the LAST open turn, at end-of-file (`endedAt` = the last record's timestamp).
159
+ // A usage-bearing record is attached to whichever turn is open at its own timestamp — `null` stays
160
+ // on a turn that never saw one, so a caller can tell "nothing to attribute here" from "attributed
161
+ // zero".
162
+ // Lead delta after Codex r2 (#5 PARTIAL, new HIGH #1): a turn's totals are the DELTA of the
163
+ // session-cumulative usage between its open and close (the record's own usage counter is
164
+ // cumulative for the session — assigning the last cumulative total to a turn made the second turn
165
+ // carry the first one's tokens). `startedMs: null` marks a turn opened by a `turn_context` WITHOUT
166
+ // a timestamp: it still closes the previous turn (so no usage can leak into it) but can never be
167
+ // matched to a window — the rollout reports it under `unmatchableTurns`.
168
+ interface OpenTurn { model: string | null; cwd: string | null; startedMs: number | null; baseline: CodexRolloutTotals | null; totals: CodexRolloutTotals | null }
169
+ const closedTurns: CodexRolloutTurn[] = [];
170
+ let open: OpenTurn | null = null;
171
+
172
+ let unmatchableTurns = 0;
173
+ const closeOpenTurn = (endMs: number | null): void => {
174
+ if (open === null) return;
175
+ if (open.startedMs === null || endMs === null) unmatchableTurns += 1;
176
+ closedTurns.push({
177
+ model: open.model,
178
+ cwd: open.cwd,
179
+ startedAt: open.startedMs === null ? null : isoOrNull(open.startedMs),
180
+ endedAt: endMs === null ? null : isoOrNull(endMs),
181
+ totals: open.totals,
182
+ });
183
+ };
184
+ const deltaTotals = (now: CodexRolloutTotals, base: CodexRolloutTotals | null): CodexRolloutTotals => {
185
+ if (base === null) return now;
186
+ const d = (a: number, b: number): number => (a - b >= 0 ? a - b : a); // a counter that went DOWN is per-record, not cumulative
187
+ return { input: d(now.input, base.input), cachedInput: d(now.cachedInput, base.cachedInput), output: d(now.output, base.output), reasoning: d(now.reasoning, base.reasoning), total: d(now.total, base.total) };
188
+ };
189
+
190
+ for (const line of text.split('\n')) {
191
+ if (line.length === 0) continue;
192
+ let rec: unknown;
193
+ try {
194
+ rec = JSON.parse(line);
195
+ } catch {
196
+ continue; // corrupt line — skip, never throw
197
+ }
198
+ if (!isRecord(rec)) continue;
199
+ sawAnyRecord = true;
200
+
201
+ const ms = recordTimeMs(rec);
202
+ if (ms !== null) {
203
+ firstMs = firstMs === null ? ms : Math.min(firstMs, ms);
204
+ lastMs = lastMs === null ? ms : Math.max(lastMs, ms);
205
+ }
206
+
207
+ const type = rec['type'];
208
+ const payload = isRecord(rec['payload']) ? rec['payload'] : null;
209
+ if (payload === null) continue;
210
+
211
+ if (type === 'session_meta') {
212
+ if (id === null) id = nonEmptyString(payload['session_id']) ?? nonEmptyString(payload['id']);
213
+ if (cwd === null) cwd = nonEmptyString(payload['cwd']);
214
+ // Step 0's documented (legacy, not observed live on this machine) shape put `model` directly on
215
+ // `session_meta` — accepted here too, but `turnContextModel` always wins at the end (below)
216
+ // since that is what the measured current schema actually carries.
217
+ if (sessionMetaModel === null) sessionMetaModel = nonEmptyString(payload['model']);
218
+ } else if (type === 'turn_context') {
219
+ if (turnContextModel === null) turnContextModel = nonEmptyString(payload['model']);
220
+ if (cwd === null) cwd = nonEmptyString(payload['cwd']);
221
+ // Close the previous open turn AT this boundary (even when the boundary has no timestamp —
222
+ // the previous turn must stop absorbing usage), then open the new one.
223
+ closeOpenTurn(ms);
224
+ open = { model: nonEmptyString(payload['model']), cwd: nonEmptyString(payload['cwd']) ?? cwd, startedMs: ms, baseline: lastTotals, totals: null };
225
+ }
226
+
227
+ // Legacy shape (Step 0's documented one, not observed live on this machine 2026-09-16):
228
+ // `type: "token_count"`, `payload.info.total_token_usage`.
229
+ if (type === 'token_count') {
230
+ const info = isRecord(payload['info']) ? payload['info'] : null;
231
+ const usage = info !== null && isRecord(info['total_token_usage']) ? info['total_token_usage'] : null;
232
+ if (usage !== null) {
233
+ const t = totalsFrom(usage);
234
+ if (open !== null) open.totals = deltaTotals(t, open.baseline);
235
+ lastTotals = t;
236
+ }
237
+ }
238
+ // Current shape (measured live, cli_version 0.154.0): `type: "token_usage_record"`,
239
+ // `payload.usage`.
240
+ if (type === 'token_usage_record') {
241
+ const usage = isRecord(payload['usage']) ? payload['usage'] : null;
242
+ if (usage !== null) {
243
+ const t = totalsFrom(usage);
244
+ if (open !== null) open.totals = deltaTotals(t, open.baseline);
245
+ lastTotals = t;
246
+ }
247
+ }
248
+ }
249
+ if (open !== null && lastMs !== null) closeOpenTurn(lastMs);
250
+
251
+ if (!sawAnyRecord) return { error: 'no parseable JSON lines in rollout text' };
252
+ if (id === null) {
253
+ // Last resort: the uuid embedded in `rollout-<ts>-<uuid>.jsonl` — never invented, only read back.
254
+ // A plain "greedy dash" regex would stop at the uuid's OWN internal dashes (its 8-4-4-4-12 hex
255
+ // groups), so this matches the canonical uuid shape explicitly rather than "everything after the
256
+ // last dash".
257
+ const m = typeof fileName === 'string'
258
+ ? /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$/.exec(fileName)
259
+ : null;
260
+ id = m !== null ? (m[1] ?? null) : null;
261
+ }
262
+ if (id === null) return { error: 'no session_meta record and no id in fileName — cannot identify this rollout' };
263
+ if (lastTotals === null) {
264
+ return { error: 'no token_count or token_usage_record entry — nothing to attribute' };
265
+ }
266
+
267
+ return {
268
+ id,
269
+ cwd,
270
+ model: turnContextModel ?? sessionMetaModel,
271
+ startedAt: isoOrNull(firstMs),
272
+ endedAt: isoOrNull(lastMs),
273
+ totals: lastTotals,
274
+ granularity: closedTurns.length > 0 ? 'turn' : 'session',
275
+ unmatchableTurns,
276
+ turns: closedTurns,
277
+ };
278
+ }
279
+
280
+ export type CodexRolloutMatch =
281
+ | { readonly status: 'none' }
282
+ | { readonly status: 'one'; readonly rollout: CodexRollout }
283
+ | { readonly status: 'ambiguous'; readonly candidates: readonly CodexRollout[] };
284
+
285
+ export interface CodexRolloutMatchWindow {
286
+ /** ISO instant — the window's lower bound. */
287
+ readonly from: string;
288
+ /** ISO instant — the window's upper bound. */
289
+ readonly to: string;
290
+ /** Exact match against {@link CodexRollout.cwd}, when given. */
291
+ readonly cwd?: string;
292
+ /** Exact match against {@link CodexRollout.model}, when given. */
293
+ readonly model?: string;
294
+ }
295
+
296
+ /**
297
+ * measurement-integrity fix-round-1/F5 (Codex r1 HIGH #5): every candidate window `matchCodexRollouts`
298
+ * may attribute spend to, at the SHARPEST granularity `parseCodexRollout` could recover from the
299
+ * file. For a `granularity: 'turn'` rollout this is one candidate PER TURN THAT ACTUALLY CARRIES
300
+ * USAGE (a turn nothing was ever attributed to yields no candidate — there is nothing honest to
301
+ * report for it); for `granularity: 'session'` it is exactly one candidate, the whole file, exactly
302
+ * as this reader behaved before this fix.
303
+ *
304
+ * This is the fix for the CRITICAL scenario the Codex review named: the OLD matcher tested the
305
+ * whole session's `[startedAt, endedAt]` against the query window, so ANY brief overlap with that wide
306
+ * interval could attribute an entire multi-turn session's cumulative spend (and, potentially, another
307
+ * turn's DIFFERENT model) to one stage. Scoping candidates to turns means two turns of the SAME
308
+ * session that only one of them overlaps the window can no longer collide — and two turns that BOTH
309
+ * overlap it correctly produce two candidates, which the caller below turns into `ambiguous` rather
310
+ * than an arbitrary pick (this is also where "the model of every usage-bearing turn matching a window
311
+ * must agree" ends up enforced: two turns with different models can only both match by being two
312
+ * SEPARATE candidates, which is ambiguous by construction — there is no path where a mismatch is
313
+ * silently resolved to one of them).
314
+ */
315
+ function candidateViewsOf(r: CodexRollout): readonly CodexRollout[] {
316
+ if (r.granularity === 'session') return [r];
317
+ const out: CodexRollout[] = [];
318
+ for (const turn of r.turns) {
319
+ if (turn.totals === null) continue; // nothing was ever attributed to this turn — not a candidate
320
+ out.push({
321
+ id: r.id,
322
+ unmatchableTurns: r.unmatchableTurns,
323
+ cwd: turn.cwd,
324
+ model: turn.model,
325
+ startedAt: turn.startedAt,
326
+ endedAt: turn.endedAt,
327
+ totals: turn.totals,
328
+ granularity: 'turn',
329
+ turns: [turn],
330
+ });
331
+ }
332
+ return out;
333
+ }
334
+
335
+ /**
336
+ * Which candidate VIEWS (session-level, or — per {@link candidateViewsOf} — turn-level whenever the
337
+ * schema recovered turn boundaries) have an interval that OVERLAPS the given `[from, to]` window
338
+ * (never nearest-in-time — ADR-001 D3 rejects "closest by clock" because two reviews back to back
339
+ * would attribute one's spend to the other). A candidate with no parseable timestamps never matches —
340
+ * an unattributable interval is not a wildcard.
341
+ *
342
+ * `0` matches → `{status:'none'}`. `1` → `{status:'one', rollout}`. `>1` → `{status:'ambiguous',
343
+ * candidates}` — NEVER an arbitrary pick of "the first" (NFR-3). `>1` also covers the case where two
344
+ * DIFFERENT turns (of the same or different rollouts) overlap the window with different models — that
345
+ * disagreement can never resolve to a lone `'one'`, it always surfaces as `'ambiguous'`.
346
+ */
347
+ export function matchCodexRollouts(
348
+ rollouts: readonly CodexRollout[],
349
+ window: CodexRolloutMatchWindow,
350
+ ): CodexRolloutMatch {
351
+ const fromMs = Date.parse(window.from);
352
+ const toMs = Date.parse(window.to);
353
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs > toMs) return { status: 'none' };
354
+
355
+ const candidates: CodexRollout[] = [];
356
+ for (const r of rollouts) {
357
+ for (const view of candidateViewsOf(r)) {
358
+ if (view.startedAt === null || view.endedAt === null) continue;
359
+ const startMs = Date.parse(view.startedAt);
360
+ const endMs = Date.parse(view.endedAt);
361
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
362
+ // Lead delta after Codex r2 (#5): the turn must START inside the window — a turn that merely
363
+ // brushes the window's edge (any-overlap) is exactly how a neighbouring dispatch's turn leaks in.
364
+ if (startMs < fromMs || startMs > toMs) continue;
365
+ if (window.cwd !== undefined && view.cwd !== window.cwd) continue;
366
+ if (window.model !== undefined && view.model !== window.model) continue;
367
+ candidates.push(view);
368
+ }
369
+ }
370
+
371
+ if (candidates.length === 0) return { status: 'none' };
372
+ if (candidates.length === 1) return { status: 'one', rollout: candidates[0]! };
373
+ return { status: 'ambiguous', candidates };
374
+ }