@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
@@ -0,0 +1,43 @@
1
+ /**
2
+ * round-exec-claim-takeover (FR-1): the pure verdict behind `dz round exec`'s claim transaction.
3
+ *
4
+ * A round claimed by an `exec` whose process died (SIGTERM, OOM, reboot) used to refuse every later
5
+ * `exec` forever (backlog 280e914607397474). This decides — from facts the caller measured under the
6
+ * state lock — whether the standing claim is held, provably stale-dead, or unknowable:
7
+ *
8
+ * - `pidAlive === true` ⇒ `held`, regardless of age (a live owner is never taken over);
9
+ * - `pidAlive === null` ⇒ `unknown` (the probe was inconclusive — refusal is the safe side);
10
+ * - `pid` not a positive safe integer ⇒ `unknown` («no pid recorded») — nothing to probe;
11
+ * - `execClaimedAt` unparsable ⇒ `unknown` («claim time unreadable») — no age to debounce on;
12
+ * - `pidAlive === false` and age ≥ `staleMinutes` ⇒ `stale-dead` with the floored age;
13
+ * - `pidAlive === false` but younger ⇒ `held` — a just-died claim may be a restart in flight, and
14
+ * the threshold is the debounce.
15
+ *
16
+ * Pure: no clock, no process table — `now` and `pidAlive` are inputs so the caller keeps the
17
+ * critical section short (`process.kill(pid, 0)`, NFR-1) and tests need no real processes.
18
+ */
19
+ export type ExecClaimVerdict =
20
+ | { kind: 'held' }
21
+ | { kind: 'stale-dead'; ageMinutes: number }
22
+ | { kind: 'unknown'; reason: string };
23
+
24
+ export type ExecClaimTakeoverInput = {
25
+ readonly execClaimedAt?: string | undefined;
26
+ readonly pid?: number | undefined;
27
+ readonly pidAlive: boolean | null;
28
+ readonly now: number;
29
+ readonly staleMinutes: number;
30
+ };
31
+
32
+ export function decideExecClaimTakeover(input: ExecClaimTakeoverInput): ExecClaimVerdict {
33
+ if (input.pidAlive === true) return { kind: 'held' };
34
+ if (input.pid === undefined || !Number.isSafeInteger(input.pid) || input.pid <= 0) {
35
+ return { kind: 'unknown', reason: 'no pid recorded' };
36
+ }
37
+ if (input.pidAlive === null) return { kind: 'unknown', reason: 'PID probe inconclusive' };
38
+ const claimedMs = input.execClaimedAt === undefined ? Number.NaN : Date.parse(input.execClaimedAt);
39
+ if (!Number.isFinite(claimedMs)) return { kind: 'unknown', reason: 'claim time unreadable' };
40
+ const ageMinutes = Math.floor((input.now - claimedMs) / 60_000);
41
+ if (ageMinutes >= input.staleMinutes) return { kind: 'stale-dead', ageMinutes };
42
+ return { kind: 'held' };
43
+ }
package/src/round.ts CHANGED
@@ -102,6 +102,8 @@ export interface RoundLedgerRow {
102
102
  * fix-round-1 #1/#2 (ADR-001 п.2 amended): also present when an EXPLICIT `--reviewer` AGREES with
103
103
  * the sidecar's own `gradedBy` (`reviewSource:'flag+qe-bridge'` below names that case). */
104
104
  readonly reviewMinutes?: number;
105
+ readonly reviewIdentitySource?: 'round-run-task' | 'partial-identity' | 'legacy-window';
106
+ readonly reviewIdentity?: { readonly round: number | null; readonly roundRun: string | null; readonly taskId: string | null; readonly bridgeRunId: string | null };
105
107
  /** measurement-integrity FR-7: present ONLY alongside `reviewMinutes` — names where `reviewer` and
106
108
  * `reviewMinutes` came from, so a reader never confuses a sidecar-sourced figure for a flag.
107
109
  * review-cost-ledger fix-round-1 #1/#2 (ADR-001 п.2 amended): `'qe-bridge'` when `reviewer` was
@@ -188,6 +190,8 @@ export interface RoundLedgerRow {
188
190
  * `closeRound` never opens a file.
189
191
  */
190
192
  export interface RoundReviewSidecar {
193
+ readonly reviewIdentitySource?: RoundLedgerRow['reviewIdentitySource'];
194
+ readonly reviewIdentity?: RoundLedgerRow['reviewIdentity'];
191
195
  /** Who graded it — family + model, e.g. `codex:gpt-5.6-sol`. */
192
196
  readonly gradedBy: string;
193
197
  readonly elapsedMs: number;
@@ -609,6 +613,8 @@ export function closeRound(input: {
609
613
  ...(nonEmpty(input.stateId) ? { stateId: input.stateId } : {}),
610
614
  ...(input.state.envelope !== undefined ? { envelope: input.state.envelope } : {}),
611
615
  ...(reviewSource !== null ? { reviewSource } : {}),
616
+ ...(reviewerTiedToSidecar && input.reviewSidecar?.reviewIdentitySource !== undefined ? { reviewIdentitySource: input.reviewSidecar.reviewIdentitySource } : {}),
617
+ ...(reviewerTiedToSidecar && input.reviewSidecar?.reviewIdentity !== undefined ? { reviewIdentity: input.reviewSidecar.reviewIdentity } : {}),
612
618
  ...(reviewMinutes !== null ? { reviewMinutes } : {}),
613
619
  taskId,
614
620
  ...(taskIdSource !== null ? { taskIdSource } : {}),
@@ -700,11 +706,13 @@ export function readOpenRoundTaskId(
700
706
  states: readonly RoundState[],
701
707
  slug: string,
702
708
  unreadableStateCount = 0,
709
+ authorityReadError = false,
703
710
  ): {
704
711
  readonly taskId: string | null;
705
712
  readonly source: 'open-round' | 'derived-legacy' | 'no-open-round' | 'ambiguous' | 'unavailable';
706
713
  } {
707
714
  const matches = states.filter((s) => s.slug === slug);
715
+ if (authorityReadError) return { taskId: null, source: 'unavailable' };
708
716
  const unreadable = Number.isFinite(unreadableStateCount) && unreadableStateCount > 0 ? Math.floor(unreadableStateCount) : 0;
709
717
  if (unreadable > 0 && matches.length === 0) return { taskId: null, source: 'unavailable' };
710
718
  if (unreadable > 0) return { taskId: null, source: 'ambiguous' };
@@ -13,11 +13,28 @@
13
13
  * Pure: payload in, verdict out. The CLI owns paths, the append, the read-back and the exit code.
14
14
  */
15
15
 
16
+ import { createHash } from 'node:crypto';
16
17
  import { matchCodexRollouts } from './codex-rollouts.js';
17
18
  import type { CodexRollout } from './codex-rollouts.js';
18
- import { redactTrainingPayload } from './feature-adr-checkpoints.js';
19
+ import { fnv1a64, redactTrainingPayload } from './feature-adr-checkpoints.js';
19
20
  import { validateExperimentEnvelope } from './feature-adr-envelope.js';
20
21
 
22
+ // Capture contract: ordered named fields, with absent distinct from explicit null.
23
+ // Kept private at producer/reader boundaries; both use this exact sha256 representation.
24
+ function capturedPayload(evidence: Record<string, unknown>): string {
25
+ const value = (v: unknown) => v === undefined ? { absent: true } : v;
26
+ const receipts = Array.isArray(evidence['receipts']) ? evidence['receipts'] : [];
27
+ return JSON.stringify([
28
+ ...['schema', 'sessionId', 'turnId', 'sourcePath', 'matchBasis', 'capturedFrom', 'capturedTo', 'model', 'cwd', 'reportedTotalBasis', 'inputCacheSemantics'].map((k) => [k, value(evidence[k])]),
29
+ ['owner', ...['runId', 'taskId', 'stage', 'attempt', 'role'].map((k) => [k, value((evidence['owner'] as Record<string, unknown> | undefined)?.[k])])],
30
+ receipts.map((raw) => {
31
+ const r = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {}; const t = r['totals'] as Record<string, unknown> | undefined;
32
+ return [...['key', 'responseId', 'turnId', 'turnIndex', 'timestamp', 'payloadDigest', 'source'].map((k) => [k, value(r[k])]),
33
+ ['totals', ...['input', 'output', 'cachedInput', 'cachedWrite', 'reasoning', 'total'].map((k) => [k, value(t?.[k])])]];
34
+ }),
35
+ ]);
36
+ }
37
+
21
38
  /** Structural — a caller passes `cost-scoring.ts`'s `ModelPricing`; kept local so `run-records.ts`
22
39
  * does not have to import `cost-scoring.ts` just to name a type.
23
40
  *
@@ -86,6 +103,44 @@ export function parseModelSpec(spec: unknown): ParsedModelSpec | null {
86
103
  return null;
87
104
  }
88
105
 
106
+ /** Internal shared ledger authority resolver; not a package-public barrel API.
107
+ * Capture/source values are comparison targets and never participate in claimant resolution. */
108
+ export function resolveLedgerModelProvenance(row: Readonly<Record<string, unknown>>) {
109
+ const diagnostics: string[] = [];
110
+ const canonicalFamily = (v: string) => v === 'openai' ? 'codex' : v;
111
+ const direct = typeof row['model'] === 'string' && row['model'].length <= 128 && /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/.test(row['model']) ? row['model'] : null;
112
+ if (row['model'] != null && direct === null) diagnostics.push('model-invalid-direct');
113
+ const family = typeof row['family'] === 'string' && SPEC_PART.test(row['family']) ? row['family'] : null;
114
+ if (row['family'] != null && family === null) diagnostics.push('model-invalid-family');
115
+ const specs: ParsedModelSpec[] = []; const familyHints: string[] = [];
116
+ for (const key of ['coder', 'reviewer']) {
117
+ const raw = row[key]; if (raw == null) continue;
118
+ if (raw === 'codex' || raw === 'claude') { familyHints.push(raw); continue; }
119
+ const spec = parseModelSpec(raw);
120
+ const parts = typeof raw === 'string' ? raw.trim().split(':') : [];
121
+ if (!spec || !SPEC_PART.test(spec.model) || (parts[0] === 'claude' && parts.length !== 2)) {
122
+ diagnostics.push('model-invalid-executor:' + key); continue;
123
+ }
124
+ specs.push(spec); familyHints.push(spec.family);
125
+ }
126
+ const models = new Set(specs.map((v) => v.model)); const families = new Set(familyHints);
127
+ if (models.size > 1 || families.size > 1) diagnostics.push('model-executor-conflict');
128
+ const unique = models.size === 1 ? specs[0]! : null;
129
+ if (direct !== null && unique && direct !== unique.model) diagnostics.push('model-direct-executor-conflict');
130
+ if (family !== null && [...families].some((v) => canonicalFamily(family) !== v)) diagnostics.push('model-family-conflict');
131
+ const declaredProvenance = row['modelProvenance'];
132
+ const validProvenance = new Set(['caller-recorded', 'executor-spec', 'provider-reported', 'dispatcher-reported', 'probed-request', 'not-recorded']);
133
+ if (declaredProvenance != null && (typeof declaredProvenance !== 'string' || !validProvenance.has(declaredProvenance))) diagnostics.push('model-invalid-provenance');
134
+ if (declaredProvenance === 'executor-spec' && (!unique || (direct !== null && direct !== unique.model))) diagnostics.push('model-provenance-conflict');
135
+ if (declaredProvenance != null && declaredProvenance !== 'executor-spec' && declaredProvenance !== 'not-recorded' && direct === null) diagnostics.push('model-provenance-conflict');
136
+ const conflict = diagnostics.some((d) => /invalid|conflict/.test(d));
137
+ const model = conflict ? null : direct ?? unique?.model ?? null;
138
+ if (model === null && !conflict) diagnostics.push('model-not-recorded');
139
+ const resolvedFamily = family ?? (families.size === 1 ? [...families][0]! : null);
140
+ return { model, family: resolvedFamily, modelProvenance: model === null ? 'not-recorded' : direct !== null
141
+ ? typeof declaredProvenance === 'string' ? declaredProvenance : 'caller-recorded' : 'executor-spec', diagnostics };
142
+ }
143
+
89
144
  /** measurement-integrity FR-5/FR-6: enrichment the WRITER supplies at write time — the rollout logs
90
145
  * it already read (I/O lives in the CLI; this stays pure) and the price table snapshot. Absent
91
146
  * entirely ⇒ zero behavior change from before this feature (NFR-1). */
@@ -93,6 +148,9 @@ export interface LedgerEnrichInput {
93
148
  /** Parsed Codex rollout logs for the window the CLI read — usually every rollout from the days the
94
149
  * window spans. Pure data; the CLI is the one that walked `~/.codex/sessions`. */
95
150
  readonly rollouts?: readonly CodexRollout[];
151
+ readonly rolloutId?: string;
152
+ readonly turnId?: string;
153
+ readonly discoveryDiagnostics?: readonly string[];
96
154
  /** The stage's own time window — usually [the previous ledger row's `ts`, this write's `ts`], or
97
155
  * an explicit `--window-from/--window-to`. Omitted ⇒ no rollout match is even attempted. */
98
156
  readonly window?: { readonly from: string; readonly to: string };
@@ -492,32 +550,29 @@ export function decideRecordWrite(input: {
492
550
  // one that already has a token figure, is left untouched. The loose `/codex/i` check below only
493
551
  // decides whether this row is WORTH TRYING at all.
494
552
  const tokensIsNull = stamped['tokens'] === null;
495
- const looksCodexFamily = isCodexFamily(stamped['coder']) || isCodexFamily(stamped['reviewer']);
496
- if (tokensIsNull && looksCodexFamily) {
497
- // measurement-integrity fix-round-1/F4 (Codex r1 HIGH #4): the matcher REQUIRES a reliable
498
- // model, parsed the same way FR-7's price lookup parses one — never `/codex/i` alone. If
499
- // `coder`/`reviewer` do not resolve to exactly ONE codex model between them (a bare `'codex'`
500
- // with no model at all, or the two fields naming DIFFERENT codex models), the matcher is never
501
- // even called with an unreliable/omitted model filter — a lone rollout in the window would
502
- // otherwise be accepted as `'one'` on time+cwd alone and its tokens misattributed to the wrong
503
- // model's stage.
504
- const codexModels = new Set(
505
- [parseModelSpec(stamped['coder']), parseModelSpec(stamped['reviewer'])]
506
- .filter((s): s is ParsedModelSpec => s !== null && s.family === 'codex')
507
- .map((s) => s.model),
508
- );
509
- if (codexModels.size !== 1) {
553
+ const looksCodexFamily = isCodexFamily(stamped['coder']) || isCodexFamily(stamped['reviewer']) || stamped['family'] === 'codex' || stamped['family'] === 'openai';
554
+ if (enrich.discoveryDiagnostics?.length) {
555
+ stamped['usageDiagnostics'] = enrich.discoveryDiagnostics;
556
+ stamped['tokensSource'] = 'codex-rollout:source-discovery-unavailable';
557
+ } else if ((tokensIsNull || enrich.rolloutId !== undefined || enrich.turnId !== undefined) && looksCodexFamily) {
558
+ // Use the same authority contract as the readonly source comparison and normalized report.
559
+ // A direct model or unique agreeing executor specs supply authority; a source never does.
560
+ const identity = resolveLedgerModelProvenance(stamped);
561
+ if (identity.model === null || !['codex', 'openai'].includes(identity.family ?? '')) {
510
562
  stamped['tokensSource'] = 'codex-rollout:no-model';
511
- } else if (enrich.window !== undefined) {
512
- const model = [...codexModels][0]!;
563
+ stamped['usageDiagnostics'] = [...(Array.isArray(stamped['usageDiagnostics']) ? stamped['usageDiagnostics'] : []), ...identity.diagnostics];
564
+ } else if (enrich.window !== undefined || enrich.rolloutId !== undefined || enrich.turnId !== undefined) {
565
+ const model = identity.model;
513
566
  const match = matchCodexRollouts(enrich.rollouts ?? [], {
514
- from: enrich.window.from,
515
- to: enrich.window.to,
567
+ ...(enrich.window !== undefined ? { from: enrich.window.from, to: enrich.window.to } : {}),
516
568
  model,
569
+ ...(enrich.rolloutId !== undefined ? { rolloutId: enrich.rolloutId } : {}),
570
+ ...(enrich.turnId !== undefined ? { turnId: enrich.turnId } : {}),
517
571
  ...(enrich.cwd !== undefined ? { cwd: enrich.cwd } : {}),
518
572
  });
519
573
  if (match.status === 'one') {
520
- stamped['tokens'] = match.rollout.totals.total;
574
+ if (tokensIsNull || stamped['tokens'] === undefined) stamped['tokens'] = match.rollout.totals.total;
575
+ else if (stamped['tokens'] !== match.rollout.totals.total) stamped['usageDiagnostics'] = ['caller-source-total-mismatch'];
521
576
  const startMs = match.rollout.startedAt !== null ? Date.parse(match.rollout.startedAt) : NaN;
522
577
  const endMs = match.rollout.endedAt !== null ? Date.parse(match.rollout.endedAt) : NaN;
523
578
  // measurement-integrity fix-round-1/F6 (Codex r1 HIGH #6): fill-ONLY-null — an existing
@@ -528,9 +583,26 @@ export function decideRecordWrite(input: {
528
583
  }
529
584
  stamped['tokensSource'] = 'codex-rollout';
530
585
  stamped['rolloutId'] = match.rollout.id;
586
+ const found = match.rollout;
587
+ for (const [key, value] of Object.entries({ tokensTotal: found.totals.total, tokensIn: found.totals.input,
588
+ tokensOut: found.totals.output, tokensCacheRead: found.totals.cachedInput, tokensCacheWrite: found.totals.cachedWrite ?? null,
589
+ tokensReasoning: found.totals.reasoning, reportedTotalBasis: 'raw-inclusive', inputCacheSemantics: 'includes-cache-read-write',
590
+ usageDiagnostics: found.diagnostics ?? [] })) if (stamped[key] === undefined || stamped[key] === null) stamped[key] = value;
591
+ const receipts = (found.receipts ?? []).map((receipt) => ({ ...receipt, key: JSON.stringify([found.id, receipt.key]) }));
592
+ const usageEvidence: Record<string, unknown> = { schema: 'codex-rollout-scope-1', sessionId: found.id, turnId: found.turnId ?? null,
593
+ sourcePath: found.sourcePath ?? null, matchBasis: enrich.rolloutId !== undefined || enrich.turnId !== undefined ? 'exact' : 'legacy-window',
594
+ capturedFrom: found.startedAt, capturedTo: found.endedAt, receipts, model: found.model, cwd: found.cwd,
595
+ owner: Object.fromEntries(['runId', 'taskId', 'stage', 'attempt', 'role'].map((key) => [key, stamped[key] ?? null])),
596
+ reportedTotalBasis: 'raw-inclusive', inputCacheSemantics: 'includes-cache-read-write',
597
+ payloadDigest: fnv1a64(JSON.stringify(receipts.map((r) => [r.key, r.payloadDigest]))) };
598
+ usageEvidence['captureSha256'] = createHash('sha256').update(capturedPayload(usageEvidence)).digest('hex');
599
+ if (JSON.stringify(usageEvidence).length <= 16000) {
600
+ if (stamped['usageEvidence'] === undefined || stamped['usageEvidence'] === null) stamped['usageEvidence'] = usageEvidence;
601
+ } else stamped['usageDiagnostics'] = [...(Array.isArray(stamped['usageDiagnostics']) ? stamped['usageDiagnostics'] : []), 'source-scope-over-record-limit'];
602
+
531
603
  } else {
532
604
  // `none` or `ambiguous` — NFR-3: an explicit status, never a guessed number.
533
- stamped['tokensSource'] = `codex-rollout:${match.status}`;
605
+ stamped['tokensSource'] = `codex-rollout:${enrich.rolloutId !== undefined || enrich.turnId !== undefined ? 'exact-' : ''}${match.status}`;
534
606
  }
535
607
  } else {
536
608
  // Eligible in principle (codex family, one reliable model, tokens null) but no window was
@@ -544,6 +616,7 @@ export function decideRecordWrite(input: {
544
616
  // above (a Claude row gets priced too; only tokens enrichment is codex-specific).
545
617
  if (enrich.prices !== undefined) {
546
618
  const modelIds = new Set<string>();
619
+ if (typeof stamped['model'] === 'string' && stamped['model'].trim()) modelIds.add(stamped['model'].trim());
547
620
  for (const v of [stamped['coder'], stamped['reviewer']]) {
548
621
  if (typeof v === 'string' && v.trim() !== '') modelIds.add(v.trim());
549
622
  }
@@ -566,7 +639,13 @@ export function decideRecordWrite(input: {
566
639
  if (price === null) unknown.push(modelId);
567
640
  else table[modelId] = { prompt: price.prompt, completion: price.completion, cachedInput: price.cachedInput, cacheCreation: price.cacheCreation };
568
641
  }
642
+ const matches = Object.fromEntries([...modelIds].map((id) => {
643
+ const parsed = parseModelSpec(id); const normalized = parsed?.family === 'claude' ? 'claude-' + parsed.model : parsed?.model ?? id;
644
+ const key = Object.keys(enrich.prices ?? {}).filter((k) => normalized.toLowerCase().startsWith(k)).sort((a,b) => b.length-a.length)[0] ?? null;
645
+ return [id, { tableKey: key, kind: key !== null && key === normalized && !key.includes('claude') ? 'exact' : key !== null ? 'family-estimate' : 'unknown' }];
646
+ }));
569
647
  const computedPrices = {
648
+ matches,
570
649
  snapshotAt: input.timestamp ?? null,
571
650
  table,
572
651
  ...(unknown.length > 0 ? { unknown } : {}),
@@ -14,9 +14,10 @@
14
14
  * is taught silently but NOT drilled — no nagging on a one-off. Drills are for recurrent patterns only.
15
15
  */
16
16
 
17
- import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'node:fs';
17
+ import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, writeFileSync, renameSync, unlinkSync, mkdirSync, rmSync } from 'node:fs';
18
18
  import { join, basename, dirname } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
+ import { createHash } from 'node:crypto';
20
21
 
21
22
  import { withProjectLockSync, NamedLockTimeoutError } from './named-lock.js';
22
23
 
@@ -538,11 +539,62 @@ export function findLatestTranscript(repoRoot: string): string | null {
538
539
  // ── Per-turn admission-debt scan (feature narrated-error-must-be-taught, ADR-001 D2/D4) ────────────
539
540
  // The Stop hook runs `dz retro --scan-tail` after EVERY assistant turn, so this half is built around
540
541
  // one budget: O(new bytes) — a persisted byte offset, no full re-read, no store open, no subprocess.
541
- // The sentinel `.dz/retro-pending.json` is the debt; the recall hook turns it into a next-prompt
542
- // directive; a REAL teach invocation (a TOOL event — prose promises never pay, ADR-001 D4) clears it.
542
+ // The sentinel `.dz/retro/<session>/pending.json` is the debt; the recall hook turns it into a
543
+ // next-prompt directive; a REAL teach invocation (a TOOL event — prose promises never pay, ADR-001 D4)
544
+ // clears it.
543
545
 
546
+ /** LEGACY flat names (pre retro-debt-sentinel-per-session): the ONE pair every session in a worktree
547
+ * once shared under `.dz/`. A scan ADOPTS its own transcript's flat files once (FR-4) and never
548
+ * writes them again; a stranger's flat file is never touched. */
544
549
  export const RETRO_SCAN_STATE_FILE = 'retro-scan-state.json';
545
550
  export const RETRO_PENDING_FILE = 'retro-pending.json';
551
+
552
+ // ── Per-session layout (feature retro-debt-sentinel-per-session, FR-1; backlog 58f3c56fbb9d6893) ──
553
+ // Two sessions in one worktree shared the flat pair above, so a Stop scan of EITHER unlinked the
554
+ // other's LIVE debt as "foreign", and the shared bookmark made each session's offset meaningless to
555
+ // the other (foreign transcript ⇒ offset 0 ⇒ a bounded re-scan every turn; MEASURED 22.09 20:23, a
556
+ // second session in this repo). The pair now lives in a directory named after the session.
557
+ export const RETRO_SESSION_DIRNAME = 'retro';
558
+ export const RETRO_SESSION_PENDING_BASENAME = 'pending.json';
559
+ export const RETRO_SESSION_STATE_BASENAME = 'scan-state.json';
560
+ /** A session dir whose scan-state is older than this belongs to a dead session: the next scan of
561
+ * any other session sweeps it (FR-5). A live session that ran no Stop for 7 days loses only a debt
562
+ * the hook would have called stale anyway (SENTINEL_FRESH_MS is 30 min) — accepted risk R2. */
563
+ export const RETRO_SESSION_STALE_MS = 7 * 24 * 3600 * 1000;
564
+ /** At most this many `.dz/retro/*` entries are examined per scan — the sweep's cost bound (NFR-1). */
565
+ const RETRO_SWEEP_MAX_ENTRIES = 64;
566
+ const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
567
+
568
+ /**
569
+ * The directory name for a session id. PURE. A plain token is used as is; anything else — a
570
+ * path-like string, the empty string, an over-long id, and the two dot names the charset regex
571
+ * alone would let through (`.` ⇒ `<dzDir>/retro`, `..` ⇒ `<dzDir>` itself) — becomes the first
572
+ * 32 hex of its sha256, so a session id can never name a path outside `.dz/retro/` (AC-2).
573
+ * TWIN: the recall hook in `apply-leg.ts` carries a copy (the hook must stay dependency-free);
574
+ * `test/retro-sentinel-per-session.test.ts` pins the two to equal outputs.
575
+ */
576
+ export function safeSessionDirName(sessionId: string): string {
577
+ if (SAFE_SESSION_ID_RE.test(sessionId) && sessionId !== '.' && sessionId !== '..') return sessionId;
578
+ return createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
579
+ }
580
+
581
+ /** The session id the SCANNER derives: the transcript basename without `.jsonl`. Claude Code names
582
+ * the transcript after the session id, so this equals the `session_id` the hook payload carries. PURE. */
583
+ export function sessionIdFromTranscript(transcriptPath: string): string {
584
+ return basename(transcriptPath).replace(/\.jsonl$/, '');
585
+ }
586
+
587
+ export interface RetroSessionPaths {
588
+ readonly dir: string;
589
+ readonly pendingPath: string;
590
+ readonly statePath: string;
591
+ }
592
+
593
+ /** Where ONE session's debt and bookmark live: `<dzDir>/retro/<safeId>/{pending,scan-state}.json`. PURE, no fs. */
594
+ export function retroSessionPaths(dzDir: string, sessionId: string): RetroSessionPaths {
595
+ const dir = join(dzDir, RETRO_SESSION_DIRNAME, safeSessionDirName(sessionId));
596
+ return { dir, pendingPath: join(dir, RETRO_SESSION_PENDING_BASENAME), statePath: join(dir, RETRO_SESSION_STATE_BASENAME) };
597
+ }
546
598
  /** Bound the very FIRST scan of an already-huge transcript; later scans read only the new bytes. */
547
599
  const MAX_TAIL_SCAN_BYTES = 8 * 1024 * 1024;
548
600
  /** Without a session id to compare, a sentinel older than this is stale (fallback freshness only). */
@@ -628,6 +680,10 @@ export interface TailScanOutcome {
628
680
  readonly snippet?: string;
629
681
  readonly scannedBytes: number;
630
682
  readonly offset: number;
683
+ /** The session the scan was scoped to (FR-6) — absent only when no transcript was named. */
684
+ readonly sessionId?: string;
685
+ /** The per-session sentinel path the scan wrote, cleared, or left absent (FR-6). */
686
+ readonly pendingPath?: string;
631
687
  }
632
688
 
633
689
  /** The scan-state + sentinel pair is a read-modify-write store; per the repo concurrency rule
@@ -728,6 +784,8 @@ const writeJsonAtomic = (path: string, value: unknown): void => {
728
784
  */
729
785
  export function runRetroTailScan(dzDir: string, transcriptPath: string | null, nowIso?: string): TailScanOutcome {
730
786
  if (transcriptPath === null || transcriptPath === '') return { status: 'no-transcript', scannedBytes: 0, offset: 0 };
787
+ const sessionId = sessionIdFromTranscript(transcriptPath);
788
+ const ids = { sessionId, pendingPath: retroSessionPaths(dzDir, sessionId).pendingPath };
731
789
  try {
732
790
  return withProjectLockSync(
733
791
  dirname(dzDir),
@@ -736,43 +794,71 @@ export function runRetroTailScan(dzDir: string, transcriptPath: string | null, n
736
794
  { timeoutMs: RETRO_SCAN_LOCK_TIMEOUT_MS },
737
795
  );
738
796
  } catch (e) {
739
- if (e instanceof NamedLockTimeoutError) return { status: 'contended', scannedBytes: 0, offset: 0 };
797
+ if (e instanceof NamedLockTimeoutError) return { status: 'contended', scannedBytes: 0, offset: 0, ...ids };
740
798
  // Compromised lock or any unexpected failure: report nothing, advance nothing (never-block).
741
- return { status: 'none', scannedBytes: 0, offset: 0 };
799
+ return { status: 'none', scannedBytes: 0, offset: 0, ...ids };
742
800
  }
743
801
  }
744
802
 
745
803
  /** The transaction body — call ONLY under the named lock. Never throws for ordinary fs failures. */
746
804
  function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: string): TailScanOutcome {
805
+ // FR-2: every path this scan writes or removes is inside ITS OWN session dir; the only files it
806
+ // ever touches outside are the legacy flat pair, and only to adopt its own (FR-4).
807
+ const sessionId = sessionIdFromTranscript(transcriptPath);
808
+ const { dir, pendingPath, statePath } = retroSessionPaths(dzDir, sessionId);
809
+ const ids = { sessionId, pendingPath };
747
810
  try {
748
- const statePath = join(dzDir, RETRO_SCAN_STATE_FILE);
749
- const pendingPath = join(dzDir, RETRO_PENDING_FILE);
811
+ const legacyStatePath = join(dzDir, RETRO_SCAN_STATE_FILE);
812
+ const legacyPendingPath = join(dzDir, RETRO_PENDING_FILE);
750
813
 
814
+ const readOffset = (p: string): number | null => {
815
+ try {
816
+ const st = JSON.parse(readFileSync(p, 'utf8')) as { transcript?: string; offset?: number };
817
+ if (st.transcript === transcriptPath && typeof st.offset === 'number' && Number.isFinite(st.offset) && st.offset >= 0) return Math.floor(st.offset);
818
+ } catch { /* absent or unreadable */ }
819
+ return null;
820
+ };
751
821
  let offset = 0;
752
- try {
753
- const st = JSON.parse(readFileSync(statePath, 'utf8')) as { transcript?: string; offset?: number };
754
- if (st.transcript === transcriptPath && typeof st.offset === 'number' && Number.isFinite(st.offset) && st.offset >= 0) offset = Math.floor(st.offset);
755
- } catch { /* first scan of this transcript */ }
756
-
757
- // Prior debt carries over ONLY for the same session; a stale sentinel (another session's debt)
758
- // is dropped — the PreCompact/SessionEnd retro of THAT session was its collector, and injecting
759
- // an old session's debt into a new one is noise (acid A9).
760
- let prior: AdmissionDebt | null = null;
761
- let hadSentinel = false;
762
- try {
763
- const s = JSON.parse(readFileSync(pendingPath, 'utf8')) as Partial<RetroPendingSentinel>;
764
- if (s.transcript === transcriptPath && typeof s.snippet === 'string') {
765
- prior = { snippet: s.snippet, ...(s.awaiting !== undefined ? { awaiting: s.awaiting } : {}) };
766
- hadSentinel = true;
767
- }
768
- else { try { unlinkSync(pendingPath); } catch { /* already gone */ } }
769
- } catch { /* no sentinel */ }
822
+ let adoptLegacyState = false;
823
+ const ownOffset = readOffset(statePath);
824
+ if (ownOffset !== null) offset = ownOffset;
825
+ else if (!existsSync(statePath)) {
826
+ // FR-4: the flat bookmark is adopted ONCE — only while this session has no bookmark of its own,
827
+ // and only when it names THIS transcript. Another session's stays where it is.
828
+ const legacyOffset = readOffset(legacyStatePath);
829
+ if (legacyOffset !== null) { offset = legacyOffset; adoptLegacyState = true; }
830
+ }
831
+
832
+ const readSentinel = (p: string): { prior: AdmissionDebt | null; present: boolean } => {
833
+ try {
834
+ const s = JSON.parse(readFileSync(p, 'utf8')) as Partial<RetroPendingSentinel>;
835
+ if (s.transcript === transcriptPath && typeof s.snippet === 'string') {
836
+ return { prior: { snippet: s.snippet, ...(s.awaiting !== undefined ? { awaiting: s.awaiting } : {}) }, present: true };
837
+ }
838
+ return { prior: null, present: true };
839
+ } catch { return { prior: null, present: false }; }
840
+ };
841
+ // Prior debt: this session's own sentinel. One naming ANOTHER transcript can only mean the
842
+ // transcript was moved or renamed under the same basename — it is IGNORED and overwritten (or
843
+ // removed below when nothing is armed), never deleted as "foreign": the pre-feature branch that
844
+ // unlinked a sentinel of another transcript deleted the NEIGHBOUR session's live debt (FR-2).
845
+ const own = readSentinel(pendingPath);
846
+ let prior = own.prior;
847
+ const staleOwn = own.present && own.prior === null;
848
+ let adoptLegacyPending = false;
849
+ if (!own.present) {
850
+ // FR-4: the flat sentinel is adopted only when it is THIS transcript's and this session has no
851
+ // per-session copy yet; a stranger's, or a nobody's (no transcript field), is left untouched.
852
+ const legacy = readSentinel(legacyPendingPath);
853
+ if (legacy.prior !== null) { prior = legacy.prior; adoptLegacyPending = true; }
854
+ }
855
+ const hadSentinel = prior !== null;
770
856
 
771
857
  let size = 0;
772
858
  try { size = statSync(transcriptPath).size; } catch {
773
859
  return prior !== null
774
- ? { status: 'pending', snippet: prior.snippet, scannedBytes: 0, offset }
775
- : { status: 'none', scannedBytes: 0, offset };
860
+ ? { status: 'pending', snippet: prior.snippet, scannedBytes: 0, offset, ...ids }
861
+ : { status: 'none', scannedBytes: 0, offset, ...ids };
776
862
  }
777
863
  if (size < offset) offset = 0; // truncated/rotated transcript
778
864
  let jumped = offset === 0 && size > MAX_TAIL_SCAN_BYTES; // bound the first scan of a huge file
@@ -802,12 +888,12 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
802
888
 
803
889
  const next = foldAdmissionDebt(events, prior);
804
890
  const newOffset = offset + consumed;
805
- mkdirSync(dzDir, { recursive: true });
891
+ mkdirSync(dir, { recursive: true });
806
892
  let outcome: TailScanOutcome;
807
893
  if (next !== null) {
808
894
  const sentinel: RetroPendingSentinel = {
809
895
  schema: 1,
810
- sessionId: basename(transcriptPath).replace(/\.jsonl$/, ''),
896
+ sessionId,
811
897
  transcript: transcriptPath,
812
898
  snippet: next.snippet.replace(/\s+/g, ' ').trim().slice(0, 200),
813
899
  ts: nowIso ?? new Date().toISOString(),
@@ -826,13 +912,45 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
826
912
  catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
827
913
  outcome = { status: 'cleared', scannedBytes: consumed, offset: newOffset };
828
914
  } else {
915
+ // Our own stale sentinel (moved transcript, see above) with nothing armed: removed, so the hook
916
+ // never confronts a debt this transcript no longer carries. Same ENOENT-only tolerance.
917
+ if (staleOwn) {
918
+ try { unlinkSync(pendingPath); }
919
+ catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
920
+ }
829
921
  outcome = { status: 'none', scannedBytes: consumed, offset: newOffset };
830
922
  }
831
923
  // Commit the debt BEFORE its offset: an interruption must leave these bytes replayable.
832
924
  writeJsonAtomic(statePath, { schema: 1, transcript: transcriptPath, offset: newOffset });
833
- return outcome;
925
+ // FR-4: the adopted flat files go only AFTER the per-session copies are committed — a failure
926
+ // above leaves them in place for the next scan to adopt again. Best-effort: leftover debris
927
+ // is harmless (a present per-session file blocks re-adoption), a thrown error here would
928
+ // misreport an already-committed scan.
929
+ if (adoptLegacyPending) { try { unlinkSync(legacyPendingPath); } catch { /* debris */ } }
930
+ if (adoptLegacyState) { try { unlinkSync(legacyStatePath); } catch { /* debris */ } }
931
+ sweepStaleSessionDirs(dzDir, dir, nowIso === undefined ? Date.now() : Date.parse(nowIso));
932
+ return { ...outcome, ...ids };
834
933
  } catch {
835
- return { status: 'none', scannedBytes: 0, offset: 0 };
934
+ return { status: 'none', scannedBytes: 0, offset: 0, ...ids };
935
+ }
936
+ }
937
+
938
+ /**
939
+ * FR-5: remove dead sessions' dirs — `<dzDir>/retro/<x>/` whose `scan-state.json` mtime is older
940
+ * than {@link RETRO_SESSION_STALE_MS}. The scanning session's own dir is excluded; a dir with no
941
+ * readable scan-state cannot be dated and is left alone; every error is swallowed; at most
942
+ * {@link RETRO_SWEEP_MAX_ENTRIES} entries are examined (one readdir + ≤64 stats — NFR-1).
943
+ */
944
+ function sweepStaleSessionDirs(dzDir: string, ownDir: string, nowMs: number): void {
945
+ let names: string[];
946
+ try { names = readdirSync(join(dzDir, RETRO_SESSION_DIRNAME)).slice(0, RETRO_SWEEP_MAX_ENTRIES); } catch { return; }
947
+ for (const name of names) {
948
+ const d = join(dzDir, RETRO_SESSION_DIRNAME, name);
949
+ if (d === ownDir) continue;
950
+ try {
951
+ const age = nowMs - statSync(join(d, RETRO_SESSION_STATE_BASENAME)).mtimeMs;
952
+ if (age > RETRO_SESSION_STALE_MS) rmSync(d, { recursive: true, force: true });
953
+ } catch { /* undatable or vanished: leave it */ }
836
954
  }
837
955
  }
838
956
 
@@ -851,6 +969,10 @@ export function retroSentinelIsFresh(
851
969
  ctx: { sessionId?: string; transcriptPath?: string; nowMs: number },
852
970
  ): boolean {
853
971
  if (typeof sentinel.snippet !== 'string' || sentinel.snippet === '') return false;
972
+ // AM-1 (retro-debt-sentinel-per-session): an EMPTY session id carries no identity — a sentinel that
973
+ // names no session never matches anyone, and an empty ctx id is the same as none (the hook side
974
+ // returns '' before any fs call for it; this is the core half of the same rule).
975
+ if (sentinel.sessionId === '') return false;
854
976
  if (typeof ctx.sessionId === 'string' && ctx.sessionId !== '') return sentinel.sessionId === ctx.sessionId;
855
977
  if (typeof ctx.transcriptPath === 'string' && ctx.transcriptPath !== '') return sentinel.transcript === ctx.transcriptPath;
856
978
  const ts = typeof sentinel.ts === 'string' ? Date.parse(sentinel.ts) : NaN;