@dzhechkov/harness-core 0.8.45 → 0.8.47

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 (159) hide show
  1. package/.dz-manifest.json +206 -174
  2. package/README.md +136 -8
  3. package/dist/agentdb-index.d.ts.map +1 -1
  4. package/dist/agentdb-index.js +32 -2
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/agentdb-snapshot.d.ts +14 -1
  7. package/dist/agentdb-snapshot.d.ts.map +1 -1
  8. package/dist/agentdb-snapshot.js +100 -4
  9. package/dist/agentdb-snapshot.js.map +1 -1
  10. package/dist/apply-leg.d.ts +9 -1
  11. package/dist/apply-leg.d.ts.map +1 -1
  12. package/dist/apply-leg.js +47 -10
  13. package/dist/apply-leg.js.map +1 -1
  14. package/dist/architecture.d.ts +0 -5
  15. package/dist/architecture.d.ts.map +1 -1
  16. package/dist/architecture.js +25 -2
  17. package/dist/architecture.js.map +1 -1
  18. package/dist/codex-rollouts.d.ts +35 -7
  19. package/dist/codex-rollouts.d.ts.map +1 -1
  20. package/dist/codex-rollouts.js +201 -113
  21. package/dist/codex-rollouts.js.map +1 -1
  22. package/dist/cost-ledger.d.ts +42 -3
  23. package/dist/cost-ledger.d.ts.map +1 -1
  24. package/dist/cost-ledger.js +478 -15
  25. package/dist/cost-ledger.js.map +1 -1
  26. package/dist/feature-adr-routing.d.ts +16 -0
  27. package/dist/feature-adr-routing.d.ts.map +1 -1
  28. package/dist/feature-adr-routing.js +21 -0
  29. package/dist/feature-adr-routing.js.map +1 -1
  30. package/dist/guard.d.ts +7 -0
  31. package/dist/guard.d.ts.map +1 -1
  32. package/dist/guard.js +48 -0
  33. package/dist/guard.js.map +1 -1
  34. package/dist/index.d.ts +13 -6
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +13 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/loop-plan.d.ts +4 -0
  39. package/dist/loop-plan.d.ts.map +1 -1
  40. package/dist/loop-plan.js +4 -0
  41. package/dist/loop-plan.js.map +1 -1
  42. package/dist/mutation-gate.d.ts +36 -1
  43. package/dist/mutation-gate.d.ts.map +1 -1
  44. package/dist/mutation-gate.js +109 -19
  45. package/dist/mutation-gate.js.map +1 -1
  46. package/dist/npm-homepage.d.ts +64 -0
  47. package/dist/npm-homepage.d.ts.map +1 -0
  48. package/dist/npm-homepage.js +109 -0
  49. package/dist/npm-homepage.js.map +1 -0
  50. package/dist/operations.d.ts +6 -0
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations.js +150 -24
  53. package/dist/operations.js.map +1 -1
  54. package/dist/pack-inventory.d.ts +13 -0
  55. package/dist/pack-inventory.d.ts.map +1 -1
  56. package/dist/pack-inventory.js +28 -2
  57. package/dist/pack-inventory.js.map +1 -1
  58. package/dist/parity.d.ts.map +1 -1
  59. package/dist/parity.js +4 -1
  60. package/dist/parity.js.map +1 -1
  61. package/dist/publish-sibling-drift.d.ts +43 -1
  62. package/dist/publish-sibling-drift.d.ts.map +1 -1
  63. package/dist/publish-sibling-drift.js +269 -13
  64. package/dist/publish-sibling-drift.js.map +1 -1
  65. package/dist/publish.d.ts +5 -1
  66. package/dist/publish.d.ts.map +1 -1
  67. package/dist/publish.js +18 -1
  68. package/dist/publish.js.map +1 -1
  69. package/dist/qe-bridge.d.ts +63 -1
  70. package/dist/qe-bridge.d.ts.map +1 -1
  71. package/dist/qe-bridge.js +82 -0
  72. package/dist/qe-bridge.js.map +1 -1
  73. package/dist/release-package-audit.d.ts +86 -0
  74. package/dist/release-package-audit.d.ts.map +1 -0
  75. package/dist/release-package-audit.js +272 -0
  76. package/dist/release-package-audit.js.map +1 -0
  77. package/dist/release.d.ts +8 -1
  78. package/dist/release.d.ts.map +1 -1
  79. package/dist/release.js +58 -32
  80. package/dist/release.js.map +1 -1
  81. package/dist/round-exec-claim.d.ts +36 -0
  82. package/dist/round-exec-claim.d.ts.map +1 -0
  83. package/dist/round-exec-claim.js +17 -0
  84. package/dist/round-exec-claim.js.map +1 -0
  85. package/dist/round.d.ts +10 -1
  86. package/dist/round.d.ts.map +1 -1
  87. package/dist/round.js +5 -1
  88. package/dist/round.js.map +1 -1
  89. package/dist/run-records.d.ts +11 -0
  90. package/dist/run-records.d.ts.map +1 -1
  91. package/dist/run-records.js +124 -20
  92. package/dist/run-records.js.map +1 -1
  93. package/dist/session-retro.d.ts +33 -0
  94. package/dist/session-retro.d.ts.map +1 -1
  95. package/dist/session-retro.js +170 -34
  96. package/dist/session-retro.js.map +1 -1
  97. package/dist/sign.d.ts +44 -0
  98. package/dist/sign.d.ts.map +1 -1
  99. package/dist/sign.js +126 -1
  100. package/dist/sign.js.map +1 -1
  101. package/dist/stage-usage.d.ts +148 -0
  102. package/dist/stage-usage.d.ts.map +1 -0
  103. package/dist/stage-usage.js +261 -0
  104. package/dist/stage-usage.js.map +1 -0
  105. package/dist/statusline.d.ts +23 -0
  106. package/dist/statusline.d.ts.map +1 -1
  107. package/dist/statusline.js +167 -1
  108. package/dist/statusline.js.map +1 -1
  109. package/dist/workflow-run-dispatch.d.ts +21 -19
  110. package/dist/workflow-run-dispatch.d.ts.map +1 -1
  111. package/dist/workflow-run-dispatch.js +206 -95
  112. package/dist/workflow-run-dispatch.js.map +1 -1
  113. package/dist/workflow-run.d.ts +12 -3
  114. package/dist/workflow-run.d.ts.map +1 -1
  115. package/dist/workflow-run.js +29 -2
  116. package/dist/workflow-run.js.map +1 -1
  117. package/package.json +4 -4
  118. package/sbom.json +309 -229
  119. package/src/agentdb-index.ts +31 -2
  120. package/src/agentdb-snapshot.ts +105 -4
  121. package/src/apply-leg.ts +47 -10
  122. package/src/architecture.ts +15 -2
  123. package/src/codex-rollouts.ts +155 -147
  124. package/src/cost-ledger.ts +308 -19
  125. package/src/feature-adr-routing.ts +22 -0
  126. package/src/guard.ts +52 -0
  127. package/src/index.ts +19 -3
  128. package/src/loop-plan.ts +8 -0
  129. package/src/mutation-gate.ts +153 -19
  130. package/src/npm-homepage.ts +142 -0
  131. package/src/operations.ts +177 -22
  132. package/src/pack-inventory.ts +27 -2
  133. package/src/parity.ts +4 -1
  134. package/src/publish-sibling-drift.ts +214 -11
  135. package/src/publish.ts +20 -1
  136. package/src/qe-bridge.ts +86 -1
  137. package/src/release-package-audit.ts +264 -0
  138. package/src/release.ts +55 -22
  139. package/src/round-exec-claim.ts +43 -0
  140. package/src/round.ts +8 -0
  141. package/src/run-records.ts +101 -22
  142. package/src/session-retro.ts +153 -31
  143. package/src/sign.ts +136 -1
  144. package/src/stage-usage.ts +191 -0
  145. package/src/statusline.ts +149 -1
  146. package/src/workflow-run-dispatch.ts +148 -89
  147. package/src/workflow-run.ts +40 -4
  148. package/dist/ledger-cost-fill.d.ts +0 -58
  149. package/dist/ledger-cost-fill.d.ts.map +0 -1
  150. package/dist/ledger-cost-fill.js +0 -78
  151. package/dist/ledger-cost-fill.js.map +0 -1
  152. package/dist/retro.d.ts +0 -131
  153. package/dist/retro.d.ts.map +0 -1
  154. package/dist/retro.js +0 -207
  155. package/dist/retro.js.map +0 -1
  156. package/dist/sbom.d.ts +0 -42
  157. package/dist/sbom.d.ts.map +0 -1
  158. package/dist/sbom.js +0 -120
  159. package/dist/sbom.js.map +0 -1
@@ -31,6 +31,8 @@
31
31
  * @packageDocumentation
32
32
  */
33
33
 
34
+ import { fnv1a64 } from './feature-adr-checkpoints.js';
35
+
34
36
  interface RawRecord {
35
37
  readonly type?: unknown;
36
38
  readonly timestamp?: unknown;
@@ -46,8 +48,8 @@ function nonEmptyString(v: unknown): string | null {
46
48
  return typeof v === 'string' && v.length > 0 ? v : null;
47
49
  }
48
50
 
49
- function finiteNonNegative(v: unknown): number {
50
- return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0;
51
+ function finiteNonNegative(v: unknown): number | null {
52
+ return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : null;
51
53
  }
52
54
 
53
55
  /** Epoch ms from a record's own `timestamp` (current schema) or `ts` (legacy/defensive), or `null`. */
@@ -71,11 +73,12 @@ function isoOrNull(ms: number | null): string | null {
71
73
  }
72
74
 
73
75
  export interface CodexRolloutTotals {
74
- readonly input: number;
75
- readonly cachedInput: number;
76
- readonly output: number;
77
- readonly reasoning: number;
78
- readonly total: number;
76
+ readonly cachedWrite?: number | null;
77
+ readonly input: number | null;
78
+ readonly cachedInput: number | null;
79
+ readonly output: number | null;
80
+ readonly reasoning: number | null;
81
+ readonly total: number | null;
79
82
  }
80
83
 
81
84
  /**
@@ -86,6 +89,7 @@ export interface CodexRolloutTotals {
86
89
  * such record fell inside this turn's interval (nothing to attribute to it).
87
90
  */
88
91
  export interface CodexRolloutTurn {
92
+ readonly turnId?: string | null;
89
93
  readonly model: string | null;
90
94
  readonly cwd: string | null;
91
95
  readonly startedAt: string | null;
@@ -94,6 +98,13 @@ export interface CodexRolloutTurn {
94
98
  }
95
99
 
96
100
  export interface CodexRollout {
101
+ readonly sourcePath?: string;
102
+ readonly turnId?: string | null;
103
+ readonly diagnostics?: readonly string[];
104
+ readonly cumulativeWitness?: CodexRolloutTotals | null;
105
+ /** Fresh validation of an observed cumulative prefix; compact association avoids copying key sets. */
106
+ readonly scopeDiagnostics?: readonly { sourceRecord: number; receiptCount: number; timestamp: string | null; diagnostics: readonly string[]; witness?: CodexRolloutTotals }[];
107
+ readonly receipts?: readonly { key: string; responseId: string | null; turnId: string | null; turnIndex?: number | null; timestamp: string | null; totals: CodexRolloutTotals; payloadDigest: string; source: string; sourceRecord?: number; diagnostics?: readonly string[] }[];
97
108
  readonly id: string;
98
109
  readonly cwd: string | null;
99
110
  readonly model: string | null;
@@ -122,6 +133,7 @@ export interface CodexRolloutParseError {
122
133
  * (both schemas use these five field names) out of a usage-bearing sub-object. */
123
134
  function totalsFrom(usage: Record<string, unknown>): CodexRolloutTotals {
124
135
  return {
136
+ cachedWrite: finiteNonNegative(usage['cache_write_input_tokens']),
125
137
  input: finiteNonNegative(usage['input_tokens']),
126
138
  cachedInput: finiteNonNegative(usage['cached_input_tokens']),
127
139
  output: finiteNonNegative(usage['output_tokens']),
@@ -139,142 +151,117 @@ function totalsFrom(usage: Record<string, unknown>): CodexRolloutTotals {
139
151
  * content.
140
152
  */
141
153
  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
- });
154
+ if (typeof text !== 'string' || !text.trim()) return { error: 'empty rollout text' };
155
+ const diagnostics: string[] = [];
156
+ let id: string | null = null; let cwd: string | null = null; let model: string | null = null; let sessionCwd: string | null = null;
157
+ let firstMs: number | null = null; let lastMs: number | null = null;
158
+ let cumulative: CodexRolloutTotals | null = null; let witnessMs: number | null = null; let sawTurnModel = false;
159
+ type Receipt = NonNullable<CodexRollout['receipts']>[number];
160
+ const modern = new Map<string, Receipt>(); const conflicted = new Set<string>();
161
+ const legacy: Receipt[] = []; const scopeDiagnostics: NonNullable<CodexRollout['scopeDiagnostics']>[number][] = [];
162
+ const turns: Array<{ model: string | null; cwd: string | null; startedAt: string | null; endedAt: string | null;
163
+ turnId: string | null; totals: CodexRolloutTotals | null; baseline: CodexRolloutTotals | null }> = [];
164
+ let current: typeof turns[number] | undefined;
165
+ const empty = (): CodexRolloutTotals => ({ input: null, cachedInput: null, cachedWrite: null, output: null, reasoning: null, total: null });
166
+ const fields = ['input', 'cachedInput', 'cachedWrite', 'output', 'reasoning', 'total'] as const;
167
+ const sum = (values: readonly CodexRolloutTotals[]): CodexRolloutTotals => Object.fromEntries(fields.map((field) => {
168
+ const counts = values.map((v) => v[field]);
169
+ const total = counts.reduce<number>((n, v) => n + (v ?? 0), 0);
170
+ if (!Number.isSafeInteger(total)) diagnostics.push('aggregate-overflow:' + field);
171
+ return [field, counts.length > 0 && counts.every((v) => v != null) && Number.isSafeInteger(total) ? total : null];
172
+ })) as unknown as CodexRolloutTotals;
173
+ const readUsage = (usage: Record<string, unknown>, owned: string[] = []): CodexRolloutTotals => {
174
+ const t = totalsFrom(usage);
175
+ for (const [key, v] of Object.entries(usage)) if (key.endsWith('_tokens') && v != null && finiteNonNegative(v) === null) owned.push('invalid-counter:' + key);
176
+ if (t.input != null && ((t.cachedInput != null && t.cachedInput > t.input) || (t.cachedInput != null && t.cachedWrite != null && t.cachedInput + t.cachedWrite > t.input))) owned.push('cache-exceeds-input');
177
+ if (t.output != null && t.reasoning != null && t.reasoning > t.output) owned.push('reasoning-exceeds-output');
178
+ if (t.input != null && t.output != null && t.total != null && t.input + t.output !== t.total) owned.push('total-split-mismatch');
179
+ diagnostics.push(...owned); return t;
183
180
  };
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
181
+ const signature = (r: Receipt): string => JSON.stringify([r.turnId, r.totals.input, r.totals.cachedInput, r.totals.output, r.totals.reasoning, r.totals.total]);
182
+ for (const [sourceRecord, line] of text.split('\n').entries()) {
183
+ if (!line.trim()) continue;
184
+ let raw: unknown;
185
+ try { raw = JSON.parse(line); } catch { diagnostics.push('malformed-record'); continue; }
186
+ if (!isRecord(raw)) continue;
187
+ const ms = recordTimeMs(raw); if (ms !== null) { firstMs = firstMs === null ? ms : Math.min(firstMs, ms); lastMs = lastMs === null ? ms : Math.max(lastMs, ms); }
188
+ const payload = isRecord(raw['payload']) ? raw['payload'] : null; if (!payload) continue;
189
+ if (raw['type'] === 'session_meta') { id ??= nonEmptyString(payload['session_id']) ?? nonEmptyString(payload['id']); cwd ??= nonEmptyString(payload['cwd']); sessionCwd ??= nonEmptyString(payload['cwd']); model ??= nonEmptyString(payload['model']); }
190
+ if (raw['type'] === 'turn_context') {
191
+ if (current) current.endedAt = isoOrNull(ms);
192
+ current = { model: nonEmptyString(payload['model']), cwd: nonEmptyString(payload['cwd']) ?? sessionCwd,
193
+ turnId: nonEmptyString(payload['turn_id']), startedAt: isoOrNull(ms), endedAt: null, totals: null, baseline: cumulative };
194
+ turns.push(current); if (!sawTurnModel && current.model !== null) { model = current.model; sawTurnModel = true; } cwd ??= current.cwd;
197
195
  }
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 };
196
+ const modernUsage = raw['type'] === 'token_usage_record' && isRecord(payload['usage']) ? payload['usage'] : null;
197
+ const info = isRecord(payload['info']) ? payload['info'] : null;
198
+ const countEvent = raw['type'] === 'token_count' || (raw['type'] === 'event_msg' && payload['type'] === 'token_count');
199
+ const recordDiagnostics: string[] = []; let recordWitness: CodexRolloutTotals | undefined;
200
+ const witness = modernUsage ? payload['thread_token_usage'] : countEvent ? info?.['total_token_usage'] : null;
201
+ if (isRecord(witness) && (witnessMs === null || ms === null || ms >= witnessMs)) {
202
+ const next = readUsage(witness, recordDiagnostics); recordWitness = next;
203
+ if (cumulative?.total != null && next.total != null && next.total < cumulative.total) { diagnostics.push('cumulative-reset'); recordDiagnostics.push('cumulative-reset'); }
204
+ cumulative = next; witnessMs = ms;
225
205
  }
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
- }
206
+ if (modernUsage) {
207
+ const owned: string[] = []; const totals = readUsage(modernUsage, owned);
208
+ const responseId = nonEmptyString(payload['response_id']);
209
+ const turnId = nonEmptyString(payload['turn_id']) ?? current?.turnId ?? null;
210
+ const key = responseId ? 'response:' + responseId : 'record:' + fnv1a64(JSON.stringify([id, turnId, isoOrNull(ms), totals]));
211
+ if (!responseId) diagnostics.push('response-id-unavailable');
212
+ if (payload['session_id'] != null && id != null && payload['session_id'] !== id) { diagnostics.push('foreign-session'); owned.push('foreign-session'); }
213
+ const receipt: Receipt = { key, responseId, turnId, turnIndex: current ? turns.indexOf(current) : null, timestamp: isoOrNull(ms), totals, sourceRecord, diagnostics: owned,
214
+ payloadDigest: fnv1a64(JSON.stringify([payload['session_id'] ?? id, payload['thread_id'] ?? id, turnId, current?.model ?? model, current?.cwd ?? cwd, totals])), source: 'modern-response' };
215
+ if (current?.turnId != null && turnId !== current.turnId) { diagnostics.push('turn-association-conflict'); owned.push('turn-association-conflict'); }
216
+ const previous = modern.get(key);
217
+ if (previous && previous.payloadDigest !== receipt.payloadDigest) { conflicted.add(key); diagnostics.push('conflicting-response:' + key); }
218
+ else modern.set(key, previous ? { ...previous, diagnostics: [...new Set([...(previous.diagnostics ?? []), ...owned])] } : receipt);
219
+ } else if (countEvent && info && isRecord(info['last_token_usage'])) {
220
+ const owned: string[] = []; const totals = readUsage(info['last_token_usage'], owned); recordDiagnostics.push(...owned);
221
+ legacy.push({ key: 'legacy:' + fnv1a64(JSON.stringify([id, current?.turnId, isoOrNull(ms), witness, totals])), responseId: null,
222
+ turnId: current?.turnId ?? null, turnIndex: current ? turns.indexOf(current) : null, timestamp: isoOrNull(ms), totals, sourceRecord, diagnostics: owned, payloadDigest: fnv1a64(JSON.stringify([current?.turnId, totals])), source: 'legacy-last' });
223
+ } else if (countEvent && cumulative && current) {
224
+ const baseline = current.baseline;
225
+ current.totals = baseline === null ? cumulative : Object.fromEntries(fields.map((field) => {
226
+ const now = cumulative?.[field]; const base = baseline[field]; const delta = now != null && base != null ? now - base : null;
227
+ if (delta != null && delta < 0) diagnostics.push('cumulative-reset:' + field);
228
+ return [field, delta != null && delta >= 0 ? delta : null];
229
+ })) as unknown as CodexRolloutTotals;
237
230
  }
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;
231
+ if (recordWitness !== undefined || recordDiagnostics.length > 0) {
232
+ let receiptCount = modern.size - conflicted.size;
233
+ if (legacy.length) {
234
+ const modernReceipts = [...modern.values()].filter((r) => !conflicted.has(r.key));
235
+ const legacyKeys = new Set(legacy.filter((r) => !modernReceipts.some((m) => signature(m) === signature(r) && m.timestamp != null && r.timestamp != null && Math.abs(Date.parse(m.timestamp) - Date.parse(r.timestamp)) <= 5000)).map((r) => r.key));
236
+ receiptCount += legacyKeys.size;
246
237
  }
238
+ scopeDiagnostics.push({ sourceRecord, receiptCount, timestamp: isoOrNull(ms), diagnostics: recordDiagnostics,
239
+ ...(recordWitness !== undefined ? { witness: recordWitness } : {}) });
247
240
  }
248
241
  }
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
- }
242
+ if (current) current.endedAt = isoOrNull(lastMs);
243
+ if (id === null && fileName) id = /([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)?.[1] ?? null;
262
244
  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' };
245
+ const seenTurnIds = new Set<string>();
246
+ for (const turn of turns) if (turn.turnId !== null) { if (seenTurnIds.has(turn.turnId)) diagnostics.push('turn-association-conflict'); seenTurnIds.add(turn.turnId); }
247
+ const responses = [...modern.values()].filter((r) => !conflicted.has(r.key));
248
+ const seenLegacy = new Set<string>();
249
+ for (const receipt of legacy) {
250
+ const mirror = responses.some((r) => signature(r) === signature(receipt) && r.timestamp != null && receipt.timestamp != null
251
+ && Math.abs(Date.parse(r.timestamp) - Date.parse(receipt.timestamp)) <= 5000);
252
+ if (!mirror && !seenLegacy.has(receipt.key)) { responses.push(receipt); seenLegacy.add(receipt.key); }
265
253
  }
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
- };
254
+ if (responses.length === 0 && cumulative === null && conflicted.size === 0) return { error: 'no token_count or token_usage_record entry — nothing to attribute' };
255
+ for (const turn of turns) {
256
+ const selected = responses.filter((r) => r.turnIndex === turns.indexOf(turn));
257
+ if (selected.length > 0) turn.totals = sum(selected.map((r) => r.totals));
258
+ }
259
+ const totals = conflicted.size > 0 ? empty() : responses.length > 0 ? sum(responses.map((r) => r.totals)) : cumulative!;
260
+ if (responses.length > 0 && cumulative?.total != null && totals.total !== cumulative.total) diagnostics.push('cumulative-witness-mismatch');
261
+ return { id, cwd, model, startedAt: isoOrNull(firstMs), endedAt: isoOrNull(lastMs), totals,
262
+ granularity: turns.length ? 'turn' : 'session', unmatchableTurns: turns.filter((t) => t.startedAt === null || t.endedAt === null).length,
263
+ turns: turns.map(({ baseline: _baseline, ...turn }) => turn), receipts: responses, diagnostics: [...new Set(diagnostics)],
264
+ cumulativeWitness: cumulative, scopeDiagnostics, ...(fileName ? { sourcePath: fileName } : {}) };
278
265
  }
279
266
 
280
267
  export type CodexRolloutMatch =
@@ -284,13 +271,15 @@ export type CodexRolloutMatch =
284
271
 
285
272
  export interface CodexRolloutMatchWindow {
286
273
  /** ISO instant — the window's lower bound. */
287
- readonly from: string;
274
+ readonly from?: string;
288
275
  /** ISO instant — the window's upper bound. */
289
- readonly to: string;
276
+ readonly to?: string;
290
277
  /** Exact match against {@link CodexRollout.cwd}, when given. */
291
278
  readonly cwd?: string;
292
279
  /** Exact match against {@link CodexRollout.model}, when given. */
293
280
  readonly model?: string;
281
+ readonly rolloutId?: string;
282
+ readonly turnId?: string;
294
283
  }
295
284
 
296
285
  /**
@@ -313,12 +302,27 @@ export interface CodexRolloutMatchWindow {
313
302
  * silently resolved to one of them).
314
303
  */
315
304
  function candidateViewsOf(r: CodexRollout): readonly CodexRollout[] {
316
- if (r.granularity === 'session') return [r];
317
- const out: CodexRollout[] = [];
318
- for (const turn of r.turns) {
305
+ if (r.granularity === 'session') return r.turns.length === 0 ? [r] : [];
306
+ const out: CodexRollout[] = []; const turnIdCounts = new Map<string, number>();
307
+ for (const turn of r.turns) if (turn.turnId != null) turnIdCounts.set(turn.turnId, (turnIdCounts.get(turn.turnId) ?? 0) + 1);
308
+ for (const [turnIndex, turn] of r.turns.entries()) {
309
+ if (turn.turnId != null && turnIdCounts.get(turn.turnId) !== 1) continue;
319
310
  if (turn.totals === null) continue; // nothing was ever attributed to this turn — not a candidate
311
+ // Original turn position owns membership, even when adjacent boundaries share a timestamp.
312
+ const receipts = r.receipts?.filter((receipt) => {
313
+ if (receipt.turnIndex != null) {
314
+ if (!Number.isSafeInteger(receipt.turnIndex) || receipt.turnIndex < 0 || receipt.turnIndex >= r.turns.length || receipt.turnIndex !== turnIndex) return false;
315
+ return receipt.turnId === null || turn.turnId == null || receipt.turnId === turn.turnId;
316
+ }
317
+ return receipt.turnId !== null && turn.turnId === receipt.turnId && turnIdCounts.get(receipt.turnId) === 1;
318
+ });
319
+ const contradicts = r.receipts?.some((receipt) => receipt.turnIndex === turnIndex && receipt.turnId !== null && turn.turnId != null && receipt.turnId !== turn.turnId);
320
+ if (contradicts || (r.receipts && r.receipts.length > 0 && receipts?.length === 0)) continue;
320
321
  out.push({
322
+ ...r,
321
323
  id: r.id,
324
+ turnId: turn.turnId ?? null,
325
+ receipts: receipts ?? [],
322
326
  unmatchableTurns: r.unmatchableTurns,
323
327
  cwd: turn.cwd,
324
328
  model: turn.model,
@@ -348,20 +352,24 @@ export function matchCodexRollouts(
348
352
  rollouts: readonly CodexRollout[],
349
353
  window: CodexRolloutMatchWindow,
350
354
  ): 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' };
355
+ const fromMs = Date.parse(window.from ?? '');
356
+ const toMs = Date.parse(window.to ?? '');
357
+ const hasWindow = window.from !== undefined || window.to !== undefined;
358
+ const exact = window.rolloutId !== undefined || window.turnId !== undefined;
359
+ if ((hasWindow && (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs > toMs)) || (!hasWindow && !exact)) return { status: 'none' };
354
360
 
355
361
  const candidates: CodexRollout[] = [];
356
362
  for (const r of rollouts) {
357
363
  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;
364
+ if (window.rolloutId !== undefined && view.id !== window.rolloutId) continue;
365
+ if (window.turnId !== undefined && view.turnId !== window.turnId) continue;
366
+ if (hasWindow && (view.startedAt === null || view.endedAt === null)) continue;
367
+ const startMs = Date.parse(view.startedAt ?? '');
368
+ const endMs = Date.parse(view.endedAt ?? '');
369
+ if (hasWindow && (!Number.isFinite(startMs) || !Number.isFinite(endMs))) continue;
362
370
  // Lead delta after Codex r2 (#5): the turn must START inside the window — a turn that merely
363
371
  // brushes the window's edge (any-overlap) is exactly how a neighbouring dispatch's turn leaks in.
364
- if (startMs < fromMs || startMs > toMs) continue;
372
+ if (hasWindow && (startMs < fromMs || startMs > toMs)) continue;
365
373
  if (window.cwd !== undefined && view.cwd !== window.cwd) continue;
366
374
  if (window.model !== undefined && view.model !== window.model) continue;
367
375
  candidates.push(view);