From f948f9c24b322cdea11c3531b306a272e28a8df7 Mon Sep 17 00:00:00 2001 From: shahinyanm Date: Thu, 4 Jun 2026 11:25:25 +0400 Subject: [PATCH] v0.40.0: capture task telemetry via SubagentStop (confirmed root cause) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the multi-release "0 task events" mystery with a clean, in-session empirical proof — not another guess. The proof (run this session, fresh 0.39.3, async already reverted): 1. Captured a cutoff timestamp. 2. Dispatched a real subagent via the Agent tool (32947 tokens, returned). Result: ZERO events after cutoff. 3. In the SAME session, triggered a Read-deny on a large file. Result: exactly 1 `denied` event written after its cutoff. 4. An unbounded recursive search was blocked by hook-pre-bash (hooks demonstrably live this session). So: appendEvent + the hook runtime are healthy (Read-deny wrote), but PostToolUse:Task produces nothing on a real dispatch. That is the entire "0 task events" history explained — the parent-side PostToolUse hook simply does not fire for the dispatch tool on this Claude Code. async (v0.35.0) was never the cause; the April zeros predate it. Releases v0.33.1 / v0.39.2 chased the wrong layer. The fix — SubagentStop: Claude Code's SubagentStop is the canonical subagent-completion event (bundle schema: agent_id, agent_type, agent_transcript_path, last_assistant_message). It fires once per subagent by definition. New hook-subagent-stop writes the `event:"task"` record from it: - subagent_type = agent_type (the adoption signal v0.30 needs: was a subagent used, tp-* or not) - estTokens = best-effort sum from the transcript usage (agent_transcript_path), 0 on any failure - code:"subagent_stop" marks the source so a future revival of PostToolUse:Task can be deduped, not double-counted Routing-miss detection (general-purpose where tp-* fit) stays in the PreToolUse:Task diagnostic, which has the task description SubagentStop lacks. Wiring: - src/hooks/subagent-stop.ts — buildSubagentTaskEvent + tokensFromTranscript (both pure / injectable, fully tested). - index.ts case hook-subagent-stop (synchronous — writes telemetry). - hooks/hooks.json + installer.ts: SubagentStop section, installed + uninstalled idempotently alongside the others. - typo-guard registers hook-subagent-stop. Verified end-to-end against the built dist: a SubagentStop payload for tp-pr-reviewer writes event:"task" subagent_type=tp-pr-reviewer code=subagent_stop. PostToolUse:Task is KEPT (harmless; carries tokens if CC ever fixes it; code-marked events let stats dedup). Tests: 1328/1328 pass (+9 subagent-stop + typo-guard). --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- agents/tp-api-surface-tracker.md | 2 +- agents/tp-audit-scanner.md | 2 +- agents/tp-commit-writer.md | 2 +- agents/tp-context-engineer.md | 2 +- agents/tp-dead-code-finder.md | 2 +- agents/tp-debugger.md | 2 +- agents/tp-dep-health.md | 2 +- agents/tp-doc-writer.md | 2 +- agents/tp-history-explorer.md | 2 +- agents/tp-impact-analyzer.md | 2 +- agents/tp-incident-timeline.md | 2 +- agents/tp-incremental-builder.md | 2 +- agents/tp-migration-scout.md | 2 +- agents/tp-onboard.md | 2 +- agents/tp-performance-profiler.md | 2 +- agents/tp-pr-reviewer.md | 2 +- agents/tp-refactor-planner.md | 2 +- agents/tp-review-impact.md | 2 +- agents/tp-run.md | 2 +- agents/tp-session-restorer.md | 2 +- agents/tp-ship-coordinator.md | 2 +- agents/tp-spec-writer.md | 2 +- agents/tp-test-coverage-gapper.md | 2 +- agents/tp-test-triage.md | 2 +- agents/tp-test-writer.md | 2 +- hooks/hooks.json | 10 +++ package.json | 2 +- src/cli/typo-guard.ts | 2 + src/hooks/installer.ts | 48 ++++++++++-- src/hooks/subagent-stop.ts | 123 ++++++++++++++++++++++++++++++ src/index.ts | 21 +++++ tests/cli/typo-guard.test.ts | 1 + tests/hooks/subagent-stop.test.ts | 101 ++++++++++++++++++++++++ 35 files changed, 330 insertions(+), 34 deletions(-) create mode 100644 src/hooks/subagent-stop.ts create mode 100644 tests/hooks/subagent-stop.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c0ccbe4..e487854 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Token Pilot \u2014 save 60-90% tokens when AI reads code", - "version": "0.39.3" + "version": "0.40.0" }, "plugins": [ { "name": "token-pilot", "source": "./", "description": "Reduces token consumption by 60-90% via AST-aware lazy file reading, structural symbol navigation, and cross-session tool-usage analytics. 22 MCP tools + 19 subagents + budget watchdog hooks.", - "version": "0.39.3", + "version": "0.40.0", "author": { "name": "Digital-Threads" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f5b80bb..285ce43 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.39.3", + "version": "0.40.0", "description": "Saves 60-90% tokens on AI code reading. AST-aware lazy reads, symbol navigation, find_usages, structural git diff/log, edit-safety guard, Task-routing matcher, cross-session telemetry (errors + diagnostics), 25 tp-* subagents tiered to haiku/sonnet/opus with budget watchdog.", "author": { "name": "Digital-Threads", diff --git a/agents/tp-api-surface-tracker.md b/agents/tp-api-surface-tracker.md index c460a83..ffe4eb3 100644 --- a/agents/tp-api-surface-tracker.md +++ b/agents/tp-api-surface-tracker.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__read_symbol - Bash model: haiku -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: dd184501203fa7f3c73f419c4ffbe33c4be75400cb64a7a51733a3fe23f6e085 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-audit-scanner.md b/agents/tp-audit-scanner.md index f1e8334..41ed825 100644 --- a/agents/tp-audit-scanner.md +++ b/agents/tp-audit-scanner.md @@ -11,7 +11,7 @@ tools: - Grep - Read model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: d172f600bf32277ea6eb4cbbee4542ddd698a986dcd96997d33930561964569b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-commit-writer.md b/agents/tp-commit-writer.md index 212ec45..8173632 100644 --- a/agents/tp-commit-writer.md +++ b/agents/tp-commit-writer.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__test_summary - mcp__token-pilot__outline - Bash -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: de64a406b5176de19f7422619c7de7949b1f28865f225402c9cea9255f377428 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-context-engineer.md b/agents/tp-context-engineer.md index 3db04e1..412845a 100644 --- a/agents/tp-context-engineer.md +++ b/agents/tp-context-engineer.md @@ -13,7 +13,7 @@ tools: - Edit - Glob model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 68b32af2dacd82ebe52c4eec93edb903d452688274c3065218270627c564d8b0 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dead-code-finder.md b/agents/tp-dead-code-finder.md index 92f68a0..85b1488 100644 --- a/agents/tp-dead-code-finder.md +++ b/agents/tp-dead-code-finder.md @@ -11,7 +11,7 @@ tools: - Grep - Read model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: d9b7f5b7ae6f4ae21305c775361bcab097cc774370a6d976c093571d46d55021 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-debugger.md b/agents/tp-debugger.md index ba16229..f0136b7 100644 --- a/agents/tp-debugger.md +++ b/agents/tp-debugger.md @@ -12,7 +12,7 @@ tools: - Read - Bash model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 052413de8d92377edcde6ae5c823f5378db304baccfa29e8866467f42553a500 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dep-health.md b/agents/tp-dep-health.md index fb8756d..d7f62aa 100644 --- a/agents/tp-dep-health.md +++ b/agents/tp-dep-health.md @@ -9,7 +9,7 @@ tools: - Bash - Read model: haiku -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: e14dc57493d816f8c2e017963e2ef5f66bea50fd0b805a80e8a0d97c968427e7 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-doc-writer.md b/agents/tp-doc-writer.md index 2c2f77f..3640198 100644 --- a/agents/tp-doc-writer.md +++ b/agents/tp-doc-writer.md @@ -13,7 +13,7 @@ tools: - Edit - Glob model: haiku -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 57d741794ab40e31a7ac49c68ea39a9088f5827cdef866ce81bfca1b7c9180cf requiredMcpServers: - "token-pilot" diff --git a/agents/tp-history-explorer.md b/agents/tp-history-explorer.md index 3721394..186d578 100644 --- a/agents/tp-history-explorer.md +++ b/agents/tp-history-explorer.md @@ -10,7 +10,7 @@ tools: - Bash - Read model: haiku -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 7b70fa76a60e3c58a1de4f56c32c0f166424137e203a0cf1c8654e7c9235d904 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-impact-analyzer.md b/agents/tp-impact-analyzer.md index 9f3de50..fb9862e 100644 --- a/agents/tp-impact-analyzer.md +++ b/agents/tp-impact-analyzer.md @@ -12,7 +12,7 @@ tools: - mcp__token-pilot__read_symbols - Read model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 351a987e11eba63852f5431a16d8eb53104f4f689f82fdcc5a2bf4db948ba92f requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incident-timeline.md b/agents/tp-incident-timeline.md index 2d83ba4..b94c6d5 100644 --- a/agents/tp-incident-timeline.md +++ b/agents/tp-incident-timeline.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__read_symbol - Bash model: inherit -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: de5722bfea374eaab096c1ae635c37879e7a91370ee3cd0532f4240be03c91eb requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incremental-builder.md b/agents/tp-incremental-builder.md index 063a458..a929bc0 100644 --- a/agents/tp-incremental-builder.md +++ b/agents/tp-incremental-builder.md @@ -13,7 +13,7 @@ tools: - Edit - Bash model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 375a824d0d847bb5453ec594c7a62ad566ee7e4d92717b0473f771f1a0477c60 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-migration-scout.md b/agents/tp-migration-scout.md index 34b4441..b3f583f 100644 --- a/agents/tp-migration-scout.md +++ b/agents/tp-migration-scout.md @@ -11,7 +11,7 @@ tools: - Grep - Glob model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 0334de1bf99b431b65359637d125cda7c44c6f780eb92c57cc538715b1939536 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-onboard.md b/agents/tp-onboard.md index 4e443a2..e76bd6b 100644 --- a/agents/tp-onboard.md +++ b/agents/tp-onboard.md @@ -10,7 +10,7 @@ tools: - mcp__token-pilot__smart_read - mcp__token-pilot__smart_read_many - mcp__token-pilot__read_section -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 832e95633fbc8e9b0c10f3e540a327d4be062fb4b3f17a6cce6be13f414e2927 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-performance-profiler.md b/agents/tp-performance-profiler.md index ebe7066..dc089b6 100644 --- a/agents/tp-performance-profiler.md +++ b/agents/tp-performance-profiler.md @@ -11,7 +11,7 @@ tools: - Bash - Read model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: b61f06380d80798fa2e49d37bcba0653495bee04dd6bdbc1feff9a75607b0508 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-pr-reviewer.md b/agents/tp-pr-reviewer.md index db579ba..bf5cc77 100644 --- a/agents/tp-pr-reviewer.md +++ b/agents/tp-pr-reviewer.md @@ -11,7 +11,7 @@ tools: - mcp__token-pilot__read_for_edit - Read model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: f83f50d05b4f70285ae7afed2b1a406fc436df56e61a0aedbfb31edc7f2b6e66 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-refactor-planner.md b/agents/tp-refactor-planner.md index 2322aa2..c71ed67 100644 --- a/agents/tp-refactor-planner.md +++ b/agents/tp-refactor-planner.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__outline - mcp__token-pilot__read_symbol model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: c5f6fc122c89e16e5cf774045f92169ee3468555320b898171ba13eca5323550 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-review-impact.md b/agents/tp-review-impact.md index c3a8aff..334b877 100644 --- a/agents/tp-review-impact.md +++ b/agents/tp-review-impact.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__module_info - Bash model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 8ef3c3341cbfed4eb8dd130126a9683edc57e378c92ff0ca764d584fd941c55c requiredMcpServers: - "token-pilot" diff --git a/agents/tp-run.md b/agents/tp-run.md index 6cc4b80..9ea6d36 100644 --- a/agents/tp-run.md +++ b/agents/tp-run.md @@ -16,7 +16,7 @@ tools: - Glob - Bash model: haiku -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 2b08618d34a61f00aafccbda9fed6d83243296dedb83440edbd2d5c28bb6dbc4 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-session-restorer.md b/agents/tp-session-restorer.md index 7b27f6b..06d08fb 100644 --- a/agents/tp-session-restorer.md +++ b/agents/tp-session-restorer.md @@ -9,7 +9,7 @@ tools: - mcp__token-pilot__session_budget - Bash - Read -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 529374ed728f5eed5b758b3be3da65624783c0bf0c1a253d7d661a843eb5f767 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-ship-coordinator.md b/agents/tp-ship-coordinator.md index 60bc246..78181a2 100644 --- a/agents/tp-ship-coordinator.md +++ b/agents/tp-ship-coordinator.md @@ -11,7 +11,7 @@ tools: - Read - Grep model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: a60f6ae110eb3138064bce074e8ba26fa0ce5f4659df1624a9d9d3646803391b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-spec-writer.md b/agents/tp-spec-writer.md index 4f79ae7..0092e8b 100644 --- a/agents/tp-spec-writer.md +++ b/agents/tp-spec-writer.md @@ -9,7 +9,7 @@ tools: - Read - Write model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: c7a4e8b39228fd5158528f389c924c5ff2d98c4b9b05ee0106d54a26c5dc1350 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-coverage-gapper.md b/agents/tp-test-coverage-gapper.md index ecdbab3..a27f4f3 100644 --- a/agents/tp-test-coverage-gapper.md +++ b/agents/tp-test-coverage-gapper.md @@ -10,7 +10,7 @@ tools: - mcp__token-pilot__test_summary - Glob - Grep -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: be81eed53a3720d146cf89e4a14a7a56577633f7c84c234c412ab70d64c05b11 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-triage.md b/agents/tp-test-triage.md index b4541ef..da4a672 100644 --- a/agents/tp-test-triage.md +++ b/agents/tp-test-triage.md @@ -8,7 +8,7 @@ tools: - mcp__token-pilot__find_usages - mcp__token-pilot__read_symbol model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 362ecf4cb03b059421ea26933473700900073dc38b3a7fe271208dfb1ae14f90 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-writer.md b/agents/tp-test-writer.md index 81997ba..14a2dda 100644 --- a/agents/tp-test-writer.md +++ b/agents/tp-test-writer.md @@ -13,7 +13,7 @@ tools: - Edit - Bash model: sonnet -token_pilot_version: "0.39.3" +token_pilot_version: "0.40.0" token_pilot_body_hash: 269f2fe22ff4517c277d3f56ca67d8a5527b93290ab21079a83ba7af22c1b5a9 requiredMcpServers: - "token-pilot" diff --git a/hooks/hooks.json b/hooks/hooks.json index 18835f4..b1738e7 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -96,6 +96,16 @@ } ] } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/dist/index.js hook-subagent-stop" + } + ] + } ] } } diff --git a/package.json b/package.json index 9d8c715..73d662e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.39.3", + "version": "0.40.0", "description": "Save up to 80% tokens when AI reads code — MCP server for token-efficient code navigation, AST-aware structural reading instead of dumping full files into context window", "type": "module", "main": "dist/index.js", diff --git a/src/cli/typo-guard.ts b/src/cli/typo-guard.ts index 83b757a..89e5898 100644 --- a/src/cli/typo-guard.ts +++ b/src/cli/typo-guard.ts @@ -37,6 +37,8 @@ export const KNOWN_COMMANDS = [ // v0.35.0 — one-shot project setup hook (uses Claude Code's // undocumented `once: true` SessionStart flag). "hook-bootstrap", + // v0.40.0 — canonical subagent-completion capture + "hook-subagent-stop", "install-hook", "uninstall-hook", "install-ast-index", diff --git a/src/hooks/installer.ts b/src/hooks/installer.ts index 3a94ac9..cbe1fd1 100644 --- a/src/hooks/installer.ts +++ b/src/hooks/installer.ts @@ -170,13 +170,21 @@ function createHookConfig(options?: HookInstallOptions) { // v0.39.2 — post-task MUST run synchronously. It writes the // `event:"task"` record via appendEvent (mkdir + stat + // appendFile). Under `async: true` Claude Code fires the hook - // detached and may reap the process before those writes flush - // — the suspected cause of persistently zero task events in - // hook-events.jsonl despite subagents being dispatched. - // Telemetry integrity > the ~5ms saved on a non-hot-path hook. + // detached and may reap the process before those writes flush. + // Kept as a secondary path; v0.39.3 probe showed it does not + // fire on current Claude Code (see SubagentStop below). hooks: [hookEntry("hook-post-task", options)], }, ], + // v0.40.0 — SubagentStop is the canonical, reliably-firing + // subagent-completion event. PostToolUse:Task proved non-firing + // for the dispatch tool; SubagentStop is where the task adoption + // signal is actually captured. Synchronous (writes telemetry). + SubagentStop: [ + { + hooks: [hookEntry("hook-subagent-stop", options)], + }, + ], }, }; } @@ -342,6 +350,21 @@ export async function installHook( } } + // v0.40.0 — SubagentStop (canonical subagent-completion capture). + // Installed idempotently, same pattern as SessionStart. + if (Array.isArray((hookConfig.hooks as any).SubagentStop)) { + if (!Array.isArray(settings.hooks.SubagentStop)) { + settings.hooks.SubagentStop = []; + } + const hasSubagentStop = + settings.hooks.SubagentStop.some(isTokenPilotHook); + if (!hasSubagentStop) { + settings.hooks.SubagentStop.push( + ...(hookConfig.hooks as any).SubagentStop, + ); + } + } + await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n"); return { @@ -374,7 +397,13 @@ export async function uninstallHook( const hasPreToolUse = !!settings.hooks?.PreToolUse; const hasSessionStart = !!settings.hooks?.SessionStart; const hasPostToolUse = !!settings.hooks?.PostToolUse; - if (!hasPreToolUse && !hasSessionStart && !hasPostToolUse) { + const hasSubagentStop = !!settings.hooks?.SubagentStop; + if ( + !hasPreToolUse && + !hasSessionStart && + !hasPostToolUse && + !hasSubagentStop + ) { return { removed: false, fatal: false, message: "No hooks to remove." }; } @@ -408,6 +437,15 @@ export async function uninstallHook( } } + if (Array.isArray(settings.hooks?.SubagentStop)) { + settings.hooks.SubagentStop = settings.hooks.SubagentStop.filter( + (h: any) => !isTokenPilotHook(h), + ); + if (settings.hooks.SubagentStop.length === 0) { + delete settings.hooks.SubagentStop; + } + } + if (settings.hooks && Object.keys(settings.hooks).length === 0) { delete settings.hooks; } diff --git a/src/hooks/subagent-stop.ts b/src/hooks/subagent-stop.ts new file mode 100644 index 0000000..bb7ed70 --- /dev/null +++ b/src/hooks/subagent-stop.ts @@ -0,0 +1,123 @@ +/** + * v0.40.0 — SubagentStop task-completion capture. + * + * Why this exists (grounded, not speculative): + * + * PostToolUse:Task was supposed to record one `event:"task"` per + * subagent dispatch. A clean restarted-session probe on v0.39.3 + * proved it does not fire: a real subagent dispatch produced ZERO + * events, while a Read-deny in the SAME session wrote its `denied` + * event fine (so appendEvent + the hook runtime are healthy). That + * is years of "0 task events" explained — the parent-side PostToolUse + * hook simply never lands for the dispatch tool on this Claude Code. + * + * SubagentStop is Claude Code's canonical subagent-completion event + * (confirmed in the 2.1.131/2.1.161 bundle schema: + * literal("SubagentStop"), stop_hook_active, agent_id, + * agent_transcript_path, agent_type, last_assistant_message ). + * It fires once per subagent completion by definition, so it is the + * reliable place to record the adoption signal the whole v0.30 goal + * depends on: WAS a subagent used, and was it a tp-* or not. + * + * Tokens: SubagentStop carries `agent_transcript_path`. We make a + * best-effort read of the transcript's cumulative usage; on any + * failure we record estTokens:0 — the agent_type signal is the + * primary value, tokens are secondary. + * + * Routing-miss detection (was general-purpose picked where a tp-* + * fit?) stays in the PreToolUse:Task diagnostic, which has the task + * description SubagentStop lacks. This hook records the ACTUAL + * completion; pre-task records the ADVICE. + */ + +import { readFileSync } from "node:fs"; +import type { HookEvent } from "../core/event-log.js"; + +export interface SubagentStopInput { + hook_event_name?: string; + agent_id?: string; + agent_type?: string; + agent_transcript_path?: string; + last_assistant_message?: string; + session_id?: string; + parent_agent_id?: string; +} + +/** + * Best-effort token total from a subagent transcript (JSONL of CC + * messages). Sums `usage.output_tokens` across assistant messages, or + * takes a cumulative `usage.total_tokens` when present. Returns 0 on + * any read/parse failure — never throws. + */ +export function tokensFromTranscript(path: string | undefined): number { + if (!path || typeof path !== "string") return 0; + let raw: string; + try { + raw = readFileSync(path, "utf-8"); + } catch { + return 0; + } + let out = 0; + let lastTotal = 0; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + let rec: unknown; + try { + rec = JSON.parse(line); + } catch { + continue; + } + const msg = (rec as { message?: { usage?: Record } }) + .message; + const usage = + msg?.usage ?? (rec as { usage?: Record }).usage; + if (usage && typeof usage === "object") { + const o = usage.output_tokens; + if (typeof o === "number") out += o; + const t = usage.total_tokens; + if (typeof t === "number") lastTotal = t; + } + } + // Prefer summed output tokens; fall back to the last cumulative total. + return out > 0 ? out : lastTotal; +} + +/** + * Build the `event:"task"` record from a SubagentStop payload. Pure — + * no I/O except the optional transcript token read, which the caller + * can pre-resolve for tests via `tokensOverride`. + */ +export function buildSubagentTaskEvent( + input: SubagentStopInput, + now: number, + tokensOverride?: number, +): HookEvent | null { + const agentType = + typeof input.agent_type === "string" ? input.agent_type : ""; + // No agent_type → nothing meaningful to record. + if (!agentType) return null; + + const est = + tokensOverride ?? tokensFromTranscript(input.agent_transcript_path); + + return { + ts: now, + session_id: input.session_id ?? "", + agent_type: input.agent_type ?? null, + agent_id: input.agent_id ?? null, + ...(input.parent_agent_id ? { parent_agent_id: input.parent_agent_id } : {}), + event: "task", + file: "", + lines: 0, + estTokens: est, + summaryTokens: 0, + savedTokens: 0, + subagent_type: agentType, + // SubagentStop has no task description, so no heuristic match here. + // Routing-miss detection lives in the PreToolUse:Task diagnostic. + matched_tp_agent: null, + // Mark the source so a future revival of PostToolUse:Task can be + // deduped in stats rather than double-counted. + code: "subagent_stop", + }; +} diff --git a/src/index.ts b/src/index.ts index c49ab39..0dad958 100644 --- a/src/index.ts +++ b/src/index.ts @@ -397,6 +397,27 @@ export async function main(cliArgs = process.argv.slice(2)): Promise { }); return; } + case "hook-subagent-stop": { + // v0.40.0 — canonical subagent-completion capture. PostToolUse:Task + // proved non-firing for the dispatch tool (clean v0.39.3 probe: + // a real dispatch wrote 0 events while a Read-deny in the same + // session wrote fine). SubagentStop fires once per subagent by + // definition, so this is the reliable source for the task + // adoption signal. Synchronous (writes telemetry — never async). + await runHookEntryPoint({ hook: "hook-subagent-stop" }, async () => { + const stdin = readFileSync(0, "utf-8"); + const input = JSON.parse(stdin); + const { buildSubagentTaskEvent } = await import( + "./hooks/subagent-stop.js" + ); + const ev = buildSubagentTaskEvent(input, Date.now()); + if (ev) { + const { appendEvent } = await import("./core/event-log.js"); + await appendEvent(process.cwd(), ev); + } + }); + return; + } case "hook-session-start": { await runHookEntryPoint({ hook: "hook-session-start" }, async () => { const cfg = await loadConfig(process.cwd()); diff --git a/tests/cli/typo-guard.test.ts b/tests/cli/typo-guard.test.ts index afd185f..9daa075 100644 --- a/tests/cli/typo-guard.test.ts +++ b/tests/cli/typo-guard.test.ts @@ -99,6 +99,7 @@ describe("checkForTypo", () => { "hook-post-task", "hook-session-start", "hook-bootstrap", + "hook-subagent-stop", // user-facing CLI "install-hook", "uninstall-hook", diff --git a/tests/hooks/subagent-stop.test.ts b/tests/hooks/subagent-stop.test.ts new file mode 100644 index 0000000..eb0f684 --- /dev/null +++ b/tests/hooks/subagent-stop.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for v0.40.0 SubagentStop task-completion capture. + * + * buildSubagentTaskEvent is pure (token read injectable); + * tokensFromTranscript is exercised against a tmp JSONL file. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + buildSubagentTaskEvent, + tokensFromTranscript, + type SubagentStopInput, +} from "../../src/hooks/subagent-stop.ts"; + +describe("buildSubagentTaskEvent", () => { + it("builds a task event from agent_type with injected tokens", () => { + const input: SubagentStopInput = { + hook_event_name: "SubagentStop", + agent_id: "a123", + agent_type: "tp-pr-reviewer", + session_id: "s1", + agent_transcript_path: "/nope", + }; + const ev = buildSubagentTaskEvent(input, 1000, 4242); + expect(ev).not.toBeNull(); + expect(ev!.event).toBe("task"); + expect(ev!.subagent_type).toBe("tp-pr-reviewer"); + expect(ev!.agent_id).toBe("a123"); + expect(ev!.estTokens).toBe(4242); + expect(ev!.matched_tp_agent).toBeNull(); + expect(ev!.code).toBe("subagent_stop"); + expect(ev!.ts).toBe(1000); + }); + + it("captures general-purpose dispatches (the adoption miss signal)", () => { + const ev = buildSubagentTaskEvent( + { agent_type: "general-purpose", agent_id: "x" }, + 5, + 0, + ); + expect(ev!.subagent_type).toBe("general-purpose"); + expect(ev!.estTokens).toBe(0); + }); + + it("returns null when agent_type is absent (nothing to record)", () => { + expect(buildSubagentTaskEvent({ agent_id: "x" }, 1, 0)).toBeNull(); + expect(buildSubagentTaskEvent({}, 1, 0)).toBeNull(); + }); + + it("carries parent_agent_id when present", () => { + const ev = buildSubagentTaskEvent( + { agent_type: "tp-debugger", parent_agent_id: "p1" }, + 1, + 0, + ); + expect(ev!.parent_agent_id).toBe("p1"); + }); +}); + +describe("tokensFromTranscript", () => { + let dir: string; + afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + it("returns 0 for missing path", () => { + expect(tokensFromTranscript(undefined)).toBe(0); + expect(tokensFromTranscript("/does/not/exist.jsonl")).toBe(0); + }); + + it("sums usage.output_tokens across assistant messages", async () => { + dir = await mkdtemp(join(tmpdir(), "tp-transcript-")); + const p = join(dir, "t.jsonl"); + await writeFile( + p, + [ + JSON.stringify({ message: { usage: { output_tokens: 100 } } }), + JSON.stringify({ type: "user" }), + JSON.stringify({ message: { usage: { output_tokens: 250 } } }), + "not json", + "", + ].join("\n"), + ); + expect(tokensFromTranscript(p)).toBe(350); + }); + + it("falls back to last cumulative total_tokens when no output_tokens", async () => { + dir = await mkdtemp(join(tmpdir(), "tp-transcript-")); + const p = join(dir, "t.jsonl"); + await writeFile( + p, + [ + JSON.stringify({ usage: { total_tokens: 500 } }), + JSON.stringify({ usage: { total_tokens: 1200 } }), + ].join("\n"), + ); + expect(tokensFromTranscript(p)).toBe(1200); + }); +});