session-orchestrator 3.17.0 → 3.20.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 (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -13,14 +13,67 @@
13
13
  * agent_type?, subagent_type?, parent_session_id?, session_id?,
14
14
  * duration_ms?, transcript_path? }.
15
15
  * 3. Discriminate on hook_event_name → event: 'start' | 'stop'.
16
- * 4. For stop events, parse the subagent transcript at `transcript_path` to
17
- * recover token_input / token_output (#624). The harness does NOT send
18
- * token_input / token_output on stdin — they must be extracted from the
16
+ * 4. For stop events, parse the subagent's OWN transcript to recover
17
+ * token_input / token_output (#624, #949, #950, #963). The harness does NOT
18
+ * send token_input / token_output on stdin — they must be extracted from the
19
19
  * transcript's per-assistant-turn `message.usage` blocks, deduped by
20
- * requestId (streaming snapshots repeat the same requestId ~4-5×).
20
+ * requestId keeping the LAST block per id (streaming snapshots repeat the
21
+ * same requestId a median of 2× — p90 4, max 22, measured 2026-07-31 —
22
+ * and only the last one is complete), falling back to `message.id` with
23
+ * the same last-wins recipe when a block carries no requestId (#963 —
24
+ * 42.9% of usage blocks corpus-wide; see extractTranscriptUsage() for the
25
+ * measurement and the per-field inflation it causes).
26
+ * `transcript_path` on stdin points at the PARENT session transcript, so
27
+ * the subagent's path is DERIVED from it — see resolveSubagentTranscriptPath().
21
28
  * 5. Build canonical record and call appendSubagent().
22
29
  * 6. Output: nothing on stdout. Diagnostic errors to stderr only.
23
30
  *
31
+ * Phantom-stop class (#939, measured 2026-07-31 over the live ledger):
32
+ * the harness fires SubagentStop for an ephemeral agent class that never fires
33
+ * SubagentStart. Those payloads carry a FRESH agent_id per firing, NO
34
+ * agent_type/subagent_type, and a transcript_path that points at the PARENT
35
+ * session transcript (evidence: 1072 of 1395 token-bearing orphan stops share
36
+ * an exact token_input:token_output:parent_session_id fingerprint with a
37
+ * concurrent typed stop; 957 of those 1072 fire BEFORE it, p50 97 s; token
38
+ * totals grow monotonically across the orphan stream). The cause is
39
+ * harness-side, not hook-side: registration (hooks.json) sends exactly
40
+ * SubagentStart|SubagentStop here, and the "missing hook_event_name flips
41
+ * starts to stops" hypothesis is refuted by perfect bimodality — 0 of 1429
42
+ * typed stops are orphaned, 0 of 1494 orphans carry a type. The hook therefore
43
+ * records these firings faithfully but marks every stop with
44
+ * `start_record_found` so readers can filter the phantom class mechanically.
45
+ *
46
+ * #949 settles what the phantom class IS: the orphans are not subagents whose
47
+ * SubagentStart was lost — no subagent ever existed. The harness writes
48
+ * `<transcriptDir>/<parent_session_id>/subagents/agent-<agent_id>.jsonl` for
49
+ * every real Task subagent; measured over session 19eecab8 on 2026-07-31,
50
+ * 25 of 25 typed stops have that file and 0 of 343 orphan stops do. The
51
+ * existence of that file is therefore a far sharper discriminator than
52
+ * `start_record_found` (343/343 coverage vs 32/2955 records carrying the flag),
53
+ * and it is recorded per stop as `subagent_transcript_found`.
54
+ *
55
+ * TOKEN-DATA PROVENANCE — historical records are unusable (#949). Until this
56
+ * fix, token_input/token_output were extracted from the stdin `transcript_path`,
57
+ * which is the PARENT session transcript: every stop record — typed ones
58
+ * included — carried the parent's running totals, not its own (measured
59
+ * 2026-07-31: agent a60348a01ca982b4c's own transcript sums 14/22 while its
60
+ * ledger record reads 40/27540, and up to 7 concurrent agents share bitwise
61
+ * identical values). Those values are NOT reconstructible from the ledger,
62
+ * which never held the subagent figure. Consumers MUST discard token_* on every
63
+ * stop record written before this fix landed and treat only records carrying
64
+ * `subagent_transcript_found: true` as token-bearing; summing across the
65
+ * history double-counts the parent once per subagent.
66
+ *
67
+ * A SECOND, independent defect rode along until #950: the requestId dedup kept
68
+ * the FIRST usage block per id, which on a streaming transcript is a partial
69
+ * snapshot (typically `output_tokens: 1`). Any record written before #950 —
70
+ * including one already reading its own subagent transcript — therefore
71
+ * understates token_output by roughly the ratio of first-chunk to final size
72
+ * (~145x on the measured agent; ~10x summed over this repo's transcript corpus —
73
+ * an earlier "5.3x" here did not reproduce, see extractTranscriptUsage()'s
74
+ * corpus block for the command and the re-measurement). token_input is
75
+ * unaffected: it is constant across a request's snapshots.
76
+ *
24
77
  * Exit codes: 0 always (informational, never blocking).
25
78
  */
26
79
 
@@ -47,6 +100,22 @@ const JSONL_PATH = path.join(SO_PROJECT_DIR, '.orchestrator', 'metrics', 'subage
47
100
  */
48
101
  const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024; // ~50 MB
49
102
 
103
+ /**
104
+ * Tail window scanned backwards to join a stop event to its own start record (#917).
105
+ *
106
+ * The ledger is append-only and grows without bound (1.4 MB / 4201 lines on
107
+ * 2026-07-30), so reading it whole on EVERY SubagentStop would be a hot-path
108
+ * regression that worsens for the life of the repo. A fixed-size tail keeps the
109
+ * join O(1) in file size.
110
+ *
111
+ * Sized from the real distribution, not a guess: measured over the live ledger on
112
+ * 2026-07-30, the byte distance from a start record to its matching stop was
113
+ * p50 1,380 · p90 3,255 · p99 24,229 · max 36,636. 256 KiB is ~7× the observed
114
+ * maximum, so a start that falls outside the window is far rarer than the
115
+ * no-start-record case the null fallback already handles honestly.
116
+ */
117
+ const START_JOIN_TAIL_BYTES = 256 * 1024;
118
+
50
119
  // ---------------------------------------------------------------------------
51
120
  // Helpers
52
121
  // ---------------------------------------------------------------------------
@@ -85,24 +154,227 @@ function readStdinJson() {
85
154
  * `{"type":"assistant"}` line carries a `message.usage` block with
86
155
  * `{ input_tokens, output_tokens, ... }`. Streaming snapshots repeat the SAME
87
156
  * `requestId` across consecutive assistant lines, so a naive Σ over every line
88
- * double-counts (observed ~4-5× on real transcripts). The correct recipe is to
89
- * group by `requestId`, keep ONE usage block per id (the first), then sum.
157
+ * double-counts by a factor that depends on the FIELD, which is why no single
158
+ * scalar belongs here. The repeats are cumulative, so re-adding them inflates
159
+ * `input_tokens` (identical in every snapshot, hence re-added once per repeat)
160
+ * far more than `output_tokens` (the early snapshots are near-empty and add
161
+ * almost nothing). Measured 2026-07-31 over this repo's own subagent transcripts
162
+ * (552 files / 13,609 requestId groups): naive Σ vs this function's dedup is
163
+ * 3.593× on token_input and 1.014× on token_output. On one named transcript,
164
+ * agent a60348a01ca982b4c (25 usage blocks / 7 groups): 3.57× and
165
+ * 1.02×. Snapshots per requestId are mean 2.43 · p50 2 · p90 4 · p99 6 · max 22
166
+ * — the retired "~4-5×" figure sat near that p90 REPEAT COUNT and was never a
167
+ * token ratio at all.
168
+ *
169
+ * d="$HOME/.claude/projects/$(pwd | tr '/.' '-')" node -e '
170
+ * const fs=require("node:fs"),{execSync}=require("node:child_process");
171
+ * const F=execSync(`find ${process.env.d} -path "*subagents*" -name "agent-*.jsonl"`,
172
+ * {maxBuffer:1<<30}).toString().trim().split("\n").filter(Boolean);
173
+ * const add=(a,u)=>{const i=u.input_tokens,o=u.output_tokens;
174
+ * if(Number.isInteger(i)&&i>=0)a.in+=i;if(Number.isInteger(o)&&o>=0)a.out+=o;};
175
+ * let n={in:0,out:0},D={in:0,out:0},g=0;
176
+ * for(const f of F){const b=new Map();
177
+ * for(const l of fs.readFileSync(f,"utf8").split("\n")){
178
+ * const t=l.trim();if(!t)continue;let o;try{o=JSON.parse(t)}catch{continue}
179
+ * if(o?.type!=="assistant"||!o.message?.usage||!o.requestId)continue;
180
+ * add(n,o.message.usage);b.set(o.requestId,o.message.usage);}
181
+ * g+=b.size;for(const u of b.values())add(D,u);}
182
+ * console.log(JSON.stringify({files:F.length,groups:g,naive:n,dedup:D,
183
+ * ratio_input:+(n.in/D.in).toFixed(3),ratio_output:+(n.out/D.out).toFixed(3)}));'
184
+ *
185
+ * {"files":552,"groups":13609,"naive":{"in":9112051,"out":12244723},
186
+ * "dedup":{"in":2536215,"out":12077245},"ratio_input":3.593,"ratio_output":1.014}
187
+ *
188
+ * Same live-corpus caveat as the block below: two consecutive runs reproduced
189
+ * both ratios to three decimals while `groups` grew 13591 → 13609, so the ratios
190
+ * are the durable part and the totals are not. The correct recipe is to group by
191
+ * `requestId`, keep ONE usage block per id — the LAST — then sum.
192
+ *
193
+ * Why the LAST and not the first (#950). The repeated blocks are not copies: they
194
+ * are CUMULATIVE snapshots of one in-flight response, and the early ones are
195
+ * partial (typically `output_tokens: 1`). Only the final block carries the
196
+ * response's true total, so keeping the first — the pre-#950 recipe — reported
197
+ * the size of the first streaming chunk as the whole turn. Measured on agent
198
+ * a60348a01ca982b4c's own transcript (2026-07-31): keep-first yields 14/22,
199
+ * keep-last 14/3185 — token_output understated ~145x.
200
+ *
201
+ * Corpus measurement (re-measured 2026-07-31; the first published numbers did
202
+ * not reproduce from the scope they named). Group every assistant `usage` block
203
+ * by (file, requestId) over this repo's own subagent transcripts and compare the
204
+ * first block of each group against the last:
205
+ *
206
+ * d="$HOME/.claude/projects/$(pwd | tr '/.' '-')" node -e '
207
+ * const fs=require("node:fs"),{execSync}=require("node:child_process");
208
+ * const F=execSync(`find ${process.env.d} -path "*subagents*" -name "agent-*.jsonl"`,
209
+ * {maxBuffer:1<<30}).toString().trim().split("\n");
210
+ * const g=new Map();
211
+ * for(const f of F)for(const l of fs.readFileSync(f,"utf8").split("\n")){
212
+ * let o;try{o=JSON.parse(l)}catch{continue}
213
+ * if(o?.type!=="assistant"||!o.message?.usage||!o.requestId)continue;
214
+ * const k=`${f} ${o.requestId}`;(g.get(k)||g.set(k,[]).get(k)).push(o.message.usage);}
215
+ * let a=0,b=0,m=0,dec=0,iv=0;
216
+ * for(const u of g.values()){const o=u.map(x=>x.output_tokens|0);
217
+ * a+=o[0];b+=o.at(-1);if(o.at(-1)===Math.max(...o))m++;
218
+ * for(let i=1;i<o.length;i++)if(o[i]<o[i-1]){dec++;break}
219
+ * if(new Set(u.map(x=>x.input_tokens|0)).size>1)iv++;}
220
+ * console.log(JSON.stringify({files:F.length,groups:g.size,first:a,last:b,
221
+ * ratio:+(b/a).toFixed(2),lastIsMax:m,decreasing:dec,inputVaries:iv}));'
222
+ *
223
+ * Output on 2026-07-31:
224
+ *
225
+ * {"files":533,"groups":13255,"first":1180170,"last":11746733,
226
+ * "ratio":9.95,"lastIsMax":13255,"decreasing":0,"inputVaries":0}
227
+ *
228
+ * So keep-first understated output ~10x corpus-wide, not the ~5.3x first
229
+ * claimed. These are absolute counts over a LIVE, growing corpus — re-running
230
+ * yields larger numbers (four calls over one session read 13,230 / 13,233 /
231
+ * 13,254 / 13,255 groups). The reproducible parts are the ratio (9.95-9.96
232
+ * across all four) and the `lastIsMax` fraction, not the totals.
233
+ *
234
+ * Why NOT a per-field `Math.max`, which would also survive a regressing
235
+ * snapshot: measured and rejected. In the run above the last block IS the max in
236
+ * 13,255 of 13,255 groups, no output sequence ever decreases, and no final block
237
+ * ever omits a field an earlier one carried — so max buys zero accuracy here.
238
+ *
239
+ * That result is CORPUS-SCOPED, and deliberately stated as such: pointing the
240
+ * same command at all of `$HOME/.claude/projects` (12,442 files / 267,735
241
+ * groups, same day) gives `lastIsMax: 267733` — two real counterexamples — plus
242
+ * 15 groups whose output dips mid-sequence before recovering and 2 whose
243
+ * `input_tokens` vary. "The last block is always the max" is an observation about
244
+ * this repo's transcripts, NOT an invariant of the format. Do not read it as a
245
+ * licence to treat `Math.max` as equivalent.
246
+ *
247
+ * What settles the choice is not the tie but block atomicity: a per-field max
248
+ * can take `input_tokens` from one snapshot and `output_tokens` from another and
249
+ * emit a pair that appears in no record, and it hard-codes "usage only ever
250
+ * grows" for the sibling fields (`cache_*`, `iterations`) a future reader may
251
+ * fold in. A usage block is an atomic statement of one response's state; the
252
+ * last one is the producer's final word, so it is kept whole. That argument
253
+ * holds on both corpora, which is why the counterexamples above do not reopen it.
254
+ *
255
+ * token_input is unaffected by this change and must stay so: it is constant
256
+ * across every snapshot of a request (`inputVaries: 0` above), which is why the
257
+ * pre-#950 defect was output-only.
90
258
  *
91
259
  * token_input is the raw `input_tokens` sum (NOT folded with cache_* fields) to
92
260
  * match the existing OTel `gen_ai.usage.input_tokens` semantic.
93
261
  *
94
262
  * Per-turn clamping (#624): a single poisoned usage value (negative, NaN, or a
95
- * non-integer such as 10.5) MUST NOT discard the otherwise-good turns. Each turn's
96
- * contribution is added ONLY when it is a non-negative integer; an invalid value
97
- * is skipped (its turn still counts toward `counted` so a transcript of mixed
98
- * good/bad turns yields the sum of the GOOD turns, never null). Without per-turn
99
- * clamping, a final `Number.isInteger(sum) && sum >= 0` aggregate check would
100
- * nuke the whole input sum to null on one bad value.
101
- *
102
- * Dedup assumption (#624): the streaming harness always supplies a `requestId` on
103
- * assistant turns, so dedup keys on it. A turn with NO requestId is counted
104
- * individually (no dedup) this is the documented forward-compat fallback, not a
105
- * double-count, because the harness never omits requestId in practice.
263
+ * non-integer such as 10.5) MUST NOT discard the otherwise-good turns. Each kept
264
+ * block's contribution is added ONLY when it is a non-negative integer; an
265
+ * invalid value is skipped (its block still counts as a kept turn, so a
266
+ * transcript of mixed good/bad turns yields the sum of the GOOD turns, never
267
+ * null). Without per-turn clamping, a final `Number.isInteger(sum) && sum >= 0`
268
+ * aggregate check would nuke the whole input sum to null on one bad value.
269
+ *
270
+ * Dedup fallback (#624 assumption refuted 2026-07-31, re-keyed 2026-08-03 / #963).
271
+ * Dedup keys on `requestId`. A turn without one used to be counted individually,
272
+ * documented as an inert forward-compat fallback rather than a double-count,
273
+ * "because the harness never omits requestId in practice". That premise is false
274
+ * and the fallback was a live double-count; it now keys on `message.id` with the
275
+ * same last-wins recipe, and only a block carrying NEITHER key is counted
276
+ * individually.
277
+ *
278
+ * Measured over this repo's own subagent transcripts (2026-08-03, command below):
279
+ * 4,684 of 35,923 assistant usage blocks (13.0%), across 32 of 524 files, carry
280
+ * usage with NO requestId — every one from a non-Anthropic model routed through
281
+ * the same harness (`gpt-5.6-sol` 4,680, `<synthetic>` 4). They are streaming
282
+ * snapshots, not distinct turns: those 4,684 blocks collapse to 876 distinct
283
+ * `message.id` values, so summing them individually inflated their contribution
284
+ * 2.55× on input and 1.84× on output.
285
+ *
286
+ * d="$HOME/.claude/projects/$(pwd | tr '/.' '-')" node -e '
287
+ * const fs=require("node:fs"),{execSync}=require("node:child_process");
288
+ * const F=execSync(`find ${process.env.d} -path "*subagents*" -name "agent-*.jsonl"`,
289
+ * {maxBuffer:1<<30}).toString().trim().split("\n").filter(Boolean);
290
+ * const add=(a,u)=>{const i=u.input_tokens,o=u.output_tokens;
291
+ * if(Number.isInteger(i)&&i>=0)a.in+=i;if(Number.isInteger(o)&&o>=0)a.out+=o;};
292
+ * let blocks=0,unkeyed=0,asIs={in:0,out:0},byMid={in:0,out:0},ids=new Set(),files=new Set();
293
+ * const models=new Map();
294
+ * for(const f of F){const m=new Map();
295
+ * for(const l of fs.readFileSync(f,"utf8").split("\n")){
296
+ * const t=l.trim();if(!t)continue;let o;try{o=JSON.parse(t)}catch{continue}
297
+ * if(o?.type!=="assistant"||!o.message?.usage)continue;
298
+ * blocks++;if(o.requestId)continue;
299
+ * unkeyed++;files.add(f);add(asIs,o.message.usage);
300
+ * models.set(o.message.model,(models.get(o.message.model)||0)+1);
301
+ * m.set(o.message.id,o.message.usage);}
302
+ * for(const [k,u] of m){ids.add(f+k);add(byMid,u);}}
303
+ * console.log(JSON.stringify({blocks,unkeyed,files:files.size,ofFiles:F.length,
304
+ * messageIds:ids.size,asIs,byMessageId:byMid,
305
+ * inflation_input:+(asIs.in/byMid.in).toFixed(2),
306
+ * inflation_output:+(asIs.out/byMid.out).toFixed(2),models:[...models]}));'
307
+ *
308
+ * {"blocks":35923,"unkeyed":4684,"files":32,"ofFiles":524,"messageIds":876,
309
+ * "asIs":{"in":15320791,"out":1241596},"byMessageId":{"in":6011144,"out":674105},
310
+ * "inflation_input":2.55,"inflation_output":1.84,
311
+ * "models":[["gpt-5.6-sol",4680],["<synthetic>",4]]}
312
+ *
313
+ * Same live-corpus caveat as every block above, and it already bit once: the
314
+ * numbers first published here read `blocks: 37771 / ofFiles: 552` on 2026-07-31
315
+ * and 35,923 / 524 on 2026-08-03 — transcripts age out of `~/.claude/projects`,
316
+ * so the totals SHRINK as well as grow and a figure left unrestated goes stale
317
+ * while still reading as current. The unkeyed set was byte-identical across both
318
+ * runs (4,684 blocks / 876 message ids / 32 files), so the defect and its
319
+ * inflation ratios are the durable part; `blocks` and `ofFiles` are not.
320
+ *
321
+ * THIS REPO UNDERSTATES THE DEFECT BY ~3.5×. Corpus-wide — every project dir
322
+ * under `~/.claude/projects`, 11,251 subagent transcripts, measured 2026-08-03 —
323
+ * 430,962 of 1,005,530 usage blocks (42.9%) are unkeyed, collapsing to 86,805
324
+ * `message.id` groups: input inflation 8.93×, output 1.70×. Model split of the
325
+ * unkeyed set: gpt-5.6-luna 370,404, gpt-5.6-sol 112,939, gpt-5.6-terra 13,992,
326
+ * `<synthetic>` 648. (Two independent runs an hour apart read 11,225 and 11,251
327
+ * files but the same 430,962 unkeyed blocks — again, ratios durable, totals not.)
328
+ *
329
+ * Why key on PRESENCE, never on `message.model`: `<synthetic>` appears on BOTH
330
+ * sides of the split (648 unkeyed, 293 keyed), so no model name partitions the
331
+ * two branches. Zero `claude-*` blocks are unkeyed, but that is an observation
332
+ * about today's routing, not a rule to branch on.
333
+ *
334
+ * Why last-wins on `message.id` is LOSSLESS, measured rather than assumed
335
+ * (2026-08-03, 86,805 groups / 430,962 blocks corpus-wide):
336
+ * - 18,387 groups contain ≥2 blocks with a non-null `stop_reason`, and in
337
+ * every single one of them all finalized usage objects are byte-identical
338
+ * (`JSON.stringify` set size 1 in all 18,387). Σ tokens lost to last-wins:
339
+ * 0 input, 0 output. A `message.id` group is one turn, not two.
340
+ * - The 8.2% of groups whose `input_tokens` look non-monotonic are cache
341
+ * re-accounting INSIDE one turn, not two turns: a partial snapshot reports an
342
+ * undifferentiated total, then the finalized block splits
343
+ * `cache_read_input_tokens` out, so `input_tokens` legitimately drops.
344
+ * (Verified on five example lines 0.6 s apart on one `parentUuid` chain.)
345
+ * - 7,556 groups never finalize (interrupted turns); last-wins keeps the last
346
+ * partial, which is the honest available value.
347
+ * - Cross-check on the KEYED half: re-keying requestId-bearing blocks on
348
+ * `message.id` instead changes totals by +0.013% input / +0.001% output, with
349
+ * `midMapsToMultipleRequestIds: 0`. The two keys are interchangeable, which
350
+ * is why the fallback is a key swap and not a second recipe.
351
+ *
352
+ * The `unkeyable` branch (NEITHER key) is retained and is NOT a merge bucket.
353
+ * Zero blocks in the entire corpus lack `message.id` (`noMidUnkeyed: 0`,
354
+ * `noMidKeyed: 0`, `midTypes: [["string", 4684]]`), so it is untested against
355
+ * real data — stated here as an untested forward-compat path rather than as
356
+ * another confident claim about the harness. The guard that keeps it honest is
357
+ * the `typeof messageId === 'string'` check in the loop below: without it every
358
+ * keyless block collapses onto the single key `undefined`, which is a silent
359
+ * UNDER-count of unrelated turns. The measurement one-liner above has exactly
360
+ * that shape (`m.set(o.message.id, …)`) — harmless there because zero blocks are
361
+ * keyless, fatal if copied into production.
362
+ *
363
+ * Observed effect, end-to-end through this hook over the 32 affected real
364
+ * transcripts (each copied into a sandbox at the harness-shaped subagent path,
365
+ * hook spawned, ledger record read back):
366
+ *
367
+ * before #963: {"affectedFiles":32,"hookTotalInput":15320791,"hookTotalOutput":1241596}
368
+ * after #963: {"affectedFiles":32,"hookTotalInput":6011144,"hookTotalOutput":674105}
369
+ *
370
+ * FORWARD-ONLY — already-written history stays inflated. Nothing recomputes it:
371
+ * 2,126 records in `.orchestrator/metrics/subagents.jsonl` and 12 session totals
372
+ * in `sessions.jsonl` were written by the pre-#963 recipe, and roughly 39 of
373
+ * 3,015 stop records in this repo (~1.3%) came from an affected transcript. No
374
+ * rewrite is attempted — the ledger is append-only and the transcripts that
375
+ * produced the oldest records have since aged out, so a rewrite would be a
376
+ * reconstruction, not a correction. Consumers comparing across the 2026-08-03
377
+ * boundary must treat it as a series break.
106
378
  *
107
379
  * Partial-usage assumption (#624): a turn carrying `input_tokens` but no
108
380
  * `output_tokens` (or vice-versa) contributes 0 to the absent side — NOT null —
@@ -130,10 +402,24 @@ function extractTranscriptUsage(transcriptPath) {
130
402
  const raw = fs.readFileSync(transcriptPath, 'utf8');
131
403
  const lines = raw.split('\n');
132
404
 
133
- const seen = new Set();
134
- let tokenInput = 0;
135
- let tokenOutput = 0;
136
- let counted = 0;
405
+ /** requestId -> the LAST usage block seen for it (#950). */
406
+ const byRequestId = new Map();
407
+ /**
408
+ * message.id -> the LAST usage block seen for it, for blocks carrying NO
409
+ * requestId (#963). Same last-wins recipe, a different key: on
410
+ * non-Anthropic-model transcripts the streaming snapshots carry `message.id`
411
+ * and no requestId, so this is the identity that collapses them. See the
412
+ * Dedup-fallback block above for the measurement and the losslessness proof.
413
+ */
414
+ const byMessageId = new Map();
415
+ /**
416
+ * Usage blocks carrying NEITHER key. Their identity is unknown, so each is
417
+ * counted as its own turn — absent is not zero and not "same turn as the
418
+ * next keyless block". Zero blocks in the measured corpus land here; it is a
419
+ * forward-compat path, and this time that is stated as an untested branch
420
+ * rather than as an established fact about the harness.
421
+ */
422
+ const unkeyable = [];
137
423
 
138
424
  for (const line of lines) {
139
425
  const trimmed = line.trim();
@@ -148,13 +434,37 @@ function extractTranscriptUsage(transcriptPath) {
148
434
  const usage = obj.message?.usage;
149
435
  if (!usage || typeof usage !== 'object') continue;
150
436
 
151
- // Dedup by requestId — keep the first usage block per id.
437
+ // Dedup by requestId — keep the LAST usage block per id (#950). The
438
+ // repeats are cumulative streaming snapshots, so overwriting is what
439
+ // promotes the partial first snapshot to the response's real total.
152
440
  const requestId = obj.requestId;
153
441
  if (typeof requestId === 'string' && requestId) {
154
- if (seen.has(requestId)) continue;
155
- seen.add(requestId);
442
+ byRequestId.set(requestId, usage);
443
+ continue;
444
+ }
445
+
446
+ // No requestId — dedup on `message.id` instead, same last-wins recipe
447
+ // (#963). The guard mirrors the requestId branch above ON PURPOSE: a bare
448
+ // `byMessageId.set(obj.message?.id, usage)` maps EVERY keyless block to the
449
+ // single key `undefined`, silently merging unrelated turns into one and
450
+ // under-reporting instead of over-reporting. Only a non-empty string is a
451
+ // usable identity; anything else falls through to the individual count.
452
+ const messageId = obj.message?.id;
453
+ if (typeof messageId === 'string' && messageId) {
454
+ byMessageId.set(messageId, usage);
455
+ } else {
456
+ unkeyable.push(usage);
156
457
  }
458
+ }
459
+
460
+ const kept = [...byRequestId.values(), ...byMessageId.values(), ...unkeyable];
157
461
 
462
+ // No assistant turns with usage → leave fields null (forward-compat).
463
+ if (kept.length === 0) return nullResult;
464
+
465
+ let tokenInput = 0;
466
+ let tokenOutput = 0;
467
+ for (const usage of kept) {
158
468
  // Per-turn clamp (#624): add a turn's value ONLY when it is a non-negative
159
469
  // integer. A poisoned value (negative, NaN, float like 10.5) is skipped so
160
470
  // the good turns survive. An absent side contributes 0, not null.
@@ -162,12 +472,8 @@ function extractTranscriptUsage(transcriptPath) {
162
472
  const outTok = usage.output_tokens;
163
473
  if (Number.isInteger(inTok) && inTok >= 0) tokenInput += inTok;
164
474
  if (Number.isInteger(outTok) && outTok >= 0) tokenOutput += outTok;
165
- counted += 1;
166
475
  }
167
476
 
168
- // No assistant turns with usage → leave fields null (forward-compat).
169
- if (counted === 0) return nullResult;
170
-
171
477
  // The aggregate is guaranteed a non-negative integer by per-turn clamping
172
478
  // above (Σ of non-negative integers), so emit it directly.
173
479
  return {
@@ -179,6 +485,154 @@ function extractTranscriptUsage(transcriptPath) {
179
485
  }
180
486
  }
181
487
 
488
+ /**
489
+ * Derive the path of the subagent's OWN transcript from the parent transcript
490
+ * path the harness sends on stdin (#949).
491
+ *
492
+ * The `transcript_path` in a SubagentStop payload is the PARENT session
493
+ * transcript — `<transcriptDir>/<parent_session_id>.jsonl`. Alongside it the
494
+ * harness maintains one file per real Task subagent at
495
+ * `<transcriptDir>/<parent_session_id>/subagents/agent-<agent_id>.jsonl`
496
+ * (plus an `agent-<agent_id>.meta.json` carrying `agentType`). Verified against
497
+ * live transcripts on 2026-07-31.
498
+ *
499
+ * Returns null — never the parent path — when the derivation is not possible.
500
+ * Falling back to `transcript_path` IS the #949 defect: it makes every stop
501
+ * inherit the parent's running token totals, so the caller must record null
502
+ * instead (an honest absence).
503
+ *
504
+ * The agent_id is charset-restricted before it is interpolated into a path.
505
+ * Real ids are hex-ish tokens (e.g. `a60348a01ca982b4c`); anything else is
506
+ * rejected rather than sanitised, so no payload value can traverse out of the
507
+ * `subagents/` directory.
508
+ *
509
+ * @param {string|undefined|null} parentTranscriptPath — stdin `transcript_path`
510
+ * @param {string} agentId — the stopping agent's id
511
+ * @returns {string|null} absolute candidate path, or null when underivable
512
+ */
513
+ function resolveSubagentTranscriptPath(parentTranscriptPath, agentId) {
514
+ if (typeof parentTranscriptPath !== 'string' || !parentTranscriptPath.trim()) return null;
515
+ if (typeof agentId !== 'string' || !/^[A-Za-z0-9_-]+$/.test(agentId)) return null;
516
+ // 'unknown' is the no-usable-id fallback — it names no file.
517
+ if (agentId === 'unknown') return null;
518
+
519
+ const dir = path.dirname(parentTranscriptPath);
520
+ const base = path.basename(parentTranscriptPath).replace(/\.jsonl$/i, '');
521
+ if (!base || base === '.' || base === '..') return null;
522
+
523
+ return path.join(dir, base, 'subagents', `agent-${agentId}.jsonl`);
524
+ }
525
+
526
+ /**
527
+ * Scan the tail of the ledger backwards for the most recent 'start' record with
528
+ * the given agent_id and return its timestamp in epoch-ms (#917).
529
+ *
530
+ * Reads only the last START_JOIN_TAIL_BYTES rather than the whole file — see that
531
+ * constant for the measured sizing rationale. Scanning backwards means the FIRST
532
+ * hit is the most recent start, which is the correct one when an agent_id is
533
+ * reused across sessions (measured 2026-07-30: 22 of 1420 distinct start ids
534
+ * appeared more than once).
535
+ *
536
+ * NEVER throws. Any failure (missing file, unreadable, no match, unparseable
537
+ * timestamp) yields null so the caller falls back to an honest "unknown".
538
+ *
539
+ * @param {string} filePath — ledger path
540
+ * @param {string} agentId — the stopping agent's id
541
+ * @returns {number|null} epoch-ms of the matching start, or null
542
+ */
543
+ function findStartTimestampMs(filePath, agentId) {
544
+ let fd;
545
+ try {
546
+ if (typeof agentId !== 'string' || !agentId.trim()) return null;
547
+ if (!fs.existsSync(filePath)) return null;
548
+
549
+ const { size } = fs.statSync(filePath);
550
+ if (size === 0) return null;
551
+
552
+ const readLen = Math.min(size, START_JOIN_TAIL_BYTES);
553
+ const from = size - readLen;
554
+ const buf = Buffer.allocUnsafe(readLen);
555
+ fd = fs.openSync(filePath, 'r');
556
+ fs.readSync(fd, buf, 0, readLen, from);
557
+
558
+ const lines = buf.toString('utf8').split('\n');
559
+ // When the window does not cover the whole file, the first element is a
560
+ // record sliced mid-line (possibly mid-UTF-8-sequence). Drop it rather than
561
+ // feed a corrupt fragment to JSON.parse.
562
+ if (from > 0) lines.shift();
563
+
564
+ for (let i = lines.length - 1; i >= 0; i--) {
565
+ const line = lines[i].trim();
566
+ if (!line) continue;
567
+ // Cheap pre-filter — skip JSON.parse for the ~99% of lines that cannot match.
568
+ if (!line.includes(agentId)) continue;
569
+ let obj;
570
+ try {
571
+ obj = JSON.parse(line);
572
+ } catch {
573
+ continue; // tolerate a torn/malformed line, keep scanning
574
+ }
575
+ if (!obj || obj.event !== 'start' || obj.agent_id !== agentId) continue;
576
+ const ms = Date.parse(obj.timestamp);
577
+ return Number.isFinite(ms) ? ms : null;
578
+ }
579
+ return null;
580
+ } catch {
581
+ return null;
582
+ } finally {
583
+ if (fd !== undefined) {
584
+ try { fs.closeSync(fd); } catch { /* ignore */ }
585
+ }
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Resolve duration_ms for a stop event (#917).
591
+ *
592
+ * Precedence:
593
+ * 1. A usable harness-supplied duration_ms — authoritative when present.
594
+ * In practice Claude Code's SubagentStop payload does NOT carry this field,
595
+ * which is exactly why every pre-#917 record read 0.
596
+ * 2. Wall-clock join: now − the precomputed timestamp of this agent's own
597
+ * start record (`startedAtMs`, resolved ONCE by the caller — it also feeds
598
+ * the #939 `start_record_found` discriminator).
599
+ * 3. null — the honest value when the duration is genuinely unknowable.
600
+ *
601
+ * Why null and not 0: a stop that took zero milliseconds never happened, so a 0
602
+ * is indistinguishable from "we never measured". The no-start branch is the
603
+ * DOMINANT path, not a corner case, and worsening (#939, measured 2026-07-31
604
+ * over the live ledger):
605
+ * - lifetime mean: 1494 of 2923 stop records (51.1%) have no start record
606
+ * with their agent_id anywhere in the ledger;
607
+ * - running traffic is far worse — the lifetime mean flatters it:
608
+ * since 2026-07-20: 894 stops, 157 matched (17.6%)
609
+ * since 2026-07-29: 408 stops, 62 matched (15.2%)
610
+ * since 2026-07-30T17:41: 122 stops, 8 matched ( 6.6%)
611
+ * i.e. ~93% of CURRENT stop traffic is orphaned (the harness phantom-stop
612
+ * class — see the file header). Writing 0 there would keep fabricating
613
+ * exactly the value #917 removed.
614
+ *
615
+ * @param {object} input — raw stdin payload
616
+ * @param {number|null} startedAtMs — epoch-ms of this agent's own start record,
617
+ * or null when no start record is recoverable (phantom stop, 'unknown' id,
618
+ * or start outside the tail window)
619
+ * @returns {number|null} duration in ms, or null when unknown
620
+ */
621
+ function resolveDurationMs(input, startedAtMs) {
622
+ const supplied = input.duration_ms;
623
+ if (typeof supplied === 'number' && Number.isFinite(supplied) && supplied > 0) {
624
+ return Math.round(supplied);
625
+ }
626
+
627
+ if (startedAtMs === null) return null;
628
+
629
+ const elapsed = Date.now() - startedAtMs;
630
+ // A non-positive elapsed means a clock jump or a start recorded in the future —
631
+ // not a measurement. Report unknown rather than invent a plausible number.
632
+ if (!Number.isFinite(elapsed) || elapsed <= 0) return null;
633
+ return Math.round(elapsed);
634
+ }
635
+
182
636
  // ---------------------------------------------------------------------------
183
637
  // Main
184
638
  // ---------------------------------------------------------------------------
@@ -214,16 +668,52 @@ async function main() {
214
668
  ...(parentSessionId !== null ? { parent_session_id: parentSessionId } : {}),
215
669
  };
216
670
 
671
+ // #939 forensic breadcrumb: registration (hooks.json) sends exactly
672
+ // SubagentStart|SubagentStop to this script, so any OTHER (or missing) event
673
+ // name is a payload anomaly. It is still written as 'stop' for
674
+ // backwards-compat, but the raw name is preserved verbatim so the "missing
675
+ // hook_event_name silently defaults to stop" hypothesis stays decidable from
676
+ // the ledger instead of requiring another inference pass. Never stamped on
677
+ // well-formed payloads — the field's PRESENCE is the anomaly signal.
678
+ if (eventName !== 'SubagentStart' && eventName !== 'SubagentStop') {
679
+ record.hook_event_name = typeof eventName === 'string' ? eventName : null;
680
+ }
681
+
217
682
  if (event === 'stop') {
218
- // duration_ms is required for stop events; default to 0 if harness omits it.
219
- record.duration_ms =
220
- typeof input.duration_ms === 'number' && input.duration_ms >= 0
221
- ? Math.round(input.duration_ms)
222
- : 0;
223
-
224
- // Tokens are NOT sent on stdin (#624) recover them from the transcript's
225
- // per-assistant-turn usage blocks, deduped by requestId. Any failure null.
226
- const { tokenInput, tokenOutput } = extractTranscriptUsage(input.transcript_path);
683
+ // #939 orphan guard: resolve the start-join ONCE it feeds both the
684
+ // duration measurement and the explicit orphan discriminator below.
685
+ // 'unknown' is the agent-id fallback for a payload with no usable id —
686
+ // joining on it would collide across unrelated agents, so never scan for it.
687
+ const startedAtMs = agentId === 'unknown' ? null : findStartTimestampMs(JSONL_PATH, agentId);
688
+
689
+ // duration_ms (#917): prefer the harness value, else join backwards to this
690
+ // agent's own start record, else null. Never 0 see resolveDurationMs().
691
+ record.duration_ms = resolveDurationMs(input, startedAtMs);
692
+
693
+ // start_record_found (#939): makes the harness phantom-stop class (see file
694
+ // header) mechanically filterable at read time. false = no start record for
695
+ // this agent_id in the tail window — for ~93% of current stop traffic this
696
+ // is the phantom class, whose token fields describe the PARENT transcript,
697
+ // not a real subagent.
698
+ record.start_record_found = startedAtMs !== null;
699
+
700
+ // subagent_transcript_found (#949): does this stop have a real subagent
701
+ // transcript of its own? Measured 25/25 on typed stops and 0/343 on
702
+ // phantoms, so it is the sharpest phantom discriminator available — and it
703
+ // is exactly the provenance flag a reader needs before trusting token_*.
704
+ const subagentTranscriptPath = resolveSubagentTranscriptPath(input.transcript_path, agentId);
705
+ const subagentTranscriptFound =
706
+ subagentTranscriptPath !== null && fs.existsSync(subagentTranscriptPath);
707
+ record.subagent_transcript_found = subagentTranscriptFound;
708
+
709
+ // Tokens are NOT sent on stdin (#624) — recover them from the SUBAGENT's own
710
+ // transcript, deduped by requestId. Any failure → null. There is deliberately
711
+ // NO fallback to input.transcript_path: that path is the parent session
712
+ // transcript, and reading it is the #949 defect (every stop inherited the
713
+ // parent's running totals). A phantom stop gets null — the honest value.
714
+ const { tokenInput, tokenOutput } = subagentTranscriptFound
715
+ ? extractTranscriptUsage(subagentTranscriptPath)
716
+ : { tokenInput: null, tokenOutput: null };
227
717
  if (tokenInput !== null) record.token_input = tokenInput;
228
718
  if (tokenOutput !== null) record.token_output = tokenOutput;
229
719