session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -12,9 +12,12 @@
12
12
  * - File-absent or all-null-token sessions return a sentinel shape with null
13
13
  * totals (not 0) so callers can distinguish "session had no token data" from
14
14
  * "session was genuinely free / cost $0".
15
+ * - `match_status` and `ledger_records` distinguish why totals are absent
16
+ * without interpreting the session key's shape (#1027 Nachtrag 7).
15
17
  * - Malformed JSONL lines are silently skipped (resilience over strictness).
16
18
  * - `subagents_with_tokens` counts distinct agent_ids that have at least one
17
- * TOKEN-BEARING record (coverage metric).
19
+ * TOKEN-BEARING record (coverage metric); every total counts each agent once
20
+ * (§ One record per agent).
18
21
  *
19
22
  * ## Token provenance — why a bare Σ over token_input is wrong (#949)
20
23
  *
@@ -64,10 +67,54 @@
64
67
  * `total_token_*` and reports the excluded ones as `legacy_v1_records`: the
65
68
  * boundary is DECLARED, never silent.
66
69
  *
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.
70
+ * ## One record per agent — a stop record carries a RUNNING total
71
+ *
72
+ * `hooks/subagent-telemetry.mjs` re-reads the agent's WHOLE transcript at every
73
+ * stop, so an agent that stops twice (resumed via SendMessage, for one) writes
74
+ * its running total twice, and summing both counts the first part again. Every
75
+ * total below therefore takes ONE v2 record per `agent_id`: its tokens come from
76
+ * the agent's last stop that found its transcript and carried tokens, its cost
77
+ * candidacy from its last stop that found its transcript ("last" = latest
78
+ * `timestamp`, ties and unparseable timestamps by file order). Those two differ
79
+ * only when the last read yielded no tokens — an oversized transcript: the
80
+ * earlier running total is then a lower bound for the tokens and the cost is
81
+ * unknown. Measured 2026-10-01 over the 31 local fleet ledgers (1,002 distinct
82
+ * session/agent pairs with two or more found stops, 407 sessions): no v2 token
83
+ * bucket ever decreased from one found stop to the next, so the last record is
84
+ * also the largest; 2 pairs end in a token-less stop, both transcripts now past
85
+ * the hook's 50 MiB read limit. FORWARD-ONLY like the #949 cut: totals already
86
+ * in `sessions.jsonl` are not recomputed.
87
+ *
88
+ * `total_cost_usd` is computed per agent via `costUsd()` and is null whenever
89
+ * `cost_records_priced < cost_records_total` (#1475) — a partial cost is worse
90
+ * than no cost, because it reads as a complete one. An agent is unpriced when the
91
+ * model of its last found record is unknown to the price table, when it has no model (a token-bearing turn
92
+ * named none), when it has no tokens at all (transcript found but oversized,
93
+ * without usage turns, or unreadable, #1474), or when the agent has a start
94
+ * record but its stop found no transcript of its own and no other record of the
95
+ * same agent did (`start_record_found: true`, `subagent_transcript_found: false`
96
+ * — mostly `workflow-subagent`), or when its only own-transcript records are
97
+ * schema_version 1 (#1487 Pkt 1 — v1 has no priceable buckets). A phantom stop
98
+ * (#939, no start record) is no agent and no candidate. A record whose four token buckets
99
+ * are all 0 is priced at $0 whatever its model, and never turns a null total into
100
+ * a number on its own. A record whose turns span several models carries
101
+ * `models_usage` (#1470) and is priced part by part. `cost_records_priced` /
102
+ * `cost_records_total` say how much of the session the estimate covers; with
103
+ * nothing to price (total 0) the cost is null as well.
104
+ *
105
+ * Blind spots the ledger cannot close: an agent whose start record lies outside
106
+ * the hook's tail window reads `start_record_found: false` and is
107
+ * indistinguishable from a phantom, and an agent that left neither a start nor a
108
+ * stop record is invisible. "Never a partial sum" holds over the agents the
109
+ * ledger can see. Until #1487 Pkt 1 (2026-10-02) a session spanning the
110
+ * 2026-09-09 schema boundary was priced over its v2 records only — its v1
111
+ * agents were never cost candidates, so the v2 half's sum read as the whole
112
+ * bill (4 distinct fleet sessions, 31 ledgers, 2026-10-01). A v1 agent is now an
113
+ * unpriced candidate: such a session reports `total_cost_usd: null` with
114
+ * `cost_records_priced < cost_records_total`. Re-measured 2026-10-02 (28
115
+ * ledgers, 1,887 ledger/session pairs): the same 4 sessions turn null, and 498
116
+ * pure-v1 pairs change only `cost_records_total` (0/0 → 0/N, cost null before
117
+ * and after). FORWARD-ONLY like the #949 cut.
71
118
  *
72
119
  * @module session-token-rollup
73
120
  */
@@ -102,6 +149,26 @@ function isTokenBearing(record) {
102
149
  return record?.subagent_transcript_found === true;
103
150
  }
104
151
 
152
+ /**
153
+ * Did a REAL agent run whose transcript the hook could not find? (#1475 follow-up)
154
+ *
155
+ * A stop record with a start record (`start_record_found: true`) but no own
156
+ * transcript (`subagent_transcript_found: false`) is not a phantom: the agent
157
+ * existed, it just left nothing to price — 813 of the 877 such records in the
158
+ * 33 fleet ledgers are `workflow-subagent` (2026-10-01). Its cost is unknown, so it must reach
159
+ * `cost_records_total`. A phantom stop (#939) has `start_record_found` false and
160
+ * stays out; a record written before either flag existed matches neither.
161
+ * @param {object} record
162
+ * @returns {boolean}
163
+ */
164
+ function isStartedWithoutTranscript(record) {
165
+ return (
166
+ record?.event === 'stop' &&
167
+ record.start_record_found === true &&
168
+ record.subagent_transcript_found === false
169
+ );
170
+ }
171
+
105
172
  /**
106
173
  * Is this record inside the schema_version 2 token contract? (#1244)
107
174
  * @param {object} record
@@ -111,19 +178,76 @@ function isV2(record) {
111
178
  return typeof record?.schema_version === 'number' && record.schema_version >= 2;
112
179
  }
113
180
 
181
+ /**
182
+ * Does this record carry a token count on either side?
183
+ * @param {object} record
184
+ * @returns {boolean}
185
+ */
186
+ function hasTokens(record) {
187
+ const { token_input: inp, token_output: out } = record;
188
+ return (typeof inp === 'number' && inp >= 0) || (typeof out === 'number' && out >= 0);
189
+ }
190
+
191
+ /**
192
+ * Was `candidate` written at a later stop than `current`? Ordered by
193
+ * `timestamp`; a tie or an unparseable timestamp falls back to file order, and
194
+ * the caller reads in file order, so the candidate then wins.
195
+ * @param {object} candidate — read after `current` in the ledger
196
+ * @param {object} current
197
+ * @returns {boolean}
198
+ */
199
+ function isLaterStop(candidate, current) {
200
+ const a = Date.parse(candidate.timestamp);
201
+ const b = Date.parse(current.timestamp);
202
+ if (Number.isNaN(a) || Number.isNaN(b) || a === b) return true;
203
+ return a > b;
204
+ }
205
+
206
+ /**
207
+ * USD cost of one v2 record, or null when it cannot be priced. A record whose
208
+ * token-bearing turns span several models carries `models_usage` (#1470): each
209
+ * part is priced at its own model's rates, and one unknown part leaves the whole
210
+ * record unpriced. Without that field the record's single `model` prices all
211
+ * four buckets.
212
+ * @param {object} record
213
+ * @returns {number|null}
214
+ */
215
+ function recordCostUsd(record) {
216
+ const parts =
217
+ Array.isArray(record.models_usage) && record.models_usage.length > 0
218
+ ? record.models_usage
219
+ : [record];
220
+ let total = 0;
221
+ for (const part of parts) {
222
+ const cost = costUsd({
223
+ model: part?.model,
224
+ tokenInputUncached: part?.token_input_uncached,
225
+ tokenCacheRead: part?.token_cache_read,
226
+ tokenCacheCreation: part?.token_cache_creation,
227
+ tokenOutput: part?.token_output,
228
+ });
229
+ if (cost === null) return null;
230
+ total += cost;
231
+ }
232
+ return total;
233
+ }
234
+
114
235
  /**
115
236
  * @typedef {Object} TokenRollupResult
116
- * @property {number|null} total_token_input - Sum of token_input across TOKEN-BEARING matched records; null when none had a non-null value.
117
- * @property {number|null} total_token_output - Sum of token_output across TOKEN-BEARING matched records; null when none had a non-null value.
237
+ * @property {'invalid-key'|'ledger-absent'|'ledger-empty'|'unmatched'|'matched'} match_status - Distinguishes absent telemetry from an unmatched key (#1027 Nachtrag 7).
238
+ * @property {number|null} ledger_records - Count of parsed non-null, non-array objects; null when the ledger was not read (#1027 Nachtrag 7).
239
+ * @property {number|null} total_token_input - Sum of token_input over one v2 token-bearing record per agent (its last stop that carried tokens — § One record per agent); null when none had a non-null value.
240
+ * @property {number|null} total_token_output - Sum of token_output over the same one record per agent; null when none had a non-null value.
241
+ * @property {number|null} total_tokens - total_token_input + total_token_output (#1436); a null side counts as absent, null only when BOTH are null.
118
242
  * @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.
119
243
  * @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).
244
+ * @property {number|null} total_token_input_uncached - Sum of token_input_uncached over the same one record per agent.
245
+ * @property {number|null} total_token_cache_read - Sum of token_cache_read over the same one record per agent.
246
+ * @property {number|null} total_token_cache_creation - Sum of token_cache_creation over the same one record per agent.
247
+ * @property {number|null} total_cost_usd - Σ cost over the agents counted in cost_records_priced; null when cost_records_priced < cost_records_total (#1475 — any unpriced candidate: unknown model, no model, no tokens, a started agent without a transcript, or a v1 agent, #1487 Pkt 1), null when there is nothing to price (cost_records_total 0), and null when no record with a non-zero bucket was priced (all-zero records alone never yield a fabricated $0). Never 0 for unknown — see telemetry/pricing.mjs.
248
+ * @property {number} cost_records_priced - How many agents of cost_records_total carry a known cost on their last found v2 record: priced by the table (per model part when the record carries models_usage, #1470), or all four token buckets 0 (priced at $0 whatever the model, #1474).
249
+ * @property {number} cost_records_total - How many agents were candidates for pricing, each counted once however often it stopped: every agent with a v2 record whose own transcript was found — priced on its LAST such record, so one whose last read yielded no tokens (transcript oversized, without usage turns, or unreadable, #1474) is unpriced — plus every started agent whose stops found no transcript of its own (`start_record_found: true`, `subagent_transcript_found: false`), plus every agent with a schema_version 1 own-transcript record (#1487 Pkt 1) — each of these two only when the agent has no v2 own-transcript record in the session. All three latter kinds have an unknown cost. Phantom stops (no start record, #939) are never candidates. priced < total nulls total_cost_usd.
250
+ * @property {number} legacy_v1_records - Token-bearing records EXCLUDED from every token total above because their schema_version < 2 (their token_input is a different quantity). Their agents are unpriced cost candidates (cost_records_total, #1487 Pkt 1).
127
251
  * @property {2} _token_schema - The token contract these totals were computed under.
128
252
  */
129
253
 
@@ -142,8 +266,11 @@ export function rollupSessionTokens({
142
266
  }) {
143
267
  /** @type {TokenRollupResult} */
144
268
  const ZERO = {
269
+ match_status: 'invalid-key',
270
+ ledger_records: null,
145
271
  total_token_input: null,
146
272
  total_token_output: null,
273
+ total_tokens: null,
147
274
  subagents_with_tokens: 0,
148
275
  matched_records: 0,
149
276
  total_token_input_uncached: null,
@@ -169,7 +296,7 @@ export function rollupSessionTokens({
169
296
  raw = readFileSync(resolvedPath, 'utf8');
170
297
  } catch (err) {
171
298
  if (err.code === 'ENOENT') {
172
- return { ...ZERO };
299
+ return { ...ZERO, match_status: 'ledger-absent' };
173
300
  }
174
301
  throw err;
175
302
  }
@@ -177,6 +304,7 @@ export function rollupSessionTokens({
177
304
  // Parse JSONL — skip malformed lines, filter to parentSessionId.
178
305
  const lines = raw.split('\n');
179
306
  const matched = [];
307
+ let ledgerRecords = 0;
180
308
  for (const line of lines) {
181
309
  const trimmed = line.trim();
182
310
  if (!trimmed) continue;
@@ -187,13 +315,19 @@ export function rollupSessionTokens({
187
315
  // Malformed line — skip silently.
188
316
  continue;
189
317
  }
190
- if (record && record.parent_session_id === parentSessionId) {
318
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) continue;
319
+ ledgerRecords += 1;
320
+ if (record.parent_session_id === parentSessionId) {
191
321
  matched.push(record);
192
322
  }
193
323
  }
194
324
 
195
325
  if (matched.length === 0) {
196
- return { ...ZERO };
326
+ return {
327
+ ...ZERO,
328
+ match_status: ledgerRecords === 0 ? 'ledger-empty' : 'unmatched',
329
+ ledger_records: ledgerRecords,
330
+ };
197
331
  }
198
332
 
199
333
  // Aggregate — skip null/undefined token values.
@@ -205,7 +339,6 @@ export function rollupSessionTokens({
205
339
  let sumCost = null;
206
340
  let costPriced = 0;
207
341
  let costTotal = 0;
208
- let costUnknownModel = false;
209
342
  let legacyV1 = 0;
210
343
 
211
344
  // Track distinct agent_ids that contributed at least one non-null token.
@@ -214,68 +347,133 @@ export function rollupSessionTokens({
214
347
  const addNonNegative = (acc, value) =>
215
348
  typeof value === 'number' && value >= 0 ? (acc ?? 0) + value : acc;
216
349
 
350
+ // Real agents that may have no v2 record of their own transcript — each an
351
+ // unpriced candidate, counted once: a started agent whose stop found no
352
+ // transcript (#1475), and an agent seen in a v1 record (#1487 Pkt 1 — v1
353
+ // carries no priceable buckets). Resolved against `agents` only AFTER the
354
+ // pass: a later v2 stop of the same agent that found its transcript prices it
355
+ // (measured 2026-10-01: 4 agents in one fleet session, `workflow-subagent`
356
+ // found:false first, then found:true) and must not null a known cost. A record
357
+ // without an agent_id cannot be joined to another and stands alone.
358
+ const maybeUnpriced = new Set();
359
+ let unkeyedUnpriced = 0;
360
+ const addMaybeUnpriced = (record) => {
361
+ if (record.agent_id === undefined || record.agent_id === null) unkeyedUnpriced += 1;
362
+ else maybeUnpriced.add(record.agent_id);
363
+ };
364
+
365
+ // One agent, one count (see module header § One record per agent). Per agent:
366
+ // `last` is its last stop that found its transcript — the cost candidate —
367
+ // and `lastWithTokens` its last such stop that carried tokens — the token
368
+ // contribution. They differ only when the last read yielded no tokens (an
369
+ // oversized transcript): the earlier running total is then a known lower bound
370
+ // for the tokens, while the cost stays unknown. A record without an agent_id
371
+ // cannot be joined to another and stands alone.
372
+ /** @type {Map<unknown, {last: object, lastWithTokens: object|null}>} */
373
+ const agents = new Map();
374
+
217
375
  for (const record of matched) {
218
376
  // Provenance gate (#949) — a record whose tokens describe the PARENT
219
- // transcript, or no transcript at all, contributes nothing. Skipping it
377
+ // transcript, or no transcript at all, contributes no TOKENS. Skipping it
220
378
  // entirely (rather than treating its values as 0) preserves the null
221
379
  // sentinel: a session of only untrustworthy records reports "no data",
222
380
  // which is true, instead of a fabricated 0.
223
- if (!isTokenBearing(record)) continue;
381
+ if (!isTokenBearing(record)) {
382
+ // …but an agent that really ran without a readable transcript has an
383
+ // unknown cost: an unpriced candidate (total, not priced), so priced <
384
+ // total nulls `total_cost_usd` (#1475). Phantoms never reach this branch.
385
+ if (isStartedWithoutTranscript(record)) addMaybeUnpriced(record);
386
+ continue;
387
+ }
224
388
 
225
389
  // Schema gate (#1244) — a v1 record's token_input is raw uncached input,
226
390
  // a different quantity from a v2 record's billable prompt volume. Count it
227
- // so the boundary is visible, never sum it.
391
+ // so the boundary is visible, never sum it. Its agent ran, though, and v1
392
+ // cannot be priced: it is an unpriced cost candidate (#1487 Pkt 1), so a
393
+ // session spanning the 2026-09-09 boundary reports a null cost instead of
394
+ // the v2 half's partial sum.
228
395
  if (!isV2(record)) {
229
396
  legacyV1 += 1;
397
+ addMaybeUnpriced(record);
230
398
  continue;
231
399
  }
232
400
 
233
- const inp = record.token_input;
234
- const out = record.token_output;
235
-
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);
401
+ const withTokens = hasTokens(record) ? record : null;
402
+ const key = record.agent_id ?? Symbol('unkeyed');
403
+ const seen = agents.get(key);
404
+ if (!seen) {
405
+ agents.set(key, { last: record, lastWithTokens: withTokens });
406
+ continue;
407
+ }
408
+ // A token-bearing `last` is always `lastWithTokens` too — set together, so
409
+ // an unparseable timestamp (no transitive order) cannot split them.
410
+ if (isLaterStop(record, seen.last)) {
411
+ seen.last = record;
412
+ if (withTokens) seen.lastWithTokens = withTokens;
413
+ } else if (withTokens && (!seen.lastWithTokens || isLaterStop(withTokens, seen.lastWithTokens))) {
414
+ seen.lastWithTokens = withTokens;
415
+ }
416
+ }
241
417
 
242
- // Count this agent as having tokens if either field is a non-null number.
243
- const hasTokens =
244
- (typeof inp === 'number' && inp >= 0) || (typeof out === 'number' && out >= 0);
245
- if (hasTokens) {
246
- if (record.agent_id !== undefined && record.agent_id !== null) {
247
- agentsWithTokens.add(record.agent_id);
248
- }
418
+ for (const [key, { last, lastWithTokens }] of agents) {
419
+ if (lastWithTokens) {
420
+ sumInput = addNonNegative(sumInput, lastWithTokens.token_input);
421
+ sumOutput = addNonNegative(sumOutput, lastWithTokens.token_output);
422
+ sumUncached = addNonNegative(sumUncached, lastWithTokens.token_input_uncached);
423
+ sumCacheRead = addNonNegative(sumCacheRead, lastWithTokens.token_cache_read);
424
+ sumCacheCreation = addNonNegative(sumCacheCreation, lastWithTokens.token_cache_creation);
425
+ if (typeof key !== 'symbol') agentsWithTokens.add(key);
426
+ }
249
427
 
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
- }
428
+ // Cost: every agent with a found transcript is ONE pricing candidate. One
429
+ // unpriced agent poisons the SESSION total — a cost covering some of the
430
+ // agents reads as covering all of them.
431
+ costTotal += 1;
432
+ if (last !== lastWithTokens) {
433
+ // #1474 — the agent's last read of its own transcript yielded no tokens:
434
+ // it exceeded the hook's MAX_TRANSCRIPT_BYTES and was not read, it held no
435
+ // usage turns, or it was unreadable. Either way the agent ran and its cost
436
+ // is unknown, so it counts as an unpriced candidate (total, not priced),
437
+ // and priced < total nulls `total_cost_usd` (#1475).
438
+ continue;
439
+ }
440
+ // #1474 — all four buckets 0 costs $0 whatever the model says. The hook
441
+ // names such a record by its last turn, typically `<synthetic>`, which
442
+ // the price table does not know; pricing it would null the session total
443
+ // for a record that added nothing to it.
444
+ const allZero = [
445
+ last.token_input_uncached,
446
+ last.token_cache_read,
447
+ last.token_cache_creation,
448
+ last.token_output,
449
+ ].every((v) => v === 0);
450
+ const cost = allZero ? 0 : recordCostUsd(last);
451
+ if (cost !== null) {
452
+ costPriced += 1;
453
+ // An all-zero record adds nothing, so it must not be what turns
454
+ // `sumCost` from null into a number: next to a token-less record it
455
+ // would persist a fabricated $0 for a session whose real cost is unknown.
456
+ if (!allZero) sumCost = (sumCost ?? 0) + cost;
267
457
  }
268
458
  }
459
+ for (const id of maybeUnpriced) if (!agents.has(id)) costTotal += 1;
460
+ costTotal += unkeyedUnpriced;
269
461
 
270
462
  return {
463
+ match_status: 'matched',
464
+ ledger_records: ledgerRecords,
271
465
  total_token_input: sumInput,
272
466
  total_token_output: sumOutput,
467
+ // #1436 — the one figure a session-level budget compares against. Null only
468
+ // when neither side has data; never a fabricated 0.
469
+ total_tokens: sumInput === null && sumOutput === null ? null : (sumInput ?? 0) + (sumOutput ?? 0),
273
470
  subagents_with_tokens: agentsWithTokens.size,
274
471
  matched_records: matched.length,
275
472
  total_token_input_uncached: sumUncached,
276
473
  total_token_cache_read: sumCacheRead,
277
474
  total_token_cache_creation: sumCacheCreation,
278
- total_cost_usd: costUnknownModel ? null : sumCost,
475
+ // #1475 — a cost that leaves out any candidate record is not persisted.
476
+ total_cost_usd: costPriced < costTotal ? null : sumCost,
279
477
  cost_records_priced: costPriced,
280
478
  cost_records_total: costTotal,
281
479
  legacy_v1_records: legacyV1,
@@ -20,6 +20,18 @@
20
20
  * (2026-05-10) carries a byte-identical duplicate LINE — an older
21
21
  * collision class than (3), and rule (1) alone resolves it.
22
22
  *
23
+ * EXCEPT: a backfill stub never shadows a record that is not one (#1443).
24
+ * A stub (a synthesised `_backfill_source` — repair provenance is not one,
25
+ * see `isBackfillStub()` — and no wave, agent or changed file, whatever the
26
+ * `status` or `_repair_source`) appended AFTER the real record for the
27
+ * same id hid that record from every reader — the writers were closed by
28
+ * #1368 and the key-occupancy guard, this reader was the remaining gap.
29
+ * Among non-stubs, and among stubs, the newest still wins. The same guard
30
+ * joins ACROSS ids: a stub sharing a non-empty `raw_session_id` with a
31
+ * non-stub under a DIFFERENT `session_id` is dropped for the non-stub. That
32
+ * join runs LAST, so it can neither orphan a synthetic twin of (2) nor
33
+ * change what (3) reports.
34
+ *
23
35
  * (2) NARROW COLLAPSE OF THE SYSTEMIC DOUBLE-STUB CLASS.
24
36
  * Two `abandoned` records with an EXACT `started_at` + `completed_at`
25
37
  * tuple match are one physical session recorded twice by the two backfill
@@ -70,8 +82,8 @@
70
82
  * refused marker keeps both records and is reported (never logged)
71
83
  * via `canonicalizeSessionsDetailed().ignoredSupersedes`.
72
84
  *
73
- * RULE ORDER: (1) → (2) → (3). The double-stub collapse must run BEFORE
74
- * supersede removal: with the reverse order a `supersedes` append deleted the
85
+ * RULE ORDER: (1) → (2) → (3) → (1)'s raw-id join. The double-stub collapse
86
+ * must run BEFORE supersede removal: with the reverse order a `supersedes` append deleted the
75
87
  * authentic stub first, shrank the tuple group to a single member, and the
76
88
  * synthetic phantom then survived the very session that refuted it.
77
89
  *
@@ -158,6 +170,80 @@ function supersedeRejectReason(superseder, target) {
158
170
  return 'no-shared-join-key';
159
171
  }
160
172
 
173
+ /**
174
+ * `_backfill_source` prefix that `session-record-repair.mjs` stamps
175
+ * (`REPAIR_SOURCE`, `'repair-invalid-sessions/1004'`) on a record it repaired
176
+ * in place that had NO origin of its own — i.e. a REAL record. A record that
177
+ * already carried a synthesised origin keeps it, and the repair goes to the
178
+ * separate `_repair_source` field, which this module deliberately ignores
179
+ * (#1443 F2). Kept as a literal so this module stays a stdlib-only leaf.
180
+ */
181
+ const REPAIR_PROVENANCE_PREFIX = 'repair-invalid-sessions/';
182
+
183
+ /**
184
+ * True when a record is a BACKFILL STUB for rule (1)'s #1443 guard: it carries
185
+ * SYNTHESISED provenance (`_backfill_source`, but not repair provenance) AND
186
+ * measured no work (0 waves, agents and files) — whatever its `status`. A
187
+ * missing `status` is the norm on real records, so status cannot decide it.
188
+ *
189
+ * Repair provenance (`repair-invalid-sessions/…`) in `_backfill_source` is
190
+ * never a stub: the repair tool puts it there only on a record without an
191
+ * origin, i.e. a real record it fixed in place, not one it invented (#1443 F1 —
192
+ * a repaired 4-wave record lost to an older 2-wave one). A repaired STUB keeps
193
+ * its synthesised `_backfill_source` (`state-md-completed`, `events-jsonl`,
194
+ * `abandoned`) and is judged on that and on its work, whatever its
195
+ * `_repair_source` says (#1443 F2 — a repaired 0-wave stub shadowed a 4-wave
196
+ * record once the repair overwrote its origin). Legacy records repaired before
197
+ * F2 lost that origin irrecoverably and read as real.
198
+ *
199
+ * Exported for `scripts/autopilot.mjs`, which must not read a stub as a
200
+ * healthy 0-token iteration (#1457 F2). Not shared with
201
+ * `session-close-backfill.mjs::isSupersedableStub()` on purpose: that asks a
202
+ * narrower WRITER question ("may this be overwritten?" — `abandoned` only), and
203
+ * that module imports this one, so reusing it here would be a cycle.
204
+ *
205
+ * @param {object} rec
206
+ * @returns {boolean}
207
+ */
208
+ export function isBackfillStub(rec) {
209
+ if (!isNonEmptyString(rec._backfill_source)) return false;
210
+ if (rec._backfill_source.startsWith(REPAIR_PROVENANCE_PREFIX)) return false;
211
+ const hasWork = (Array.isArray(rec.waves) && rec.waves.length > 0)
212
+ || rec.total_waves > 0 || rec.total_agents > 0 || rec.total_files_changed > 0;
213
+ return !hasWork;
214
+ }
215
+
216
+ /**
217
+ * Rule (1), cross-id half (#1443) — drop every backfill stub that shares a
218
+ * non-empty `raw_session_id` with at least one non-stub. A group of stubs only
219
+ * (or of non-stubs only) is left intact: with no non-stub to prefer there is no
220
+ * evidence which record is the artefact. Mutates `byId`.
221
+ *
222
+ * LIMIT (#1457 point 9): the join assumes one raw_session_id = one physical
223
+ * session. A raw id can also span a CLOSED session and a later, separate
224
+ * abandoned tail (measured: a stub starting 4 minutes after its partner's
225
+ * `completed_at`, LeadPipeDACH `main-2026-09-03-session-11`) — that stub is
226
+ * dropped too, so the tail is invisible to every reader. No time-window check
227
+ * guards against it; adding one is a separate decision.
228
+ * @param {Map<string, object>} byId
229
+ * @returns {void}
230
+ */
231
+ function collapseStubsByRawSessionId(byId) {
232
+ const byRaw = new Map();
233
+ for (const rec of byId.values()) {
234
+ if (!isNonEmptyString(rec.raw_session_id)) continue;
235
+ const group = byRaw.get(rec.raw_session_id);
236
+ if (group) group.push(rec);
237
+ else byRaw.set(rec.raw_session_id, [rec]);
238
+ }
239
+ for (const group of byRaw.values()) {
240
+ if (group.length < 2 || group.every(isBackfillStub)) continue;
241
+ for (const rec of group) {
242
+ if (isBackfillStub(rec)) byId.delete(rec.session_id);
243
+ }
244
+ }
245
+ }
246
+
161
247
  /**
162
248
  * Rule (3) — collapse the systemic two-writer double stub. Mutates `byId` by
163
249
  * deleting the synthetic twin of each qualifying pair.
@@ -285,10 +371,13 @@ function resolveSupersedes(byId, ignored) {
285
371
  *
286
372
  * Rules, applied in this order (see the module header for the measured
287
373
  * justification of each):
288
- * 1. newest-wins per `session_id` (file order is chronological);
374
+ * 1. newest-wins per `session_id` (file order is chronological), except
375
+ * that a later backfill stub never shadows an earlier non-stub (#1443);
289
376
  * 2. two `abandoned` records with an exact, both-present
290
377
  * `started_at` + `completed_at` tuple collapse to the non-synthetic one;
291
- * 3. a surviving record's ATTESTABLE `supersedes: X` removes record `X`.
378
+ * 3. a surviving record's ATTESTABLE `supersedes: X` removes record `X`;
379
+ * 4. a backfill stub sharing a non-empty `raw_session_id` with a surviving
380
+ * non-stub under another `session_id` is dropped (#1443).
292
381
  *
293
382
  * The double-stub collapse runs BEFORE supersede removal so that a stub which
294
383
  * is itself about to be superseded still shadows its synthetic twin — with the
@@ -325,6 +414,9 @@ export function canonicalizeSessionsDetailed(records, { keepUnidentified = false
325
414
  if (keepUnidentified) unidentified.push(rec);
326
415
  continue;
327
416
  }
417
+ // #1443 — a later backfill stub never shadows an earlier non-stub.
418
+ const prev = byId.get(rec.session_id);
419
+ if (prev && isBackfillStub(rec) && !isBackfillStub(prev)) continue;
328
420
  byId.set(rec.session_id, rec);
329
421
  }
330
422
 
@@ -335,6 +427,9 @@ export function canonicalizeSessionsDetailed(records, { keepUnidentified = false
335
427
  const ignoredSupersedes = [];
336
428
  resolveSupersedes(byId, ignoredSupersedes);
337
429
 
430
+ // -- (1b) stub vs non-stub across ids, joined by raw_session_id (#1443) ----
431
+ collapseStubsByRawSessionId(byId);
432
+
338
433
  return { records: [...byId.values(), ...unidentified], ignoredSupersedes };
339
434
  }
340
435
 
@@ -399,7 +494,7 @@ export function countSessionsInJsonl(raw) {
399
494
  * is skipped rather than aborting the whole read (same posture as the readers
400
495
  * in `session-close-backfill.mjs` and `backfill-abandoned-sessions.mjs`).
401
496
  *
402
- * NOT EVERY RAW READ OF `sessions.jsonl` IS A GAP TO CLOSE (#1221 P2). Five
497
+ * NOT EVERY RAW READ OF `sessions.jsonl` IS A GAP TO CLOSE (#1221 P2). Four
403
498
  * more readers bypass this module's collapse on purpose — verified via
404
499
  * `rg -n "sessions.jsonl|SESSIONS"` against each file — and none of them
405
500
  * should be "migrated" to `readCanonicalSessions`:
@@ -417,10 +512,6 @@ export function countSessionsInJsonl(raw) {
417
512
  * window; a canonical read could drop a duplicate/superseded stub that
418
513
  * still names a worktree, making a live worktree look unreferenced and
419
514
  * eligible for garbage collection.
420
- * - `readTailSession()` in `scripts/autopilot.mjs` parses only the LAST raw
421
- * line to project the record this run's own session just appended,
422
- * verbatim; canonicalizing could return a different record than the tail
423
- * line if a collapse rule folded it away.
424
515
  *
425
516
  * @param {object} [args]
426
517
  * @param {string} [args.repoRoot] project root; the ledger is resolved as
@@ -192,7 +192,9 @@ const CLOSING_DIAGNOSTIC_EVENTS = new Map([
192
192
  // scripts/lib/autopilot/worktree-pipeline.mjs (release_failed/released only).
193
193
  ['orchestrator.session.lock.released', null],
194
194
  ['orchestrator.session.lock.release_failed', null],
195
- // Only emitter: hooks/on-session-end.mjs — reading the lock during teardown.
195
+ // hooks/on-session-end.mjs — reading the lock during teardown. Since #1494 also
196
+ // hooks/_lib/lock-bootstrap.mjs at SessionStart (`unreadable` only); skipping that
197
+ // one too loses nothing, because the same start emits orchestrator.session.started.
196
198
  ['orchestrator.session.lock.read_anomaly', null],
197
199
  // Wave lifecycle: hooks/post-tool-batch-wave-signal.mjs emits wave.completed MID-session
198
200
  // (real activity, still counted); only the SessionEnd final-wave stamp is a closing
@@ -0,0 +1,29 @@
1
+ /**
2
+ * sh-quote.mjs — the one POSIX shell-quoting helper (#1438).
3
+ *
4
+ * Four byte-identical private copies (cursor-install, tmux-layout/layouts,
5
+ * tmux-layout/tmux-shell, run-quality-gate) and one inline copy
6
+ * (vault-integration-watcher) were folded into this module.
7
+ */
8
+
9
+ /**
10
+ * Quote one value as a single POSIX shell word.
11
+ *
12
+ * Wraps the value in single quotes, inside which POSIX `sh` treats EVERY byte
13
+ * literally — no `$` expansion, no backtick substitution, no word splitting, a
14
+ * newline included. The one byte that cannot appear inside single quotes is
15
+ * the single quote itself, and it cannot be backslash-escaped there either
16
+ * (`\'` inside quotes is a literal backslash that then ENDS the string); so
17
+ * each `'` closes the quoted run, emits an escaped `\'` outside it, and
18
+ * reopens: `'` → `'\''`.
19
+ *
20
+ * The value is coerced with `String(value)`, as all four former copies did —
21
+ * their call sites pass paths, command strings and pane commands, and a
22
+ * number or other primitive must quote to its string form rather than throw.
23
+ *
24
+ * @param {unknown} value The value to quote; coerced via `String()`.
25
+ * @returns {string} e.g. `'it'\''s'` for `it's`, `''` for the empty string.
26
+ */
27
+ export function shellQuote(value) {
28
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
29
+ }