@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.
- package/.dz-manifest.json +224 -104
- package/README.md +335 -10
- package/dist/agentdb-index.d.ts +87 -7
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +416 -57
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +57 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +450 -52
- package/dist/apply-leg.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +67 -5
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +13 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +13 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/codex-rollouts.d.ts +118 -0
- package/dist/codex-rollouts.d.ts.map +1 -0
- package/dist/codex-rollouts.js +297 -0
- package/dist/codex-rollouts.js.map +1 -0
- package/dist/cost-ledger.d.ts +56 -4
- package/dist/cost-ledger.d.ts.map +1 -1
- package/dist/cost-ledger.js +176 -20
- package/dist/cost-ledger.js.map +1 -1
- package/dist/cross-family-control.d.ts +345 -0
- package/dist/cross-family-control.d.ts.map +1 -0
- package/dist/cross-family-control.js +802 -0
- package/dist/cross-family-control.js.map +1 -0
- package/dist/debt-ratchet.d.ts +53 -0
- package/dist/debt-ratchet.d.ts.map +1 -0
- package/dist/debt-ratchet.js +107 -0
- package/dist/debt-ratchet.js.map +1 -0
- package/dist/embedding-config.d.ts +42 -0
- package/dist/embedding-config.d.ts.map +1 -1
- package/dist/embedding-config.js +106 -10
- package/dist/embedding-config.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +6 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +29 -0
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-decision-recall.d.ts +2 -2
- package/dist/feature-adr-decision-recall.d.ts.map +1 -1
- package/dist/feature-adr-decision-recall.js +5 -3
- package/dist/feature-adr-decision-recall.js.map +1 -1
- package/dist/feature-adr-envelope.d.ts +96 -0
- package/dist/feature-adr-envelope.d.ts.map +1 -0
- package/dist/feature-adr-envelope.js +183 -0
- package/dist/feature-adr-envelope.js.map +1 -0
- package/dist/feature-adr-routing.d.ts +64 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +122 -2
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/feature-adr-stage-canon.d.ts +79 -0
- package/dist/feature-adr-stage-canon.d.ts.map +1 -0
- package/dist/feature-adr-stage-canon.js +117 -0
- package/dist/feature-adr-stage-canon.js.map +1 -0
- package/dist/index.d.ts +23 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -7
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +4 -4
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/mutation-gate.d.ts +51 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +295 -0
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +18 -2
- package/dist/operations.js.map +1 -1
- package/dist/publish.d.ts +59 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +205 -32
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +4 -2
- package/dist/qe-bridge.js.map +1 -1
- package/dist/qe-findings.d.ts +107 -0
- package/dist/qe-findings.d.ts.map +1 -0
- package/dist/qe-findings.js +417 -0
- package/dist/qe-findings.js.map +1 -0
- package/dist/recap.d.ts +1 -1
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +4 -2
- package/dist/recap.js.map +1 -1
- package/dist/release-line.d.ts +16 -0
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +31 -0
- package/dist/release-line.js.map +1 -1
- package/dist/round.d.ts +74 -1
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +112 -4
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +60 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +244 -2
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts +44 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +78 -5
- package/dist/score.js.map +1 -1
- package/dist/vector-tier.d.ts +34 -3
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +105 -14
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +403 -103
- package/src/agentdb-index.ts +423 -60
- package/src/apply-leg.ts +469 -50
- package/src/codex-hooks-assets.ts +67 -5
- package/src/codex-hooks.ts +13 -1
- package/src/codex-rollouts.ts +374 -0
- package/src/cost-ledger.ts +232 -24
- package/src/cross-family-control.ts +960 -0
- package/src/debt-ratchet.ts +143 -0
- package/src/embedding-config.ts +131 -10
- package/src/feature-adr-checkpoints.ts +29 -0
- package/src/feature-adr-decision-recall.ts +6 -4
- package/src/feature-adr-envelope.ts +242 -0
- package/src/feature-adr-routing.ts +139 -2
- package/src/feature-adr-stage-canon.ts +141 -0
- package/src/index.ts +66 -7
- package/src/loop-blobs.generated.ts +4 -4
- package/src/mutation-gate.ts +316 -0
- package/src/operations.ts +18 -3
- package/src/publish.ts +247 -30
- package/src/qe-bridge.ts +4 -2
- package/src/qe-findings.ts +463 -0
- package/src/recap.ts +10 -3
- package/src/release-line.ts +32 -0
- package/src/round.ts +165 -6
- package/src/run-records.ts +282 -2
- package/src/score.ts +115 -6
- 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
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
374
|
-
|
|
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');
|
package/src/codex-hooks.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|