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
@@ -17,10 +17,12 @@
17
17
  * Decision flow:
18
18
  * G1 tool filter — only Bash is gated.
19
19
  * G2 command is a non-empty string.
20
- * G3 matcher — `gh|glab … issue create|new` only. PR/MR creation passes.
20
+ * G3 matcher — `gh|glab … issue create|new` plus the REST route
21
+ * (`gh|glab api … /issues`, #1163) only. PR/MR creation passes.
21
22
  * Verb-resolved since #1145, so a wrapped (`nohup`), absolute-path or
22
23
  * env-prefixed create is seen; a `--help` invocation is not (it creates
23
- * nothing).
24
+ * nothing). Since #1163 the matcher returns EVERY create statement of the
25
+ * chain, and the cap charges ONE unit per statement.
24
26
  * G4 config — `issue-budget` from CLAUDE.md/AGENTS.md. `mode: off` → allow.
25
27
  * G3b bulk — a create inside a shell LOOP body creates an unknowable number
26
28
  * of issues (#1145). `strict` → deny; `warn` → allow with an explicit
@@ -29,7 +31,8 @@
29
31
  * G5 exemption — priority::critical / carryover class / broken-window /
30
32
  * the overflow collector itself bypass the cap unconditionally, keeping
31
33
  * the session-end promises at SKILL.md:319 and :1113 intact.
32
- * G6 charge the counter in .orchestrator/runtime/issue-budget/<hash>.json
34
+ * G6 charge the counter ONCE PER ISSUE-CREATE STATEMENT in
35
+ * .orchestrator/runtime/issue-budget/<hash>.json
33
36
  * (one file per session since #1141 — see scripts/lib/issue-budget.mjs
34
37
  * `budgetStateRel`).
35
38
  * under cap → allow; over cap + `warn` → allow with stderr notice;
@@ -50,13 +53,16 @@
50
53
  import { readStdin, emitAllow, emitDeny, emitWarn } from '../scripts/lib/io.mjs';
51
54
  import { resolveProjectDir } from '../scripts/lib/platform.mjs';
52
55
  import { readJson } from '../scripts/lib/common.mjs';
53
- import { isIssueCreate, isLoopedIssueCreate, extractTitle } from './_lib/vcs-create-matcher.mjs';
56
+ import { findIssueCreateStatements, isLoopedIssueCreate } from './_lib/vcs-create-matcher.mjs';
54
57
  import {
55
58
  loadIssueBudgetConfig,
56
59
  resolveIssueBudgetSessionId,
57
60
  chargeIssueBudget,
58
61
  classifyExemption,
59
62
  formatBlockReason,
63
+ readBudgetState,
64
+ writeBudgetState,
65
+ budgetStatePath,
60
66
  } from '../scripts/lib/issue-budget.mjs';
61
67
 
62
68
  import { shouldRunHook } from './_lib/profile-gate.mjs';
@@ -145,7 +151,13 @@ async function resolveSessionId(input, projectDir) {
145
151
  *
146
152
  * NAMED CEILING (BV-004): a loop is detected by `do`/`done` in command position
147
153
  * (see `isLoopedIssueCreate`), so an UNROLLED bulk create — 50 create statements
148
- * chained with `&&` — is not a "loop" and is charged 50, correctly. Revisit this
154
+ * chained with `&&` — is not a "loop". Since #1163 it is charged 50, once per
155
+ * issue-create STATEMENT: until then this comment CLAIMED that behaviour while
156
+ * the code called `chargeIssueBudget` exactly once per Bash tool call with the
157
+ * whole command string, so `glab issue create --title A && glab issue create
158
+ * --title B` charged 1 for 2 (measured 2026-09-09). The claim is now true
159
+ * because `findIssueCreateStatements` supplies the per-statement units and the
160
+ * exemption is classified per statement too. Revisit this
149
161
  * choice if the overflow triage of a per-session counter file
150
162
  * (`.orchestrator/runtime/issue-budget/<hash>.json`) shows operators routinely
151
163
  * hitting this deny on loops over a KNOWN literal word list; the cheap answer
@@ -176,6 +188,59 @@ function formatLoopDenyReason(config) {
176
188
  ].join('\n');
177
189
  }
178
190
 
191
+ /**
192
+ * Park every chargeable statement of a command that does NOT fit under the cap,
193
+ * and return a `formatBlockReason`-shaped verdict for the deny envelope.
194
+ *
195
+ * ## Why this is not `chargeIssueBudget`
196
+ *
197
+ * `chargeIssueBudget` decides ONE creation against the current count, and its
198
+ * strict branch parks only when the count is ALREADY at the cap. A chain of
199
+ * statements that straddles the cap (count 11, max 12, three creates) has no
200
+ * single call shape in that API: the first statement would be ALLOWED and
201
+ * counted, and the deny that follows would leave that count standing for an
202
+ * issue nobody created. So the fit is judged for the chain as a whole and the
203
+ * whole chain is parked — count and exempt untouched, because nothing ran.
204
+ *
205
+ * The exemption CLASSIFICATION still comes from the shared core
206
+ * (`classifyExemption`, applied by the caller); what is local here is only the
207
+ * bookkeeping write, through the module's own public `writeBudgetState`.
208
+ *
209
+ * An identity-less invocation (no session key) must not write at all — the
210
+ * legacy flat path is shared across sessions and writing it would reset a live
211
+ * session's count and drop its parked overflow. Same rule `chargeIssueBudget`'s
212
+ * `persist` applies; the deny still happens, only unrecorded.
213
+ *
214
+ * @param {{ projectDir: string, sessionId: string|null,
215
+ * state: { count: number, exempt: number, overflow: object[], sessionId: string|null },
216
+ * chargeable: Array<{ text: string, title: string|null }>,
217
+ * config: { "max-per-session": number, mode: string, overflow: string },
218
+ * now?: string }} opts
219
+ * @returns {{ count: number, max: number, overflowPath: string,
220
+ * overflowSink: string, overflowCount: number }}
221
+ */
222
+ function parkOverflow({
223
+ projectDir,
224
+ sessionId,
225
+ state,
226
+ chargeable,
227
+ config,
228
+ now = new Date().toISOString(),
229
+ }) {
230
+ state.sessionId = sessionId;
231
+ for (const s of chargeable) {
232
+ state.overflow.push({ title: s.title ?? null, command: String(s.text).slice(0, 500), at: now });
233
+ }
234
+ if (sessionId !== null) writeBudgetState(projectDir, state);
235
+ return {
236
+ count: state.count,
237
+ max: config['max-per-session'],
238
+ overflowPath: budgetStatePath(projectDir, sessionId),
239
+ overflowSink: config.overflow,
240
+ overflowCount: state.overflow.length,
241
+ };
242
+ }
243
+
179
244
  // ---------------------------------------------------------------------------
180
245
  // Main
181
246
  // ---------------------------------------------------------------------------
@@ -191,8 +256,11 @@ async function main() {
191
256
  const command = input?.tool_input?.command;
192
257
  if (typeof command !== 'string' || command.length === 0) return emitAllow();
193
258
 
194
- // G3 — shared matcher. Only ISSUE creation is capped; `pr`/`mr` create pass.
195
- if (!isIssueCreate(command)) return emitAllow();
259
+ // G3 — shared matcher, PER STATEMENT (#1163). Only ISSUE creation is capped;
260
+ // `pr`/`mr` create pass. An empty list is the old `!isIssueCreate(command)`
261
+ // short-circuit, unchanged.
262
+ const statements = findIssueCreateStatements(command);
263
+ if (statements.length === 0) return emitAllow();
196
264
 
197
265
  const projectDir = resolveProjectDir() || process.cwd();
198
266
 
@@ -202,9 +270,12 @@ async function main() {
202
270
 
203
271
  // G3b — bulk creation whose multiplicity is not computable (#1145). The
204
272
  // exemption is asked FIRST, through the same classifier chargeIssueBudget
205
- // uses, so a looped carryover sweep keeps its unconditional pass.
273
+ // uses, so a looped carryover sweep keeps its unconditional pass. It is asked
274
+ // on the FIRST issue-create statement's text, which is the very statement
275
+ // `isLoopedIssueCreate` judges — classifying it on the whole command would
276
+ // let an exempt NEIGHBOUR statement lift the loop deny.
206
277
  const uncountableBulk =
207
- isLoopedIssueCreate(command) && !classifyExemption(command).exempt;
278
+ isLoopedIssueCreate(command) && !classifyExemption(statements[0].text).exempt;
208
279
  if (uncountableBulk && config.mode === 'strict') {
209
280
  // Nothing is charged and nothing is parked — the command is handed back
210
281
  // whole, which is what makes unrolling it the correct next action.
@@ -213,26 +284,50 @@ async function main() {
213
284
 
214
285
  const sessionId = await resolveSessionId(input, projectDir);
215
286
 
216
- // G5 + G6 exemption check and counter charge live in the shared core so
217
- // the programmatic path (scripts/lib/spiral-carryover.mjs runCli) decides
218
- // identically.
219
- const verdict = chargeIssueBudget({
220
- repoRoot: projectDir,
221
- sessionId,
222
- command,
223
- title: extractTitle(command),
224
- config,
225
- });
287
+ // G5 pre-flighta Bash call is ATOMIC from this hook's point of view: a
288
+ // deny refuses the WHOLE command, so not one of its statements runs. Charging
289
+ // statement-by-statement until one blocks would therefore count creations
290
+ // that never happened (and double-count them when the operator re-issues the
291
+ // command unrolled). So the fit is decided BEFORE any charge, and a command
292
+ // that does not fit parks every chargeable statement without counting any.
293
+ const chargeable = statements.filter((s) => !classifyExemption(s.text).exempt);
294
+ if (config.mode === 'strict' && chargeable.length > 0) {
295
+ const state = readBudgetState(projectDir, sessionId);
296
+ if (state.count + chargeable.length > config['max-per-session']) {
297
+ return emitDeny(formatBlockReason(parkOverflow({
298
+ projectDir, sessionId, state, chargeable, config,
299
+ })));
300
+ }
301
+ }
302
+
303
+ // G6 — charge ONE unit per issue-create STATEMENT. The decision itself stays
304
+ // in the shared core (scripts/lib/issue-budget.mjs), so the programmatic path
305
+ // (scripts/lib/spiral-carryover.mjs runCli) decides identically; what changed
306
+ // in #1163 is only HOW MANY times it is asked. Each statement is judged on
307
+ // its OWN text: `glab issue create --title REAL && glab issue create
308
+ // --label carryover --title X` is 1 charge + 1 exemption, never 2 exemptions.
309
+ const verdicts = statements.map((s) =>
310
+ chargeIssueBudget({
311
+ repoRoot: projectDir,
312
+ sessionId,
313
+ command: s.text,
314
+ title: s.title,
315
+ config,
316
+ }),
317
+ );
318
+ const verdict = verdicts[verdicts.length - 1];
319
+ const exemptions = verdicts.filter((v) => v.decision === 'exempt');
320
+ const blocked = verdicts.find((v) => v.decision === 'block');
226
321
 
227
- if (verdict.decision === 'exempt') {
322
+ if (exemptions.length > 0) {
323
+ const reasons = [...new Set(exemptions.map((v) => v.reason))].join(', ');
228
324
  process.stderr.write(
229
- `ℹ pre-bash-issue-budget: exempt (${verdict.reason}) — cap not charged ` +
230
- `(${verdict.count}/${verdict.max})\n`,
325
+ `ℹ pre-bash-issue-budget: ${exemptions.length} exempt statement(s) (${reasons}) — ` +
326
+ `cap not charged (${verdict.count}/${verdict.max})\n`,
231
327
  );
232
- return emitAllow();
233
328
  }
234
329
 
235
- if (verdict.decision === 'warn') {
330
+ if (verdicts.some((v) => v.decision === 'warn')) {
236
331
  process.stderr.write(
237
332
  `⚠ pre-bash-issue-budget: session cap exceeded — ${verdict.count}/${verdict.max} ` +
238
333
  `issues created (mode: warn — allowing). Set \`issue-budget.mode: strict\` to enforce.\n`,
@@ -240,7 +335,9 @@ async function main() {
240
335
  return emitAllow();
241
336
  }
242
337
 
243
- if (verdict.decision === 'block') {
338
+ if (exemptions.length === statements.length) return emitAllow();
339
+
340
+ if (blocked) {
244
341
  // Single channel (#906). formatBlockReason's multi-line text — overflow
245
342
  // store path, the [Backlog-Sammel] fold-in promise, the exemption list and
246
343
  // the cap-raising hint — used to go to stderr AND to a duplicated `exit 2`
@@ -249,7 +346,7 @@ async function main() {
249
346
  // to Claude (the actor that must re-file or defer the issue), while the
250
347
  // operator gets the first line as the systemMessage headline. Under exit 0
251
348
  // a stderr write would only reach the debug log — dead, but alive-looking.
252
- emitDeny(formatBlockReason(verdict));
349
+ return emitDeny(formatBlockReason(blocked));
253
350
  }
254
351
 
255
352
  // A PERMITTED bulk create is charged ONCE, which is an undercount by
@@ -64,6 +64,33 @@
64
64
  * `subagent_transcript_found: true` as token-bearing; summing across the
65
65
  * history double-counts the parent once per subagent.
66
66
  *
67
+ * TOKEN-DATA PROVENANCE — SERIES BREAK 2026-09-09 (schema_version 2, #1244).
68
+ * Until this fix `token_input` was the raw `usage.input_tokens` sum only, and
69
+ * `cache_read_input_tokens` / `cache_creation_input_tokens` were read nowhere in
70
+ * the repo. Under prompt caching virtually the whole prompt is cache traffic, so
71
+ * that number described a rounding error rather than the run: measured
72
+ * 2026-09-09 on agent af50d1eda37099e17, the ledger recorded token_input 56
73
+ * where its transcript holds 56 + 3,528,536 cache_read + 147,587 cache_creation
74
+ * = 3,676,179 — an understatement of 65,646×; session-wide 1,518 vs 85,271,214.
75
+ * (token_output was within ~11% and is unaffected.)
76
+ *
77
+ * From schema_version 2 onward:
78
+ * token_input = uncached + cache_read + cache_creation
79
+ * (BILLABLE PROMPT VOLUME — a redefinition, not a
80
+ * widening: v1 and v2 values are not comparable)
81
+ * token_input_uncached = raw usage.input_tokens (additive)
82
+ * token_cache_read = usage.cache_read_input_tokens (additive)
83
+ * token_cache_creation = usage.cache_creation_input_tokens (additive)
84
+ * model = message.model, or null (additive; enables pricing)
85
+ * gen_ai.usage.input_tokens stays the RAW UNCACHED value (OTel semantic), so
86
+ * it is deliberately ≠ token_input under v2. Two new OTel aliases carry the
87
+ * cache buckets.
88
+ *
89
+ * FORWARD-ONLY. Nothing recomputes the v1 history, so consumers MUST gate any
90
+ * v2 sum on `schema_version >= 2` — `scripts/lib/session-token-rollup.mjs` does
91
+ * exactly that and reports the excluded v1 records as `legacy_v1_records`
92
+ * rather than silently folding them in.
93
+ *
67
94
  * A SECOND, independent defect rode along until #950: the requestId dedup kept
68
95
  * the FIRST usage block per id, which on a streaming transcript is a partial
69
96
  * snapshot (typically `output_tokens: 1`). Any record written before #950 —
@@ -391,13 +418,27 @@ function readStdinJson() {
391
418
  * so the present side still sums and the aggregate stays valid.
392
419
  *
393
420
  * NEVER throws. Any failure (missing/unreadable path, 0 assistant turns, parse
394
- * error) yields { tokenInput: null, tokenOutput: null } so the hook still exits 0.
421
+ * error) yields an all-null result so the hook still exits 0.
422
+ *
423
+ * Four buckets, not two (#1244 / schema_version 2): `input_tokens`,
424
+ * `cache_read_input_tokens`, `cache_creation_input_tokens` and `output_tokens`
425
+ * are accumulated SEPARATELY per deduped turn, because they are billed at three
426
+ * different rates. `model` is captured from `message.model` of the last kept
427
+ * block that carries one (null when absent) — it is what makes a cost estimate
428
+ * possible at all downstream.
395
429
  *
396
430
  * @param {string|undefined|null} transcriptPath — absolute path from stdin
397
- * @returns {{ tokenInput: number|null, tokenOutput: number|null }}
431
+ * @returns {{ tokenInputUncached: number|null, tokenCacheRead: number|null,
432
+ * tokenCacheCreation: number|null, tokenOutput: number|null, model: string|null }}
398
433
  */
399
434
  function extractTranscriptUsage(transcriptPath) {
400
- const nullResult = { tokenInput: null, tokenOutput: null };
435
+ const nullResult = {
436
+ tokenInputUncached: null,
437
+ tokenCacheRead: null,
438
+ tokenCacheCreation: null,
439
+ tokenOutput: null,
440
+ model: null,
441
+ };
401
442
  try {
402
443
  if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) return nullResult;
403
444
  if (!fs.existsSync(transcriptPath)) return nullResult;
@@ -447,9 +488,15 @@ function extractTranscriptUsage(transcriptPath) {
447
488
  // Dedup by requestId — keep the LAST usage block per id (#950). The
448
489
  // repeats are cumulative streaming snapshots, so overwriting is what
449
490
  // promotes the partial first snapshot to the response's real total.
491
+ // Model id rides along with the usage block (#1244): it is per-turn data
492
+ // and the only thing that makes the record priceable downstream.
493
+ const turnModel = typeof obj.message?.model === 'string' && obj.message.model
494
+ ? obj.message.model
495
+ : null;
496
+
450
497
  const requestId = obj.requestId;
451
498
  if (typeof requestId === 'string' && requestId) {
452
- byRequestId.set(requestId, usage);
499
+ byRequestId.set(requestId, { usage, model: turnModel });
453
500
  continue;
454
501
  }
455
502
 
@@ -461,9 +508,9 @@ function extractTranscriptUsage(transcriptPath) {
461
508
  // usable identity; anything else falls through to the individual count.
462
509
  const messageId = obj.message?.id;
463
510
  if (typeof messageId === 'string' && messageId) {
464
- byMessageId.set(messageId, usage);
511
+ byMessageId.set(messageId, { usage, model: turnModel });
465
512
  } else {
466
- unkeyable.push(usage);
513
+ unkeyable.push({ usage, model: turnModel });
467
514
  }
468
515
  }
469
516
 
@@ -472,23 +519,34 @@ function extractTranscriptUsage(transcriptPath) {
472
519
  // No assistant turns with usage → leave fields null (forward-compat).
473
520
  if (kept.length === 0) return nullResult;
474
521
 
475
- let tokenInput = 0;
522
+ let tokenInputUncached = 0;
523
+ let tokenCacheRead = 0;
524
+ let tokenCacheCreation = 0;
476
525
  let tokenOutput = 0;
477
- for (const usage of kept) {
526
+ let model = null;
527
+ for (const { usage, model: turnModel } of kept) {
478
528
  // Per-turn clamp (#624): add a turn's value ONLY when it is a non-negative
479
529
  // integer. A poisoned value (negative, NaN, float like 10.5) is skipped so
480
530
  // the good turns survive. An absent side contributes 0, not null.
481
531
  const inTok = usage.input_tokens;
532
+ const cacheRead = usage.cache_read_input_tokens;
533
+ const cacheCreation = usage.cache_creation_input_tokens;
482
534
  const outTok = usage.output_tokens;
483
- if (Number.isInteger(inTok) && inTok >= 0) tokenInput += inTok;
535
+ if (Number.isInteger(inTok) && inTok >= 0) tokenInputUncached += inTok;
536
+ if (Number.isInteger(cacheRead) && cacheRead >= 0) tokenCacheRead += cacheRead;
537
+ if (Number.isInteger(cacheCreation) && cacheCreation >= 0) tokenCacheCreation += cacheCreation;
484
538
  if (Number.isInteger(outTok) && outTok >= 0) tokenOutput += outTok;
539
+ if (turnModel !== null) model = turnModel;
485
540
  }
486
541
 
487
- // The aggregate is guaranteed a non-negative integer by per-turn clamping
488
- // above (Σ of non-negative integers), so emit it directly.
542
+ // The aggregates are guaranteed non-negative integers by per-turn clamping
543
+ // above (Σ of non-negative integers), so emit them directly.
489
544
  return {
490
- tokenInput,
545
+ tokenInputUncached,
546
+ tokenCacheRead,
547
+ tokenCacheCreation,
491
548
  tokenOutput,
549
+ model,
492
550
  };
493
551
  } catch {
494
552
  return nullResult;
@@ -657,7 +715,7 @@ async function main() {
657
715
  timestamp: new Date().toISOString(),
658
716
  event,
659
717
  agent_id: agentId,
660
- schema_version: 1,
718
+ schema_version: 2,
661
719
  ...(agentType !== null ? { agent_type: agentType } : {}),
662
720
  ...(parentSessionId !== null ? { parent_session_id: parentSessionId } : {}),
663
721
  };
@@ -705,23 +763,51 @@ async function main() {
705
763
  // NO fallback to input.transcript_path: that path is the parent session
706
764
  // transcript, and reading it is the #949 defect (every stop inherited the
707
765
  // parent's running totals). A phantom stop gets null — the honest value.
708
- const { tokenInput, tokenOutput } = subagentTranscriptFound
709
- ? extractTranscriptUsage(subagentTranscriptPath)
710
- : { tokenInput: null, tokenOutput: null };
711
- if (tokenInput !== null) record.token_input = tokenInput;
766
+ const { tokenInputUncached, tokenCacheRead, tokenCacheCreation, tokenOutput, model } =
767
+ subagentTranscriptFound
768
+ ? extractTranscriptUsage(subagentTranscriptPath)
769
+ : {
770
+ tokenInputUncached: null,
771
+ tokenCacheRead: null,
772
+ tokenCacheCreation: null,
773
+ tokenOutput: null,
774
+ model: null,
775
+ };
776
+
777
+ // schema_version 2 (#1244): `token_input` is now BILLABLE PROMPT VOLUME —
778
+ // uncached + cache_read + cache_creation — and the three components are
779
+ // written additively beside it. See the file header § TOKEN-DATA PROVENANCE
780
+ // for the 2026-09-09 series break this creates.
781
+ if (tokenInputUncached !== null) {
782
+ record.token_input =
783
+ tokenInputUncached + (tokenCacheRead ?? 0) + (tokenCacheCreation ?? 0);
784
+ record.token_input_uncached = tokenInputUncached;
785
+ record.token_cache_read = tokenCacheRead;
786
+ record.token_cache_creation = tokenCacheCreation;
787
+ }
712
788
  if (tokenOutput !== null) record.token_output = tokenOutput;
713
789
 
790
+ // Model id (#1244) — null when the transcript exposes none. Cost is NOT
791
+ // computed here: pricing lives in scripts/lib/telemetry/pricing.mjs and is
792
+ // applied by the session rollup, so this hot-path hook keeps its import
793
+ // graph unchanged.
794
+ record.model = model;
795
+
714
796
  // Cost is best-effort / forward-compat (#624): the native transcript does
715
797
  // NOT expose total_cost_usd today, so this is null in practice. No rate
716
- // table — use the native cost only, default null when absent.
798
+ // table is applied HERE — use the native cost only, default null when absent.
717
799
  const totalCostUsd =
718
800
  typeof input.total_cost_usd === 'number' && Number.isFinite(input.total_cost_usd) && input.total_cost_usd >= 0
719
801
  ? input.total_cost_usd
720
802
  : null;
721
803
  record.total_cost_usd = totalCostUsd;
722
804
 
723
- // OTel alias — #411 additive, schema_version=1 backwards-compat
724
- record['gen_ai.usage.input_tokens'] = tokenInput;
805
+ // OTel alias — #411 additive. `gen_ai.usage.input_tokens` stays the RAW
806
+ // UNCACHED value (OTel semantic), which is why it is deliberately NOT equal
807
+ // to `token_input` under schema_version 2.
808
+ record['gen_ai.usage.input_tokens'] = tokenInputUncached;
809
+ record['gen_ai.usage.cache_read_input_tokens'] = tokenCacheRead;
810
+ record['gen_ai.usage.cache_creation_input_tokens'] = tokenCacheCreation;
725
811
  record['gen_ai.usage.output_tokens'] = tokenOutput;
726
812
  record['gen_ai.system'] = 'anthropic';
727
813
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "4.0.1",
3
+ "version": "4.2.0",
4
4
  "description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
@@ -69,7 +69,7 @@
69
69
  "dependencies": {
70
70
  "@babel/parser": "^7.26.0",
71
71
  "ajv": "^8.17.1",
72
- "js-yaml": "^4.3.1",
72
+ "js-yaml": "^4.3.2",
73
73
  "mdast-util-to-string": "^4.0.0",
74
74
  "picomatch": "^4.0.4",
75
75
  "remark": "^15.0.0",
@@ -83,12 +83,12 @@
83
83
  "@commitlint/cli": "^19.6.0",
84
84
  "@commitlint/config-conventional": "^19.6.0",
85
85
  "@eslint/js": "^10.0.1",
86
- "@vitest/coverage-v8": "^4.1.5",
86
+ "@vitest/coverage-v8": "^4.1.11",
87
87
  "eslint": "^10.2.1",
88
88
  "husky": "^9.1.7",
89
89
  "lint-staged": "^15.2.10",
90
90
  "prettier": "^3.0.0",
91
- "vitest": "^4.1.5"
91
+ "vitest": "^4.1.11"
92
92
  },
93
93
  "overrides": {
94
94
  "vite": "^8.0.16",
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /** Data-only lookup for the configured local bootstrap baseline. */
3
+ import path from 'node:path';
4
+ import { realpathSync } from 'node:fs';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { loadBaselineArchetypes } from './lib/baseline-archetypes.mjs';
7
+
8
+ export async function main(argv = process.argv.slice(2)) {
9
+ const options = {};
10
+ for (let index = 0; index < argv.length; index++) {
11
+ const arg = argv[index];
12
+ if (arg === '--help' || arg === '-h') {
13
+ process.stdout.write('Usage: node scripts/baseline-archetypes.mjs [--repo PATH] [--archetype ID]\nOffline, read-only JSON lookup. Exit 0: public/private; exit 2: invalid configuration or contract.\n');
14
+ return 0;
15
+ }
16
+ if (!['--repo', '--archetype'].includes(arg) || !argv[index + 1] || argv[index + 1].startsWith('--')) {
17
+ process.stdout.write(`${JSON.stringify({ status: 'error', reason: 'invalid-arguments', archetypes: [], selected: null })}\n`);
18
+ return 2;
19
+ }
20
+ options[arg === '--repo' ? 'repoRoot' : 'archetype'] = argv[++index];
21
+ }
22
+ if (options.repoRoot) options.repoRoot = path.resolve(options.repoRoot);
23
+ const result = await loadBaselineArchetypes(options);
24
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
25
+ return result.status === 'error' ? 2 : 0;
26
+ }
27
+
28
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exitCode = await main();
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ // Repository CI only: requires the checkout's Vitest config and dev dependencies.
3
+ // Test-result JSON does not prove coverage. Check the structured coverage summary
4
+ // against the same global percentage thresholds Vitest uses before writing a marker.
5
+ // The caller removes both reports and the marker before starting the current run.
6
+
7
+ import { readFileSync } from 'node:fs';
8
+ import config from '../../vitest.config.mjs';
9
+
10
+ const metrics = ['lines', 'functions', 'statements', 'branches'];
11
+ const [summaryPath = 'coverage/coverage-summary.json', xmlPath = 'coverage/cobertura-coverage.xml'] = process.argv.slice(2);
12
+
13
+ function requireValid(condition, reason) {
14
+ if (!condition) throw new Error(reason);
15
+ }
16
+
17
+ // Check completeness of the XML emitted by the configured Istanbul reporter.
18
+ // This is an artifact check, not a DTD/schema evaluator; never fetch the DOCTYPE.
19
+ function checkCobertura(xml, total) {
20
+ const stack = [];
21
+ let root;
22
+ let hasClassLine = false;
23
+ const parts = xml.match(/<[^>]*>|[^<]+/g) ?? [];
24
+ requireValid(parts.join('') === xml, 'malformed Cobertura artifact');
25
+ for (const part of parts) {
26
+ if (/^<\?xml\s[^<>]*\?>$|^<!DOCTYPE coverage SYSTEM "[^"<>]*">$/.test(part)) {
27
+ requireValid(!root, 'unexpected Cobertura declaration');
28
+ continue;
29
+ }
30
+ if (!part.startsWith('<')) {
31
+ requireValid(!part.trim() || stack.at(-1) === 'source', 'unexpected Cobertura content');
32
+ continue;
33
+ }
34
+ const tag = part.match(/^<(\/?)([A-Za-z][\w.-]*)((?:\s+[\w:-]+="[^"<>]*")*)\s*(\/?)>$/);
35
+ requireValid(tag, 'malformed Cobertura tag');
36
+ const [, closing, name, attributes, selfClosing] = tag;
37
+ if (closing) {
38
+ requireValid(!attributes && !selfClosing && stack.pop() === name, 'unbalanced Cobertura tags');
39
+ } else {
40
+ const entries = [...attributes.matchAll(/([\w:-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]);
41
+ requireValid(new Set(entries.map(([key]) => key)).size === entries.length, 'duplicate Cobertura attributes');
42
+ const values = Object.fromEntries(entries);
43
+ if (stack.length === 0) {
44
+ requireValid(!root && name === 'coverage' && !selfClosing, 'invalid Cobertura root');
45
+ root = values;
46
+ }
47
+ const path = [...stack, name].join('/');
48
+ if (path === 'coverage/packages/package/classes/class') {
49
+ requireValid(typeof values.filename === 'string' && values.filename.trim(), 'Cobertura class is missing a filename');
50
+ }
51
+ if (path === 'coverage/packages/package/classes/class/lines/line') {
52
+ requireValid(/^\d+$/.test(values.number) && Number(values.number) > 0
53
+ && /^\d+$/.test(values.hits), 'invalid Cobertura line record');
54
+ hasClassLine = true;
55
+ }
56
+ if (!selfClosing) stack.push(name);
57
+ }
58
+ }
59
+ requireValid(root && stack.length === 0, 'empty or incomplete Cobertura artifact');
60
+ requireValid(total.lines.total === 0 || hasClassLine, 'Cobertura artifact contains no class line records');
61
+ for (const [attribute, expected] of Object.entries({
62
+ 'lines-valid': total.lines.total,
63
+ 'lines-covered': total.lines.covered,
64
+ 'line-rate': total.lines.pct / 100,
65
+ 'branches-valid': total.branches.total,
66
+ 'branches-covered': total.branches.covered,
67
+ 'branch-rate': total.branches.pct / 100,
68
+ })) {
69
+ requireValid(root[attribute] !== undefined && root[attribute] !== ''
70
+ && Number(root[attribute]) === expected, `Cobertura ${attribute} disagrees with coverage summary`);
71
+ }
72
+ }
73
+
74
+ try {
75
+ const thresholds = config.test?.coverage?.thresholds;
76
+ requireValid(thresholds && Object.keys(thresholds).every((key) => metrics.includes(key)),
77
+ 'unsupported coverage thresholds: expected global percentage thresholds');
78
+ const { total } = JSON.parse(readFileSync(summaryPath, 'utf8'));
79
+ requireValid(total && typeof total === 'object', 'missing coverage totals');
80
+ for (const metric of metrics) {
81
+ const threshold = thresholds[metric];
82
+ requireValid(Number.isFinite(threshold) && threshold >= 0 && threshold <= 100,
83
+ `missing or invalid configured ${metric} threshold`);
84
+ const data = total[metric];
85
+ requireValid(data && ['total', 'covered', 'skipped'].every((key) => Number.isSafeInteger(data[key]) && data[key] >= 0)
86
+ && data.total > 0 && data.covered <= data.total && data.skipped <= data.total
87
+ && Number.isFinite(data.pct) && data.pct >= 0 && data.pct <= 100,
88
+ `missing or invalid ${metric} coverage data`);
89
+ // Istanbul truncates percentages to two decimals; compare the reported
90
+ // percentage (Vitest's threshold input) only after checking its counters.
91
+ const percentage = Math.floor((100000 * data.covered / data.total) / 10) / 100;
92
+ requireValid(data.pct === percentage, `inconsistent ${metric} coverage percentage`);
93
+ requireValid(data.pct >= threshold, `${metric} coverage ${data.pct}% is below configured ${threshold}%`);
94
+ }
95
+ checkCobertura(readFileSync(xmlPath, 'utf8'), total);
96
+ console.log(`[ci] coverage verified: ${metrics.map((metric) => `${metric}=${total[metric].pct}%`).join(' ')}`);
97
+ } catch (error) {
98
+ console.error(`[ci] coverage NOT VERIFIED: ${error.message}`);
99
+ process.exitCode = 1;
100
+ }