session-orchestrator 4.1.0 → 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 (137) 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 +34 -0
  9. package/README.md +8 -8
  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/session.md +3 -2
  16. package/docs/README.md +4 -4
  17. package/docs/USER-GUIDE.md +115 -48
  18. package/docs/agent-authoring.md +2 -2
  19. package/docs/baseline.md +55 -1
  20. package/docs/ci-setup.md +1 -1
  21. package/docs/codex-setup.md +1 -0
  22. package/docs/components.md +2 -2
  23. package/docs/cursor-setup.md +1 -0
  24. package/docs/events-schema.md +4 -1
  25. package/docs/instruction-delivery.md +1 -1
  26. package/docs/memory-proposal-flow.md +3 -3
  27. package/docs/migration-v4.md +2 -2
  28. package/docs/owner-config-schema.md +74 -90
  29. package/docs/persona-panel.md +4 -4
  30. package/docs/pi-setup.md +1 -0
  31. package/docs/rule-authoring.md +13 -6
  32. package/docs/scope-collision-guard.md +2 -0
  33. package/docs/session-config-reference.md +55 -22
  34. package/docs/session-config-template.md +9 -5
  35. package/docs/vault-docs-architecture.md +4 -2
  36. package/hooks/_lib/hook-import-set.json +28 -3
  37. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks.json +1 -1
  40. package/hooks/pre-bash-issue-budget.mjs +123 -26
  41. package/hooks/subagent-telemetry.mjs +106 -20
  42. package/package.json +4 -4
  43. package/scripts/baseline-archetypes.mjs +28 -0
  44. package/scripts/lib/auto-dialectic.mjs +0 -68
  45. package/scripts/lib/baseline-archetypes.mjs +439 -0
  46. package/scripts/lib/build-live-signals.mjs +5 -6
  47. package/scripts/lib/config/issue-budget.mjs +68 -8
  48. package/scripts/lib/config/private-config-dir.mjs +3 -2
  49. package/scripts/lib/config/remote-hosts.mjs +2 -2
  50. package/scripts/lib/config-schema.mjs +79 -0
  51. package/scripts/lib/file-lock.mjs +47 -5
  52. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  53. package/scripts/lib/issue-budget.mjs +76 -3
  54. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  55. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  56. package/scripts/lib/owner-config.example.yaml +29 -46
  57. package/scripts/lib/owner-yaml.mjs +14 -13
  58. package/scripts/lib/quality-gate.mjs +13 -6
  59. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  60. package/scripts/lib/rules-sync.mjs +34 -4
  61. package/scripts/lib/session-close-backfill.mjs +182 -40
  62. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  63. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  64. package/scripts/lib/session-schema/constants.mjs +6 -0
  65. package/scripts/lib/session-schema/validator.mjs +20 -0
  66. package/scripts/lib/session-shape.mjs +558 -0
  67. package/scripts/lib/session-start-probes.mjs +10 -3
  68. package/scripts/lib/session-token-rollup.mjs +95 -10
  69. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  70. package/scripts/lib/state-md.mjs +1 -0
  71. package/scripts/lib/subagents-schema.mjs +77 -9
  72. package/scripts/lib/telemetry/pricing.mjs +197 -0
  73. package/scripts/lib/telemetry/sync.mjs +50 -1
  74. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  75. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  76. package/scripts/lib/vault-mirror/process.mjs +2 -1
  77. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  78. package/scripts/lib/wave-resource-gate.mjs +23 -27
  79. package/scripts/lib/wave-sizing.mjs +10 -3
  80. package/scripts/materialize-wave-scope.mjs +68 -14
  81. package/scripts/print-applicable-rules.mjs +7 -6
  82. package/scripts/print-learnings-index.mjs +3 -2
  83. package/scripts/session-shape.mjs +266 -0
  84. package/skills/_shared/config-reading.md +15 -9
  85. package/skills/_shared/private-capability-context.md +89 -0
  86. package/skills/bootstrap/SKILL.md +61 -13
  87. package/skills/bootstrap/_shared-template.md +99 -14
  88. package/skills/bootstrap/deep-template.md +36 -26
  89. package/skills/bootstrap/fast-template.md +44 -8
  90. package/skills/bootstrap/intensity-heuristic.md +10 -4
  91. package/skills/bootstrap/private-contract.md +119 -0
  92. package/skills/bootstrap/public-fallback.md +30 -18
  93. package/skills/bootstrap/standard-template.md +39 -24
  94. package/skills/discovery/probes-ui.md +1 -1
  95. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  96. package/skills/evolve/SKILL.md +2 -2
  97. package/skills/gitlab-ops/SKILL.md +3 -3
  98. package/skills/grill/SKILL.md +1 -1
  99. package/skills/memory-cleanup/SKILL.md +2 -2
  100. package/skills/plan/mode-new.md +9 -0
  101. package/skills/reconcile/SKILL.md +1 -1
  102. package/skills/session-end/SKILL.md +3 -2
  103. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  104. package/skills/session-end/phase-3-6-tail.md +23 -65
  105. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  106. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  107. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  108. package/skills/session-end/session-metrics-write.md +31 -12
  109. package/skills/session-plan/SKILL.md +56 -48
  110. package/skills/session-plan/wave-template.md +8 -15
  111. package/skills/session-start/SKILL.md +18 -2
  112. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  113. package/skills/session-start/phase-8-5-express-path.md +12 -9
  114. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  115. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  116. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  117. package/skills/test-runner/rubric-v1.md +2 -2
  118. package/skills/wave-executor/SKILL.md +42 -12
  119. package/skills/wave-executor/circuit-breaker.md +3 -1
  120. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  121. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  122. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  123. package/templates/nextjs-minimal/package.json +1 -1
  124. package/templates/node-minimal/package.json +1 -1
  125. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  126. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  127. package/scripts/lib/owner-config/coerce.mjs +0 -29
  128. package/scripts/lib/owner-config/constants.mjs +0 -21
  129. package/scripts/lib/owner-config/defaults.mjs +0 -50
  130. package/scripts/lib/owner-config/error.mjs +0 -19
  131. package/scripts/lib/owner-config/index.mjs +0 -13
  132. package/scripts/lib/owner-config/merge.mjs +0 -52
  133. package/scripts/lib/owner-config/validate.mjs +0 -259
  134. package/scripts/lib/owner-config-loader.mjs +0 -170
  135. package/scripts/lib/owner-config.mjs +0 -28
  136. package/scripts/lib/soul-resolve.mjs +0 -130
  137. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -53,11 +53,28 @@
53
53
  * be reconstruction, not correction. Consumers comparing token totals across the
54
54
  * 2026-08-11 boundary must treat it as a series break.
55
55
  *
56
+ * ## Schema-version boundary — v1 and v2 token_input are different quantities (#1244)
57
+ *
58
+ * Since 2026-09-09 (`schema_version: 2`) a stop record's `token_input` is
59
+ * BILLABLE PROMPT VOLUME — uncached + cache_read + cache_creation — where v1
60
+ * held raw `usage.input_tokens` only. Under prompt caching those differ by up
61
+ * to five orders of magnitude (measured: 56 vs 3,676,179 on one agent), so
62
+ * summing them together produces a number describing neither. This module
63
+ * therefore sums ONLY `schema_version >= 2` token-bearing records into
64
+ * `total_token_*` and reports the excluded ones as `legacy_v1_records`: the
65
+ * boundary is DECLARED, never silent.
66
+ *
67
+ * `total_cost_usd` is computed per record via `costUsd()` and is null when ANY
68
+ * priced record carries a model the price table does not know — a partial cost
69
+ * is worse than no cost, because it reads as a complete one. `cost_records_priced`
70
+ * / `cost_records_total` say how much of the session the estimate covers.
71
+ *
56
72
  * @module session-token-rollup
57
73
  */
58
74
 
59
75
  import { readFileSync } from 'node:fs';
60
76
  import { resolve } from 'node:path';
77
+ import { costUsd } from './telemetry/pricing.mjs';
61
78
 
62
79
  // ---------------------------------------------------------------------------
63
80
  // Default subagents.jsonl path (relative to cwd, mirroring the rest of the
@@ -85,12 +102,29 @@ function isTokenBearing(record) {
85
102
  return record?.subagent_transcript_found === true;
86
103
  }
87
104
 
105
+ /**
106
+ * Is this record inside the schema_version 2 token contract? (#1244)
107
+ * @param {object} record
108
+ * @returns {boolean}
109
+ */
110
+ function isV2(record) {
111
+ return typeof record?.schema_version === 'number' && record.schema_version >= 2;
112
+ }
113
+
88
114
  /**
89
115
  * @typedef {Object} TokenRollupResult
90
116
  * @property {number|null} total_token_input - Sum of token_input across TOKEN-BEARING matched records; null when none had a non-null value.
91
117
  * @property {number|null} total_token_output - Sum of token_output across TOKEN-BEARING matched records; null when none had a non-null value.
92
118
  * @property {number} subagents_with_tokens - Count of distinct agent_ids with at least one token-bearing record. This is the numerator of the honest coverage ratio.
93
119
  * @property {number} matched_records - Total count of JSONL records matched by parentSessionId. Counts start records, phantom stops and pre-#949 records alike, so it is NOT the denominator for a token-coverage ratio — dividing by it is what made healthy sessions read as 12% covered.
120
+ * @property {number|null} total_token_input_uncached - Sum of token_input_uncached across v2 token-bearing records.
121
+ * @property {number|null} total_token_cache_read - Sum of token_cache_read across v2 token-bearing records.
122
+ * @property {number|null} total_token_cache_creation - Sum of token_cache_creation across v2 token-bearing records.
123
+ * @property {number|null} total_cost_usd - Σ costUsd() over v2 token-bearing records; null when ANY of them carries an unknown model (never 0 — see telemetry/pricing.mjs).
124
+ * @property {number} cost_records_priced - How many token-bearing records the price table could price.
125
+ * @property {number} cost_records_total - How many token-bearing records were candidates for pricing.
126
+ * @property {number} legacy_v1_records - Token-bearing records EXCLUDED from every total above because their schema_version < 2 (their token_input is a different quantity).
127
+ * @property {2} _token_schema - The token contract these totals were computed under.
94
128
  */
95
129
 
96
130
  /**
@@ -112,6 +146,14 @@ export function rollupSessionTokens({
112
146
  total_token_output: null,
113
147
  subagents_with_tokens: 0,
114
148
  matched_records: 0,
149
+ total_token_input_uncached: null,
150
+ total_token_cache_read: null,
151
+ total_token_cache_creation: null,
152
+ total_cost_usd: null,
153
+ cost_records_priced: 0,
154
+ cost_records_total: 0,
155
+ legacy_v1_records: 0,
156
+ _token_schema: 2,
115
157
  };
116
158
 
117
159
  if (typeof parentSessionId !== 'string' || parentSessionId.length === 0) {
@@ -157,10 +199,21 @@ export function rollupSessionTokens({
157
199
  // Aggregate — skip null/undefined token values.
158
200
  let sumInput = null;
159
201
  let sumOutput = null;
202
+ let sumUncached = null;
203
+ let sumCacheRead = null;
204
+ let sumCacheCreation = null;
205
+ let sumCost = null;
206
+ let costPriced = 0;
207
+ let costTotal = 0;
208
+ let costUnknownModel = false;
209
+ let legacyV1 = 0;
160
210
 
161
211
  // Track distinct agent_ids that contributed at least one non-null token.
162
212
  const agentsWithTokens = new Set();
163
213
 
214
+ const addNonNegative = (acc, value) =>
215
+ typeof value === 'number' && value >= 0 ? (acc ?? 0) + value : acc;
216
+
164
217
  for (const record of matched) {
165
218
  // Provenance gate (#949) — a record whose tokens describe the PARENT
166
219
  // transcript, or no transcript at all, contributes nothing. Skipping it
@@ -169,24 +222,48 @@ export function rollupSessionTokens({
169
222
  // which is true, instead of a fabricated 0.
170
223
  if (!isTokenBearing(record)) continue;
171
224
 
225
+ // Schema gate (#1244) — a v1 record's token_input is raw uncached input,
226
+ // a different quantity from a v2 record's billable prompt volume. Count it
227
+ // so the boundary is visible, never sum it.
228
+ if (!isV2(record)) {
229
+ legacyV1 += 1;
230
+ continue;
231
+ }
232
+
172
233
  const inp = record.token_input;
173
234
  const out = record.token_output;
174
235
 
175
- if (typeof inp === 'number' && inp >= 0) {
176
- sumInput = (sumInput ?? 0) + inp;
177
- }
178
- if (typeof out === 'number' && out >= 0) {
179
- sumOutput = (sumOutput ?? 0) + out;
180
- }
236
+ sumInput = addNonNegative(sumInput, inp);
237
+ sumOutput = addNonNegative(sumOutput, out);
238
+ sumUncached = addNonNegative(sumUncached, record.token_input_uncached);
239
+ sumCacheRead = addNonNegative(sumCacheRead, record.token_cache_read);
240
+ sumCacheCreation = addNonNegative(sumCacheCreation, record.token_cache_creation);
181
241
 
182
242
  // Count this agent as having tokens if either field is a non-null number.
183
- if (
184
- (typeof inp === 'number' && inp >= 0) ||
185
- (typeof out === 'number' && out >= 0)
186
- ) {
243
+ const hasTokens =
244
+ (typeof inp === 'number' && inp >= 0) || (typeof out === 'number' && out >= 0);
245
+ if (hasTokens) {
187
246
  if (record.agent_id !== undefined && record.agent_id !== null) {
188
247
  agentsWithTokens.add(record.agent_id);
189
248
  }
249
+
250
+ // Cost: every token-bearing v2 record is a pricing candidate. One unknown
251
+ // model poisons the SESSION total — a cost covering some of the agents
252
+ // reads as covering all of them.
253
+ costTotal += 1;
254
+ const cost = costUsd({
255
+ model: record.model,
256
+ tokenInputUncached: record.token_input_uncached,
257
+ tokenCacheRead: record.token_cache_read,
258
+ tokenCacheCreation: record.token_cache_creation,
259
+ tokenOutput: record.token_output,
260
+ });
261
+ if (cost === null) {
262
+ costUnknownModel = true;
263
+ } else {
264
+ costPriced += 1;
265
+ sumCost = (sumCost ?? 0) + cost;
266
+ }
190
267
  }
191
268
  }
192
269
 
@@ -195,5 +272,13 @@ export function rollupSessionTokens({
195
272
  total_token_output: sumOutput,
196
273
  subagents_with_tokens: agentsWithTokens.size,
197
274
  matched_records: matched.length,
275
+ total_token_input_uncached: sumUncached,
276
+ total_token_cache_read: sumCacheRead,
277
+ total_token_cache_creation: sumCacheCreation,
278
+ total_cost_usd: costUnknownModel ? null : sumCost,
279
+ cost_records_priced: costPriced,
280
+ cost_records_total: costTotal,
281
+ legacy_v1_records: legacyV1,
282
+ _token_schema: 2,
198
283
  };
199
284
  }
@@ -11,9 +11,10 @@
11
11
  */
12
12
 
13
13
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
14
- import { dirname, join, resolve as resolvePath } from 'node:path';
14
+ import { dirname, resolve as resolvePath } from 'node:path';
15
15
  import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
16
16
  import { withStateMdLock } from '../session-lock.mjs';
17
+ import { resolveStateDir } from '../platform.mjs';
17
18
 
18
19
  /**
19
20
  * Sets frontmatter.updated to the given ISO 8601 timestamp and returns the
@@ -70,33 +71,26 @@ export function updateFrontmatterFields(contents, fields) {
70
71
  // writeFileSync(STATE)` migrate to `await writeStateMd(repoRoot, contents => …)`,
71
72
  // which mechanically serialises concurrent writers via `withStateMdLock`.
72
73
 
73
- // Canonical STATE.md path candidates (matches harness-audit category1 order).
74
- const STATE_MD_CANDIDATES = [
75
- '.claude/STATE.md',
76
- '.codex/STATE.md',
77
- '.cursor/STATE.md',
78
- '.pi/STATE.md',
79
- ];
74
+ // Legacy fallback order; the requested artifact's active harness always wins.
75
+ const STATE_DIR_CANDIDATES = ['.claude', '.codex', '.cursor', '.pi'];
80
76
 
81
- function preferredStateMdCandidate() {
82
- const envStateDir = process.env.SO_STATE_DIR;
83
- if (typeof envStateDir === 'string' && envStateDir.length > 0) {
84
- return join(envStateDir, 'STATE.md');
85
- }
86
- switch (process.env.SO_PLATFORM) {
87
- case 'codex': return '.codex/STATE.md';
88
- case 'cursor': return '.cursor/STATE.md';
89
- case 'pi': return '.pi/STATE.md';
90
- default: return '.claude/STATE.md';
91
- }
92
- }
93
-
94
- function orderedStateMdCandidates() {
95
- const preferred = preferredStateMdCandidate();
96
- return [
97
- preferred,
98
- ...STATE_MD_CANDIDATES.filter((candidate) => candidate !== preferred),
99
- ];
77
+ /**
78
+ * Resolve a state artifact independently of other files in the state directory.
79
+ * Check SO_STATE_DIR, the detected active harness, then legacy harness directories.
80
+ * If none exist, return the preferred path for create-on-first-write callers.
81
+ * In particular, an existing legacy STATE.md must not redirect a native scope.
82
+ *
83
+ * @param {string|undefined} repoRoot
84
+ * @param {string} filename - Artifact filename, such as STATE.md or wave-scope.json.
85
+ * @returns {string} Absolute artifact path.
86
+ */
87
+ export function resolveStateArtifactPath(repoRoot, filename) {
88
+ const root = repoRoot ?? process.cwd();
89
+ const override = (process.env.SO_STATE_DIR ?? '').trim();
90
+ const active = resolveStateDir();
91
+ const directories = [...new Set([override || active, active, ...STATE_DIR_CANDIDATES])];
92
+ const candidates = directories.map((directory) => resolvePath(root, directory, filename));
93
+ return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
100
94
  }
101
95
 
102
96
  /**
@@ -110,13 +104,7 @@ function orderedStateMdCandidates() {
110
104
  * @returns {string} Absolute path to STATE.md.
111
105
  */
112
106
  export function resolveStateMdPath(repoRoot) {
113
- const root = repoRoot ?? process.cwd();
114
- const candidates = orderedStateMdCandidates();
115
- for (const candidate of candidates) {
116
- const abs = resolvePath(join(root, candidate));
117
- if (existsSync(abs)) return abs;
118
- }
119
- return resolvePath(join(root, candidates[0]));
107
+ return resolveStateArtifactPath(repoRoot, 'STATE.md');
120
108
  }
121
109
 
122
110
  // ---------------------------------------------------------------------------
@@ -23,6 +23,7 @@ export { parseStateMd, serializeStateMd } from './state-md/yaml-parser.mjs';
23
23
  export {
24
24
  touchUpdatedField,
25
25
  updateFrontmatterFields,
26
+ resolveStateArtifactPath,
26
27
  resolveStateMdPath,
27
28
  writeStateMd,
28
29
  updateFrontmatterFieldsOnDisk,
@@ -32,14 +32,28 @@
32
32
  * Optional:
33
33
  * agent_type string | null — e.g. 'explore', 'writer', 'test-writer'
34
34
  * parent_session_id string | null — session that spawned this subagent
35
- * token_input integer | null — prompt token count for this subagent
35
+ * token_input integer | null — BILLABLE PROMPT VOLUME for this subagent.
36
+ * schema_version 1: raw `usage.input_tokens` only.
37
+ * schema_version 2 (#1244, 2026-09-09): uncached + cache_read +
38
+ * cache_creation. This is a REDEFINITION — a v1 and a v2 value
39
+ * are not comparable and must never be summed together.
40
+ * token_input_uncached integer | null — raw `usage.input_tokens` (v2+)
41
+ * token_cache_read integer | null — `usage.cache_read_input_tokens` (v2+)
42
+ * token_cache_creation integer | null — `usage.cache_creation_input_tokens` (v2+)
36
43
  * token_output integer | null — completion token count for this subagent
44
+ * model string | null — model id from the transcript (v2+); null when
45
+ * the transcript exposes none. Required to price
46
+ * the record — see scripts/lib/telemetry/pricing.mjs.
37
47
  * total_cost_usd number | null — native total cost in USD (#624, fractional,
38
48
  * best-effort: null when the harness does not
39
49
  * expose it; no rate table is applied)
40
50
  *
41
51
  * OTel aliases (optional, stop-only, additive — #411, schema_version=1 backwards-compat):
42
- * gen_ai.usage.input_tokens integer | null — alias of token_input
52
+ * gen_ai.usage.input_tokens integer | null — RAW UNCACHED prompt tokens (OTel
53
+ * semantic). Under schema_version 2
54
+ * this is deliberately ≠ token_input.
55
+ * gen_ai.usage.cache_read_input_tokens integer | null (v2+)
56
+ * gen_ai.usage.cache_creation_input_tokens integer | null (v2+)
43
57
  * gen_ai.usage.output_tokens integer | null — alias of token_output
44
58
  * gen_ai.system 'anthropic' — AI provider identifier
45
59
  */
@@ -52,8 +66,25 @@ import path from 'node:path';
52
66
  // Constants
53
67
  // ---------------------------------------------------------------------------
54
68
 
55
- /** Current subagent-record schema version. */
56
- export const CURRENT_SCHEMA_VERSION = 1;
69
+ /** Current subagent-record schema version (2 since #1244, 2026-09-09). */
70
+ export const CURRENT_SCHEMA_VERSION = 2;
71
+
72
+ /**
73
+ * Schema versions this module reads. v1 records stay valid forever — the ledger
74
+ * is append-only — but their `token_input` means something different (see the
75
+ * module header), which is why consumers gate on the version rather than trust
76
+ * the field name.
77
+ */
78
+ export const SUPPORTED_SCHEMA_VERSIONS = Object.freeze([1, 2]);
79
+
80
+ /**
81
+ * Version assumed for a record read from disk that carries none. It is 1, NOT
82
+ * `CURRENT_SCHEMA_VERSION`: a versionless record predates the field, so
83
+ * stamping it with the current version would relabel legacy token semantics as
84
+ * v2 and let the rollup sum a raw-input-only figure into a billable-volume
85
+ * total — exactly the silent mixing #1244 exists to prevent.
86
+ */
87
+ export const LEGACY_SCHEMA_VERSION = 1;
57
88
 
58
89
  /** Allowed event values. */
59
90
  export const VALID_EVENTS = Object.freeze(['start', 'stop']);
@@ -102,10 +133,10 @@ export function validateSubagent(entry, options = {}) {
102
133
  throw new ValidationError('subagent record must be a non-null object');
103
134
  }
104
135
 
105
- // schema_version
106
- if (entry.schema_version !== CURRENT_SCHEMA_VERSION) {
136
+ // schema_version — v1 and v2 both validate (append-only ledger, #1244).
137
+ if (!SUPPORTED_SCHEMA_VERSIONS.includes(entry.schema_version)) {
107
138
  throw new ValidationError(
108
- `schema_version must be ${CURRENT_SCHEMA_VERSION}, got: ${entry.schema_version}`,
139
+ `schema_version must be one of ${SUPPORTED_SCHEMA_VERSIONS.join('|')}, got: ${entry.schema_version}`,
109
140
  'schema_version',
110
141
  );
111
142
  }
@@ -185,6 +216,23 @@ export function validateSubagent(entry, options = {}) {
185
216
  }
186
217
  }
187
218
 
219
+ // Cache-bucket fields (optional, #1244 / schema_version 2). Same
220
+ // non-negative-integer-or-null contract as token_input above.
221
+ for (const field of ['token_input_uncached', 'token_cache_read', 'token_cache_creation']) {
222
+ const value = entry[field];
223
+ if (value !== undefined && value !== null) {
224
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
225
+ throw new ValidationError(`${field} must be a non-negative integer or null`, field);
226
+ }
227
+ }
228
+ }
229
+
230
+ // model (optional, #1244) — the transcript's model id; null is an honest
231
+ // absence and makes the record unpriceable, never free.
232
+ if (entry.model !== undefined && entry.model !== null && typeof entry.model !== 'string') {
233
+ throw new ValidationError('model must be a string or null', 'model');
234
+ }
235
+
188
236
  // total_cost_usd (optional, #624) — fractional number (not integer), best-effort.
189
237
  if (entry.total_cost_usd !== undefined && entry.total_cost_usd !== null) {
190
238
  if (typeof entry.total_cost_usd !== 'number' || !Number.isFinite(entry.total_cost_usd) || entry.total_cost_usd < 0) {
@@ -208,6 +256,19 @@ export function validateSubagent(entry, options = {}) {
208
256
  }
209
257
  }
210
258
 
259
+ // OTel cache aliases — #1244 additive (schema_version 2).
260
+ for (const field of [
261
+ 'gen_ai.usage.cache_read_input_tokens',
262
+ 'gen_ai.usage.cache_creation_input_tokens',
263
+ ]) {
264
+ const value = entry[field];
265
+ if (value !== undefined && value !== null) {
266
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
267
+ throw new ValidationError(`${field} must be a non-negative integer or null`, field);
268
+ }
269
+ }
270
+ }
271
+
211
272
  // OTel alias — #411 additive, schema_version=1 backwards-compat
212
273
  if (entry['gen_ai.system'] !== undefined && entry['gen_ai.system'] !== null) {
213
274
  if (typeof entry['gen_ai.system'] !== 'string') {
@@ -234,15 +295,22 @@ export function normalizeSubagent(entry) {
234
295
  if (!entry || typeof entry !== 'object') return entry;
235
296
  return {
236
297
  ...entry,
237
- schema_version: entry.schema_version ?? CURRENT_SCHEMA_VERSION,
298
+ schema_version: entry.schema_version ?? LEGACY_SCHEMA_VERSION,
238
299
  agent_type: entry.agent_type ?? null,
239
300
  parent_session_id: entry.parent_session_id ?? null,
240
301
  token_input: entry.token_input ?? null,
241
302
  token_output: entry.token_output ?? null,
303
+ // #1244 additive (schema_version 2) — cache buckets + model id.
304
+ token_input_uncached: entry.token_input_uncached ?? null,
305
+ token_cache_read: entry.token_cache_read ?? null,
306
+ token_cache_creation: entry.token_cache_creation ?? null,
307
+ model: entry.model ?? null,
242
308
  // total_cost_usd — #624 additive, best-effort native cost (null when absent)
243
309
  total_cost_usd: entry.total_cost_usd ?? null,
244
310
  // OTel alias — #411 additive, schema_version=1 backwards-compat
245
311
  'gen_ai.usage.input_tokens': entry['gen_ai.usage.input_tokens'] ?? null,
312
+ 'gen_ai.usage.cache_read_input_tokens': entry['gen_ai.usage.cache_read_input_tokens'] ?? null,
313
+ 'gen_ai.usage.cache_creation_input_tokens': entry['gen_ai.usage.cache_creation_input_tokens'] ?? null,
246
314
  'gen_ai.usage.output_tokens': entry['gen_ai.usage.output_tokens'] ?? null,
247
315
  'gen_ai.system': entry['gen_ai.system'] ?? null,
248
316
  };
@@ -266,7 +334,7 @@ export function migrateLegacySubagent(entry) {
266
334
  if (!entry || typeof entry !== 'object') return entry;
267
335
  const out = { ...entry };
268
336
  if (out.schema_version === undefined || out.schema_version === null) {
269
- out.schema_version = CURRENT_SCHEMA_VERSION;
337
+ out.schema_version = LEGACY_SCHEMA_VERSION;
270
338
  }
271
339
  return out;
272
340
  }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * pricing.mjs — USD price table for token-bucket cost estimation (#1244).
3
+ *
4
+ * The subagent ledger records four token buckets per stop record
5
+ * (`token_input_uncached`, `token_cache_read`, `token_cache_creation`,
6
+ * `token_output`). Each bucket is billed at its OWN rate, so a cost estimate
7
+ * that multiplies one blended rate by a single token total is wrong by up to
8
+ * an order of magnitude on a cache-heavy run — which is the whole reason this
9
+ * table exists rather than a single `$/token` constant.
10
+ *
11
+ * ## What is measured and what is derived
12
+ *
13
+ * `input` and `output` are the first-party Anthropic API list prices as
14
+ * published in the bundled model table, sourced on the date `PRICING_TABLE_DATE`
15
+ * carries (2026-09-09) — that constant is the SSOT, never a second date repeated
16
+ * in this prose. `cache_read` and `cache_creation` are DERIVED from the
17
+ * documented ephemeral-cache multipliers (read ≈ 0.1×, 5-minute write ≈ 1.25×
18
+ * the input rate) except where a row carries a documented figure — Claude Fable
19
+ * 5.1 publishes $0.25/MTok cache reads directly, which is NOT 0.1× its $10
20
+ * input rate. Every row therefore declares its own `cache_source` so a reader
21
+ * never has to guess which numbers were read off a price list and which were
22
+ * multiplied out here.
23
+ *
24
+ * A row with `verified: false` carries a PLACEHOLDER: its numbers are a
25
+ * best-effort stand-in, never a quoted price. `costUsd()` still prices such a
26
+ * row (an estimate is more useful than a null when it is labelled), but any
27
+ * consumer publishing a dollar figure must surface the flag.
28
+ *
29
+ * ## Null means UNKNOWN MODEL, never zero
30
+ *
31
+ * `priceFor()` and `costUsd()` return `null` for a model this table does not
32
+ * know. `null` is an honest absence — the same discipline the token rollup
33
+ * applies to `total_token_input`. Coercing it to `0` fabricates a free run and
34
+ * is the single most damaging misreading of this module.
35
+ *
36
+ * All rates are USD per MILLION tokens.
37
+ *
38
+ * @module telemetry/pricing
39
+ */
40
+
41
+ /**
42
+ * The date the `input`/`output` rates in PRICING_TABLE were sourced.
43
+ * Any consumer quoting a dollar figure should quote this date beside it — a
44
+ * price without its measurement date ages silently.
45
+ * @type {string}
46
+ */
47
+ export const PRICING_TABLE_DATE = '2026-09-09';
48
+
49
+ /**
50
+ * @typedef {Object} PricingRow
51
+ * @property {number} input - USD per 1M uncached prompt tokens.
52
+ * @property {number} cache_read - USD per 1M tokens served from the prompt cache.
53
+ * @property {number} cache_creation - USD per 1M tokens written to the prompt cache.
54
+ * @property {number} output - USD per 1M completion tokens.
55
+ * @property {boolean} verified - false ⇒ the row is a labelled placeholder, not a quoted price.
56
+ * @property {'documented'|'derived-multiplier'} cache_source - provenance of the two cache rates.
57
+ */
58
+
59
+ /**
60
+ * Per-model USD-per-million-token rates.
61
+ * @type {Readonly<Record<string, PricingRow>>}
62
+ */
63
+ export const PRICING_TABLE = Object.freeze({
64
+ // Claude Fable 5.1 — $10 / $50 list; cache reads documented at $0.25/MTok
65
+ // (NOT the usual 0.1× multiplier), cache writes derived at 1.25× input.
66
+ 'claude-fable-5-1': Object.freeze({
67
+ input: 10.0,
68
+ cache_read: 0.25,
69
+ cache_creation: 12.5,
70
+ output: 50.0,
71
+ verified: true,
72
+ cache_source: 'documented',
73
+ }),
74
+ 'claude-opus-5': Object.freeze({
75
+ input: 5.0,
76
+ cache_read: 0.5,
77
+ cache_creation: 6.25,
78
+ output: 25.0,
79
+ verified: true,
80
+ cache_source: 'derived-multiplier',
81
+ }),
82
+ 'claude-sonnet-5': Object.freeze({
83
+ input: 2.0,
84
+ cache_read: 0.2,
85
+ cache_creation: 2.5,
86
+ output: 10.0,
87
+ verified: true,
88
+ cache_source: 'derived-multiplier',
89
+ }),
90
+ 'claude-haiku-4-5': Object.freeze({
91
+ input: 1.0,
92
+ cache_read: 0.1,
93
+ cache_creation: 1.25,
94
+ output: 5.0,
95
+ verified: true,
96
+ cache_source: 'derived-multiplier',
97
+ }),
98
+ });
99
+
100
+ /**
101
+ * Explicit aliases for model ids that appear in transcripts but are not the
102
+ * canonical table key — dated snapshots and the `[1m]` context-window suffix
103
+ * the harness stamps on long-context sessions.
104
+ * @type {Readonly<Record<string, string>>}
105
+ */
106
+ export const MODEL_ALIASES = Object.freeze({
107
+ 'claude-haiku-4-5-20251001': 'claude-haiku-4-5',
108
+ 'claude-opus-5[1m]': 'claude-opus-5',
109
+ 'claude-fable-5-1[1m]': 'claude-fable-5-1',
110
+ 'claude-sonnet-5[1m]': 'claude-sonnet-5',
111
+ });
112
+
113
+ /**
114
+ * Look up the rate row for a model id.
115
+ *
116
+ * Resolution order: exact key → explicit alias → longest matching table key
117
+ * that the id starts with (so `claude-opus-5-20260401` and `claude-opus-5[1m]`
118
+ * both resolve to `claude-opus-5`). Longest-prefix wins so a future
119
+ * `claude-opus-5-1` row is never shadowed by `claude-opus-5`.
120
+ *
121
+ * @param {string|null|undefined} modelId
122
+ * @returns {PricingRow|null} the row, or null when the model is UNKNOWN
123
+ * (never a zero-rate row — see the module header).
124
+ */
125
+ export function priceFor(modelId) {
126
+ if (typeof modelId !== 'string') return null;
127
+ const id = modelId.trim();
128
+ if (!id) return null;
129
+
130
+ if (Object.hasOwn(PRICING_TABLE, id)) return PRICING_TABLE[id];
131
+
132
+ const aliased = MODEL_ALIASES[id];
133
+ if (aliased && Object.hasOwn(PRICING_TABLE, aliased)) return PRICING_TABLE[aliased];
134
+
135
+ let best = null;
136
+ for (const key of Object.keys(PRICING_TABLE)) {
137
+ if (id.startsWith(key) && (best === null || key.length > best.length)) best = key;
138
+ }
139
+ return best === null ? null : PRICING_TABLE[best];
140
+ }
141
+
142
+ /**
143
+ * Non-negative finite number within the exactly-representable integer range,
144
+ * else 0. Absent is 0 — an absent bucket costs nothing; an unknown MODEL is what
145
+ * yields null (in costUsd, not here).
146
+ *
147
+ * The `MAX_SAFE_INTEGER` ceiling is not pedantry: a corrupt bucket of `1e308`
148
+ * is finite, so it passed the old guard and multiplied out to `Infinity`, which
149
+ * `JSON.stringify` writes as `null` — the exact encoding this module reserves
150
+ * for "unknown model". A token count above 2^53 is not a measurement.
151
+ *
152
+ * @param {unknown} n
153
+ * @returns {number}
154
+ */
155
+ function num(n) {
156
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= Number.MAX_SAFE_INTEGER
157
+ ? n
158
+ : 0;
159
+ }
160
+
161
+ /**
162
+ * Estimate the USD cost of one record's four token buckets.
163
+ *
164
+ * Each bucket is multiplied by ITS OWN rate — a cache read is ~10× cheaper
165
+ * than an uncached prompt token and a cache write ~1.25× more expensive, so a
166
+ * blended rate is not an approximation of this, it is a different number.
167
+ *
168
+ * @param {object} opts
169
+ * @param {string|null|undefined} opts.model - model id from the transcript
170
+ * @param {number|null|undefined} opts.tokenInputUncached
171
+ * @param {number|null|undefined} opts.tokenCacheRead
172
+ * @param {number|null|undefined} opts.tokenCacheCreation
173
+ * @param {number|null|undefined} opts.tokenOutput
174
+ * @returns {number|null} USD cost, or `null` when the model is UNKNOWN.
175
+ * **null means "unknown model", never 0.** A caller that coerces it to 0
176
+ * reports a free run that was not free.
177
+ */
178
+ export function costUsd({
179
+ model,
180
+ tokenInputUncached,
181
+ tokenCacheRead,
182
+ tokenCacheCreation,
183
+ tokenOutput,
184
+ } = {}) {
185
+ const row = priceFor(model);
186
+ if (row === null) return null;
187
+ const perToken = 1e-6;
188
+ const cost =
189
+ num(tokenInputUncached) * row.input * perToken +
190
+ num(tokenCacheRead) * row.cache_read * perToken +
191
+ num(tokenCacheCreation) * row.cache_creation * perToken +
192
+ num(tokenOutput) * row.output * perToken;
193
+ // Belt and braces beside `num()`'s ceiling: a non-finite total would serialise
194
+ // as JSON `null` and be indistinguishable from "unknown model". Returning null
195
+ // deliberately makes that reading TRUE rather than accidental.
196
+ return Number.isFinite(cost) ? cost : null;
197
+ }
@@ -348,11 +348,46 @@ export function deriveSessionFromEvents(metricsDir) {
348
348
  let startedAt = null;
349
349
  let sessionType = null;
350
350
  let lastTs = null;
351
+ // `orchestrator.session.shape_resolved` (scripts/lib/session-shape.mjs) is
352
+ // the plan-time measurement: it fires AFTER the operator picked a mode, and
353
+ // it is the only event carrying `session_profile`. `session.started`'s mode
354
+ // predates the choice, and the STATE.md profile read below evaporates the
355
+ // moment STATE.md is rewritten — so when a shape record exists it wins for
356
+ // BOTH fields. Latest wins; absent ⇒ the pre-existing behaviour, unchanged.
357
+ //
358
+ // "Latest" is decidable only for a record carrying a parseable timestamp. An
359
+ // UNDATED record has no place in that order and must never displace a dated
360
+ // one (the earlier `ts === null` disjunct inverted exactly that). It is kept
361
+ // separately and used only when no dated shape record exists AND the
362
+ // pre-existing sources below yielded nothing.
363
+ let shapeType = null;
364
+ let shapeProfile = null;
365
+ let shapeTs = null;
366
+ let undatedShapeType = null;
367
+ let undatedShapeProfile = null;
351
368
 
352
369
  for (const ev of events) {
353
370
  if (!ev || typeof ev !== 'object') continue;
354
371
  const ts = typeof ev.timestamp === 'string' && !Number.isNaN(Date.parse(ev.timestamp)) ? ev.timestamp : null;
355
372
  if (ts && (lastTs === null || ts > lastTs)) lastTs = ts;
373
+ if (ev.event === 'orchestrator.session.shape_resolved') {
374
+ const type = typeof ev.session_type === 'string' && ev.session_type.trim() !== '' ? ev.session_type.trim() : null;
375
+ // OMITTED, never null, when the session has no profile — only a
376
+ // present string may overwrite an earlier reading.
377
+ const profile =
378
+ typeof ev.session_profile === 'string' && ev.session_profile.trim() !== '' ? ev.session_profile.trim() : null;
379
+ if (ts !== null) {
380
+ if (shapeTs === null || ts >= shapeTs) {
381
+ shapeTs = ts;
382
+ if (type !== null) shapeType = type;
383
+ if (profile !== null) shapeProfile = profile;
384
+ }
385
+ } else if (undatedShapeType === null && undatedShapeProfile === null) {
386
+ undatedShapeType = type;
387
+ undatedShapeProfile = profile;
388
+ }
389
+ continue;
390
+ }
356
391
  if (ev.event !== 'orchestrator.session.started') continue;
357
392
  // `mode` is what session-start writes; `session_type` is the ledger's own
358
393
  // name for the same fact. Read both — neither is guaranteed present.
@@ -364,10 +399,24 @@ export function deriveSessionFromEvents(metricsDir) {
364
399
  }
365
400
  }
366
401
 
367
- if (startedAt === null && sessionType === null) return { session: {}, source: 'absent' };
402
+ if (shapeType !== null) sessionType = shapeType;
403
+ // An undated shape record is the weakest reading there is: it wins over
404
+ // nothing at all, and over nothing else. A dated shape record (shapeTs) or
405
+ // a `session.started` mode both outrank it.
406
+ // Type and profile are read as a PAIR from the same record, so the undated
407
+ // fallback is taken as a pair too — never spliced onto a type another source
408
+ // supplied.
409
+ let profile = shapeProfile;
410
+ if (shapeTs === null && sessionType === null) {
411
+ sessionType = undatedShapeType;
412
+ profile = undatedShapeProfile;
413
+ }
414
+
415
+ if (startedAt === null && sessionType === null && profile === null) return { session: {}, source: 'absent' };
368
416
 
369
417
  const session = {};
370
418
  if (sessionType !== null) session.session_type = sessionType;
419
+ if (profile !== null) session.session_profile = profile;
371
420
  if (startedAt !== null) session.started_at = startedAt;
372
421
  // completed_at is the last life-sign, never the wall clock — the same
373
422
  // omit-never-fabricate contract session-close-backfill.mjs uses (#914 R1).