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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- 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
|
|
17
|
-
*
|
|
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
|
|
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
|
|
89
|
-
*
|
|
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
|
|
96
|
-
* contribution is added ONLY when it is a non-negative integer; an
|
|
97
|
-
* is skipped (its
|
|
98
|
-
* good/bad turns yields the sum of the GOOD turns, never
|
|
99
|
-
* clamping, a final `Number.isInteger(sum) && sum >= 0`
|
|
100
|
-
* nuke the whole input sum to null on one bad value.
|
|
101
|
-
*
|
|
102
|
-
* Dedup
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
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
|
|