session-orchestrator 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  6. package/.cursor/skills/session-plan/SKILL.md +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/CHANGELOG.md +57 -0
  9. package/README.md +55 -51
  10. package/agents/ux-evaluator.md +1 -1
  11. package/commands/close.md +3 -3
  12. package/commands/go.md +2 -0
  13. package/commands/memory-cleanup.md +4 -3
  14. package/commands/persona-panel.md +1 -1
  15. package/commands/release.md +4 -4
  16. package/commands/session.md +3 -2
  17. package/docs/README.md +4 -4
  18. package/docs/USER-GUIDE.md +115 -48
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +55 -1
  21. package/docs/ci-setup.md +1 -1
  22. package/docs/codex-setup.md +1 -0
  23. package/docs/components.md +2 -2
  24. package/docs/cursor-setup.md +1 -0
  25. package/docs/events-schema.md +4 -1
  26. package/docs/instruction-delivery.md +1 -1
  27. package/docs/memory-proposal-flow.md +3 -3
  28. package/docs/migration-v4.md +2 -2
  29. package/docs/owner-config-schema.md +74 -90
  30. package/docs/persona-panel.md +4 -4
  31. package/docs/pi-setup.md +1 -0
  32. package/docs/rule-authoring.md +13 -6
  33. package/docs/scope-collision-guard.md +2 -0
  34. package/docs/session-config-reference.md +55 -22
  35. package/docs/session-config-template.md +9 -5
  36. package/docs/vault-docs-architecture.md +4 -2
  37. package/hooks/_lib/hook-import-set.json +28 -3
  38. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks.json +1 -1
  41. package/hooks/pre-bash-issue-budget.mjs +123 -26
  42. package/hooks/subagent-telemetry.mjs +106 -20
  43. package/package.json +4 -4
  44. package/scripts/baseline-archetypes.mjs +28 -0
  45. package/scripts/ci/assert-coverage-green.mjs +100 -0
  46. package/scripts/lib/auto-dialectic.mjs +0 -68
  47. package/scripts/lib/baseline-archetypes.mjs +439 -0
  48. package/scripts/lib/build-live-signals.mjs +5 -6
  49. package/scripts/lib/config/issue-budget.mjs +68 -8
  50. package/scripts/lib/config/private-config-dir.mjs +3 -2
  51. package/scripts/lib/config/remote-hosts.mjs +2 -2
  52. package/scripts/lib/config-schema.mjs +79 -0
  53. package/scripts/lib/events.mjs +3 -3
  54. package/scripts/lib/file-lock.mjs +47 -5
  55. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  56. package/scripts/lib/issue-budget.mjs +76 -3
  57. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  58. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  59. package/scripts/lib/owner-config.example.yaml +29 -46
  60. package/scripts/lib/owner-yaml.mjs +14 -13
  61. package/scripts/lib/project-hygiene.mjs +182 -6
  62. package/scripts/lib/quality-gate.mjs +13 -6
  63. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  64. package/scripts/lib/rules-sync.mjs +34 -4
  65. package/scripts/lib/session-close-backfill.mjs +182 -40
  66. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  67. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  68. package/scripts/lib/session-identity/own-session.mjs +24 -13
  69. package/scripts/lib/session-schema/constants.mjs +6 -0
  70. package/scripts/lib/session-schema/validator.mjs +20 -0
  71. package/scripts/lib/session-shape.mjs +558 -0
  72. package/scripts/lib/session-start-probes.mjs +10 -3
  73. package/scripts/lib/session-token-rollup.mjs +95 -10
  74. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  75. package/scripts/lib/state-md.mjs +1 -0
  76. package/scripts/lib/subagents-schema.mjs +77 -9
  77. package/scripts/lib/telemetry/pricing.mjs +197 -0
  78. package/scripts/lib/telemetry/sync.mjs +50 -1
  79. package/scripts/lib/validate/check-owner-leakage.mjs +17 -8
  80. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  81. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  82. package/scripts/lib/vault-mirror/process.mjs +2 -1
  83. package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
  84. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  85. package/scripts/lib/wave-resource-gate.mjs +23 -27
  86. package/scripts/lib/wave-sizing.mjs +10 -3
  87. package/scripts/materialize-wave-scope.mjs +68 -14
  88. package/scripts/print-applicable-rules.mjs +7 -6
  89. package/scripts/print-learnings-index.mjs +3 -2
  90. package/scripts/release.mjs +32 -11
  91. package/scripts/session-shape.mjs +266 -0
  92. package/skills/_shared/config-reading.md +15 -9
  93. package/skills/_shared/private-capability-context.md +89 -0
  94. package/skills/bootstrap/SKILL.md +61 -13
  95. package/skills/bootstrap/_shared-template.md +99 -14
  96. package/skills/bootstrap/deep-template.md +36 -26
  97. package/skills/bootstrap/fast-template.md +44 -8
  98. package/skills/bootstrap/intensity-heuristic.md +10 -4
  99. package/skills/bootstrap/private-contract.md +119 -0
  100. package/skills/bootstrap/public-fallback.md +30 -18
  101. package/skills/bootstrap/standard-template.md +39 -24
  102. package/skills/discovery/probes-ui.md +1 -1
  103. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  104. package/skills/evolve/SKILL.md +2 -2
  105. package/skills/gitlab-ops/SKILL.md +3 -3
  106. package/skills/grill/SKILL.md +1 -1
  107. package/skills/memory-cleanup/SKILL.md +2 -2
  108. package/skills/plan/mode-new.md +9 -0
  109. package/skills/reconcile/SKILL.md +1 -1
  110. package/skills/session-end/SKILL.md +3 -2
  111. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  112. package/skills/session-end/phase-3-6-tail.md +23 -65
  113. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  114. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  115. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  116. package/skills/session-end/session-metrics-write.md +31 -12
  117. package/skills/session-plan/SKILL.md +56 -48
  118. package/skills/session-plan/wave-template.md +8 -15
  119. package/skills/session-start/SKILL.md +18 -2
  120. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  121. package/skills/session-start/phase-8-5-express-path.md +12 -9
  122. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  123. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  124. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  125. package/skills/test-runner/rubric-v1.md +2 -2
  126. package/skills/wave-executor/SKILL.md +42 -12
  127. package/skills/wave-executor/circuit-breaker.md +3 -1
  128. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  129. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  130. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  131. package/templates/nextjs-minimal/package.json +1 -1
  132. package/templates/node-minimal/package.json +1 -1
  133. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  134. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  135. package/scripts/lib/owner-config/coerce.mjs +0 -29
  136. package/scripts/lib/owner-config/constants.mjs +0 -21
  137. package/scripts/lib/owner-config/defaults.mjs +0 -50
  138. package/scripts/lib/owner-config/error.mjs +0 -19
  139. package/scripts/lib/owner-config/index.mjs +0 -13
  140. package/scripts/lib/owner-config/merge.mjs +0 -52
  141. package/scripts/lib/owner-config/validate.mjs +0 -259
  142. package/scripts/lib/owner-config-loader.mjs +0 -170
  143. package/scripts/lib/owner-config.mjs +0 -28
  144. package/scripts/lib/soul-resolve.mjs +0 -130
  145. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -0,0 +1,178 @@
1
+ /**
2
+ * session-end/tail-runner.mjs — the APPLY half of the Phase 3.6.x tail.
3
+ *
4
+ * `phase-skip.mjs` decides WHICH tail phases should run and is side-effect-free
5
+ * by contract. Until now nothing was the other half for 3.6.4: the
6
+ * Expired-Learnings Sweep was coordinator PROSE, so the dry-run decision was
7
+ * computed on every close and the apply path was never mechanically reached.
8
+ * Census (2026-09-09, `rg -n sweepExpiredLearnings scripts/ hooks/ skills/
9
+ * tests/`): the only call sites were the definition itself, the `dryRun: true`
10
+ * probe in `phase-skip.mjs`, the standalone CLI, and tests — no session-end
11
+ * caller applied anything. Measured consequence across three consumer repos:
12
+ * 0 sweeps ever applied, 628 learnings resident in the active stores.
13
+ *
14
+ * This module closes that gap and nothing else. It is deliberately NOT a
15
+ * general tail executor:
16
+ *
17
+ * - **3.6.3 Memory-Proposals** — AUQ-gated (the operator approves each
18
+ * proposal before it is written); `.claude/rules/ask-via-tool.md` AUQ-004
19
+ * puts the tool out of reach of anything but the coordinator thread.
20
+ * - **3.6.5 Auto-Dream** and **3.6.7 Auto-Dialectic** — both NUDGES that end
21
+ * in a subagent dispatch (memory-cleanup, `dialectic-deriver`); a library
22
+ * function cannot dispatch an agent.
23
+ * - **3.6.6 Skill-Judge** — needs a live LLM dispatch for the judgement.
24
+ * - **3.6.8 Reconcile** — the write is operator-approved per proposal (AUQ),
25
+ * which is the whole never-always-on firewall of Epic #693.
26
+ *
27
+ * So {@link runTailPhases} exists as the SEAM (one dispatch table, one result
28
+ * shape) rather than as an abstraction over six phases that will never all be
29
+ * mechanical. A future phase that becomes mechanical is added here; the five
30
+ * above stay coordinator-executed by design, not by omission.
31
+ *
32
+ * Never-throws contract, and its DIRECTION is the opposite of `phase-skip.mjs`.
33
+ * The planner fails OPEN (probe error → run the phase, because losing a phase
34
+ * silently is worse than running it needlessly). A runner that writes to disk
35
+ * must fail CLOSED: any error yields `{ran: false, reason: 'error'}` and the
36
+ * close proceeds. Session close is the one thing that must never be blocked by
37
+ * a best-effort maintenance sweep — a store that stays unswept for one more
38
+ * session costs nothing; a close that aborts loses the session record.
39
+ *
40
+ * Plain Node ESM, no external deps.
41
+ *
42
+ * @typedef {Object} SweepResult
43
+ * @property {boolean} ran - true only when the store was actually rewritten.
44
+ * @property {string} [reason] - why it did not run ('plan-skip' | 'no-plan' | 'error').
45
+ * @property {string} [error] - error message, `reason === 'error'` only.
46
+ * @property {number} [scanned] - entries read from the active store (ran only).
47
+ * @property {number} [archived] - entries moved to the archive sidecar (ran only).
48
+ * @property {string} [archivePath] - the archive sidecar written to (ran only).
49
+ */
50
+
51
+ import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
52
+ import { resolveLearningsPaths } from './phase-skip.mjs';
53
+
54
+ /** Event name — a plain string literal so the events-schema census can see it. */
55
+ const SWEEP_EVENT = 'orchestrator.learnings.sweep_applied';
56
+
57
+ /** Payload marker for the producer, so the ledger separates it from the CLI. */
58
+ const SWEEP_SOURCE = 'session-end-3.6.4';
59
+
60
+ /**
61
+ * Pull the decision for one phase id out of a `planTailPhases()` result.
62
+ *
63
+ * Accepts three shapes so callers need no adapter: the full
64
+ * `{plan, skippedReport}` envelope, a bare `PhaseDecision[]`, or a single
65
+ * `PhaseDecision` object. Anything else → `null` (treated as "no plan").
66
+ *
67
+ * @param {object|Array|undefined} plan
68
+ * @param {string} phase
69
+ * @returns {object|null}
70
+ */
71
+ function findPhaseDecision(plan, phase) {
72
+ if (!plan) return null;
73
+ const list = Array.isArray(plan) ? plan : Array.isArray(plan.plan) ? plan.plan : null;
74
+ if (list) return list.find((d) => d && d.phase === phase) ?? null;
75
+ return plan.phase === phase ? plan : null;
76
+ }
77
+
78
+ /**
79
+ * Emit the one sweep record. Best-effort by construction: a telemetry failure
80
+ * never changes the caller's return value.
81
+ *
82
+ * The shape (lazy `events.mjs` import, explicit `repoRoot`, silent catch) is
83
+ * copied from `_emitEvaluated` in `scripts/lib/express-path.mjs`. Both halves
84
+ * are load-bearing: the lazy import keeps `platform.mjs`'s load-time filesystem
85
+ * walk-up out of the static graph of a module the planner imports, and the
86
+ * refusal to emit WITHOUT an explicit `repoRoot` prevents the `SO_PROJECT_DIR`
87
+ * fallback from writing a synthetic record into whatever tree the ambient env
88
+ * happens to resolve to (#941 — that mistake once landed a test record in the
89
+ * operator's real fleet ledger).
90
+ *
91
+ * @param {{repoRoot: string, scanned: number, archived: number}} ctx
92
+ * @returns {Promise<void>}
93
+ */
94
+ async function emitSweepApplied({ repoRoot, scanned, archived }) {
95
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
96
+ process.stderr.write(
97
+ `tail-runner: skipped ${SWEEP_EVENT} — no repoRoot given; ` +
98
+ 'refusing the ambient SO_PROJECT_DIR destination (#941).\n',
99
+ );
100
+ return;
101
+ }
102
+ try {
103
+ const { emitEvent, sessionAttribution } = await import('../events.mjs');
104
+ await emitEvent(
105
+ SWEEP_EVENT,
106
+ { scanned, archived, source: SWEEP_SOURCE, ...sessionAttribution(repoRoot) },
107
+ { repoRoot },
108
+ );
109
+ } catch {
110
+ // Best-effort telemetry — the sweep already happened and its result is
111
+ // authoritative whether or not the ledger accepted the record.
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Phase 3.6.4 — apply the Expired-Learnings Sweep the planner decided on.
117
+ *
118
+ * Runs ONLY when the plan's 3.6.4 decision says `run === true`. Any other
119
+ * shape (skip decision, phase absent, no plan at all) returns without touching
120
+ * disk — the planner owns the decision, this function owns the write, and a
121
+ * runner that re-derives the decision would be free to disagree with it.
122
+ *
123
+ * @param {object} args
124
+ * @param {string} args.repoRoot Absolute repo root.
125
+ * @param {object|Array} [args.plan] `planTailPhases()` result, its `plan`
126
+ * array, or the bare 3.6.4 decision.
127
+ * @param {Date|number} [args.now] Injectable clock (grace-window maths).
128
+ * @param {number} [args.graceDays] Override the 14-day grace window.
129
+ * @param {boolean} [args.emit=true] Set false to suppress the ledger record.
130
+ * @returns {Promise<SweepResult>} Never throws.
131
+ */
132
+ export async function runExpiredSweep({ repoRoot, plan, now, graceDays, emit = true } = {}) {
133
+ try {
134
+ const decision = findPhaseDecision(plan, '3.6.4');
135
+ if (!decision) return { ran: false, reason: 'no-plan' };
136
+ if (decision.run !== true) {
137
+ return { ran: false, reason: 'plan-skip', planReason: decision.reason };
138
+ }
139
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
140
+ return { ran: false, reason: 'error', error: 'runExpiredSweep: repoRoot is required' };
141
+ }
142
+
143
+ const { filePath, archivePath } = resolveLearningsPaths(repoRoot);
144
+ const res = await sweepExpiredLearnings({
145
+ filePath,
146
+ archivePath,
147
+ now,
148
+ dryRun: false,
149
+ ...(graceDays === undefined ? {} : { graceDays }),
150
+ });
151
+
152
+ const scanned = res?.scanned ?? 0;
153
+ const archived = res?.archived ?? 0;
154
+ if (emit) await emitSweepApplied({ repoRoot, scanned, archived });
155
+
156
+ return { ran: true, scanned, archived, archivePath: res?.archivePath ?? archivePath };
157
+ } catch (err) {
158
+ // Fail CLOSED (see the module header): a maintenance sweep must never be
159
+ // able to block a session close.
160
+ return { ran: false, reason: 'error', error: err && err.message ? err.message : String(err) };
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Run every MECHANICAL phase of the session-end tail.
166
+ *
167
+ * Today that is exactly one — 3.6.4. See the module header for why 3.6.3 and
168
+ * 3.6.5–3.6.8 stay coordinator-executed (AUQ-gated per
169
+ * `.claude/rules/ask-via-tool.md` AUQ-004, or requiring a subagent dispatch a
170
+ * library function cannot make). The keyed return shape is the seam: a caller
171
+ * reads `result['3.6.4']` today and keeps compiling when a second phase lands.
172
+ *
173
+ * @param {object} args — forwarded verbatim to {@link runExpiredSweep}.
174
+ * @returns {Promise<Record<string, SweepResult>>} Never throws.
175
+ */
176
+ export async function runTailPhases({ repoRoot, plan, now, graceDays, emit } = {}) {
177
+ return { '3.6.4': await runExpiredSweep({ repoRoot, plan, now, graceDays, emit }) };
178
+ }
@@ -80,8 +80,8 @@ function readLockIds(repoRoot) {
80
80
  * handled right now (`session_id` / `sessionId`, plus `parent_session_id`
81
81
  * for a sub-agent invocation, whose coordinator is equally us). The only
82
82
  * tier that is per-INVOCATION rather than per-working-copy.
83
- * 2. `CLAUDE_CODE_SESSION_ID` — process-scoped, absent on harnesses that
84
- * export no session env var.
83
+ * 2. The native process-scoped id (`CLAUDE_CODE_SESSION_ID` or
84
+ * `CODEX_THREAD_ID`), selected by {@link readProcessLocalSessionIds}.
85
85
  * 3. `session.lock` `session_id` / `semantic_session_id` — repo-GLOBAL, and
86
86
  * the identity the WRITER of a manifest uses: `wave-scope.json`'s
87
87
  * `session` field comes from `sessionAttribution()`, which reads this same
@@ -138,20 +138,12 @@ function readLockIds(repoRoot) {
138
138
  * never as a mismatch.
139
139
  */
140
140
  export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
141
- const ids = new Set();
141
+ const ids = new Set(readProcessLocalSessionIds({ hookInput }));
142
142
  const add = (value) => {
143
143
  const trimmed = typeof value === 'string' ? value.trim() : '';
144
144
  if (trimmed) ids.add(trimmed);
145
145
  };
146
146
 
147
- // Source 1 — the harness's statement about THIS invocation.
148
- if (hookInput && typeof hookInput === 'object') {
149
- for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
150
- }
151
-
152
- // Source 2 — process-scoped env var.
153
- add(process.env.CLAUDE_CODE_SESSION_ID);
154
-
155
147
  // Source 3 — repo-global lock file (the manifest writer's own identity).
156
148
  try {
157
149
  const lock = readLockIds(repoRoot);
@@ -190,6 +182,14 @@ export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
190
182
  * rather than unioning is the fix — a better signal REPLACES a worse one
191
183
  * (`.claude/rules/host-resources.md` § HR-102).
192
184
  *
185
+ * Native env identity (#1274): an explicit SO_PLATFORM selects ONLY its own
186
+ * variable (claude → CLAUDE_CODE_SESSION_ID, codex → CODEX_THREAD_ID). Cursor
187
+ * and Pi have no native env source here. Without a valid explicit selection,
188
+ * a single non-empty native id, or two equal ids, is usable; conflicting ids
189
+ * are ambiguous and contribute nothing. This prevents an inherited parent
190
+ * harness's id from confirming a peer lock. Hook invocation/parent ids remain
191
+ * valid independently. No filesystem/platform detection is an identity proof.
192
+ *
193
193
  * Never throws.
194
194
  *
195
195
  * @param {{ env?: object, hookInput?: object|null }} [opts]
@@ -211,8 +211,19 @@ export function readProcessLocalSessionIds({ env = process.env, hookInput = null
211
211
  if (hookInput && typeof hookInput === 'object') {
212
212
  for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
213
213
  }
214
- // Tier 2 — process-scoped env var.
215
- add(env?.CLAUDE_CODE_SESSION_ID);
214
+ // Tier 2 — select a native process id without importing the platform module
215
+ // into this hook-safe leaf or using shared repository markers as evidence.
216
+ const trim = (value) => typeof value === 'string' ? value.trim() : '';
217
+ const platform = trim(env?.SO_PLATFORM);
218
+ const claudeId = trim(env?.CLAUDE_CODE_SESSION_ID);
219
+ const codexId = trim(env?.CODEX_THREAD_ID);
220
+ if (platform === 'claude') add(claudeId);
221
+ else if (platform === 'codex') add(codexId);
222
+ else if (platform !== 'cursor' && platform !== 'pi') {
223
+ // Invalid/absent SO_PLATFORM is not an explicit choice (platform.mjs uses
224
+ // the same trimmed allowlist). Disagreement must never become a union.
225
+ if (!claudeId || !codexId || claudeId === codexId) add(claudeId || codexId);
226
+ }
216
227
 
217
228
  return ids;
218
229
  }
@@ -161,6 +161,12 @@ export const OPTIONAL_FIELDS = Object.freeze([
161
161
  'total_token_input',
162
162
  'total_token_output',
163
163
  'subagents_with_tokens',
164
+ // #1244 — cache buckets + cost estimate (additive, optional).
165
+ 'total_token_input_uncached',
166
+ 'total_token_cache_read',
167
+ 'total_token_cache_creation',
168
+ 'total_cost_usd',
169
+ '_token_schema',
164
170
  // Epic #724 C1 — SessionEnd close-through backfill provenance fields.
165
171
  // `status` distinguishes normally-closed ('completed') from hook-backfilled
166
172
  // ('abandoned') records. The `_backfill_*` markers record how a stub was
@@ -384,6 +384,26 @@ function _validateOptionalFields(entry) {
384
384
  );
385
385
  }
386
386
  }
387
+ // #1244 — cache buckets, cost estimate and the token-contract marker.
388
+ // Same non-negative-finite-or-null contract as the two totals above;
389
+ // total_cost_usd is fractional and null means "unknown model", never 0.
390
+ for (const field of [
391
+ 'total_token_input_uncached',
392
+ 'total_token_cache_read',
393
+ 'total_token_cache_creation',
394
+ 'total_cost_usd',
395
+ '_token_schema',
396
+ ]) {
397
+ const value = entry[field];
398
+ if (value !== undefined && value !== null) {
399
+ if (!Number.isFinite(value) || value < 0) {
400
+ throw new ValidationError(
401
+ `${field} must be a non-negative finite number or null, got: ${value}`
402
+ );
403
+ }
404
+ }
405
+ }
406
+
387
407
  // subagents_with_tokens: non-negative integer (never null — it is always a count, defaulting to 0).
388
408
  if (entry.subagents_with_tokens !== undefined && entry.subagents_with_tokens !== null) {
389
409
  if (