diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a48213c..1df40e3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Token Pilot — save 60-90% tokens when AI reads code", - "version": "0.48.0" + "version": "0.49.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. 25 MCP tools + 25 subagents + budget watchdog hooks.", - "version": "0.48.0", + "version": "0.49.0", "author": { "name": "Digital-Threads" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a917d6c..4f063ee 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.48.0", + "version": "0.49.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/CHANGELOG.md b/CHANGELOG.md index 8237b8c..39d3592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,61 @@ All notable changes to Token Pilot will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.49.0] - 2026-07-26 + +### Fixed — the tp-* response-budget watchdog was dead + +Every tp-* agent declares a `Response budget: ~N tokens`, and a watchdog +was supposed to flag replies that overran it. It never ran. The check +lived in `PostToolUse:Task`, which does not fire for the dispatch tool on +current Claude Code — something this codebase already knew and noted in a +comment back in v0.39.3, without connecting it to the budget path. + +The symptoms were quiet and complete: `.token-pilot/over-budget.log` did +not exist on any machine, and every recorded task event carried a null +budget. Months of nothing. + +Moved the check to `SubagentStop`, where subagent completions actually +arrive, and corrected what it measures. It now sizes the agent's **final +reply** rather than everything the agent emitted along the way — a +response budget is about the answer handed back, not about the thinking +and tool calls on the way there. Summing all output would have flagged +essentially every agent. + +The log write is unconditional. Surfacing the warning in the transcript +rides the existing `TOKEN_PILOT_SUBAGENT_FEEDBACK=1` gate, because +`additionalContext` on `SubagentStop` needs Claude Code 2.1.163+. + +### Security + +Cleared all 48 open dependency advisories, every one transitive through +`@modelcontextprotocol/sdk` (27 were `hono` alone). The SDK moves 1.27.1 → +1.29.0, which carries current `hono`, `qs`, `path-to-regexp`, +`ip-address`, `vite` and `vitest`. Two packages needed a major the SDK +does not request and are pinned through `overrides`: `fast-uri` 4.1.1 — +which is on our path, since the SDK's server imports the ajv validator +under stdio — and `@hono/node-server` 2.0.12. + +### Documentation + +Corrected the advertised tool count, which had drifted in three places +(README said 23, marketplace 24, docs 23; there are 25), and documented +`call_tree`, which was missing from the tools reference. + +Corrected `tokensFromTranscript`'s docstring: it sums every line carrying +a usage object, not "across assistant messages" as claimed. Claude Code +writes one record per content block and repeats the usage on each, so a +per-line sum double-counts within a request — ~1.4% on output tokens, a +clean ~2x on any input-side field. + +### CI + +npm is retiring the audit endpoint the pipeline used; it now answers 400 +for the npm shipped with Node 20 and 22, which failed every build +regardless of the code. The step now reads the JSON report itself: a +missing or malformed report is a warning, real advisories at moderate or +above still fail the build. + ## [0.48.0] - 2026-07-26 Catches up with Claude Code 2.1.188 → 2.1.220. Every breaking change in diff --git a/agents/tp-api-surface-tracker.md b/agents/tp-api-surface-tracker.md index 999f561..c2de79b 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: dd184501203fa7f3c73f419c4ffbe33c4be75400cb64a7a51733a3fe23f6e085 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-audit-scanner.md b/agents/tp-audit-scanner.md index e5f2b68..c171b07 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: d172f600bf32277ea6eb4cbbee4542ddd698a986dcd96997d33930561964569b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-commit-writer.md b/agents/tp-commit-writer.md index 9d2a9f6..57dca65 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: de64a406b5176de19f7422619c7de7949b1f28865f225402c9cea9255f377428 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-context-engineer.md b/agents/tp-context-engineer.md index 910778c..baa87b6 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.48.0" +token_pilot_version: "0.49.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 17cf447..9fa9fec 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: d9b7f5b7ae6f4ae21305c775361bcab097cc774370a6d976c093571d46d55021 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-debugger.md b/agents/tp-debugger.md index 83d3206..b6524a2 100644 --- a/agents/tp-debugger.md +++ b/agents/tp-debugger.md @@ -12,7 +12,7 @@ tools: - Read - Bash model: sonnet -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 052413de8d92377edcde6ae5c823f5378db304baccfa29e8866467f42553a500 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dep-health.md b/agents/tp-dep-health.md index edf7fde..e20dc15 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: e14dc57493d816f8c2e017963e2ef5f66bea50fd0b805a80e8a0d97c968427e7 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-doc-writer.md b/agents/tp-doc-writer.md index 1a75996..123b44a 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 57d741794ab40e31a7ac49c68ea39a9088f5827cdef866ce81bfca1b7c9180cf requiredMcpServers: - "token-pilot" diff --git a/agents/tp-history-explorer.md b/agents/tp-history-explorer.md index a68c2c6..9711f19 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 7b70fa76a60e3c58a1de4f56c32c0f166424137e203a0cf1c8654e7c9235d904 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-impact-analyzer.md b/agents/tp-impact-analyzer.md index eae5c59..c2b4da2 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 351a987e11eba63852f5431a16d8eb53104f4f689f82fdcc5a2bf4db948ba92f requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incident-timeline.md b/agents/tp-incident-timeline.md index bd58f85..6977ed5 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: de5722bfea374eaab096c1ae635c37879e7a91370ee3cd0532f4240be03c91eb requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incremental-builder.md b/agents/tp-incremental-builder.md index 8a3a890..0e75416 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 375a824d0d847bb5453ec594c7a62ad566ee7e4d92717b0473f771f1a0477c60 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-migration-scout.md b/agents/tp-migration-scout.md index d18499a..6a2f062 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 0334de1bf99b431b65359637d125cda7c44c6f780eb92c57cc538715b1939536 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-onboard.md b/agents/tp-onboard.md index a4b7e6f..e68dec3 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 832e95633fbc8e9b0c10f3e540a327d4be062fb4b3f17a6cce6be13f414e2927 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-performance-profiler.md b/agents/tp-performance-profiler.md index 1bba70e..03073f8 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: b61f06380d80798fa2e49d37bcba0653495bee04dd6bdbc1feff9a75607b0508 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-pr-reviewer.md b/agents/tp-pr-reviewer.md index 1d9da30..7a5d8dd 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: f83f50d05b4f70285ae7afed2b1a406fc436df56e61a0aedbfb31edc7f2b6e66 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-refactor-planner.md b/agents/tp-refactor-planner.md index de04c67..06057b7 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: c5f6fc122c89e16e5cf774045f92169ee3468555320b898171ba13eca5323550 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-review-impact.md b/agents/tp-review-impact.md index 2eb3d13..f78f25f 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 8ef3c3341cbfed4eb8dd130126a9683edc57e378c92ff0ca764d584fd941c55c requiredMcpServers: - "token-pilot" diff --git a/agents/tp-run.md b/agents/tp-run.md index 08204cd..5f17a2e 100644 --- a/agents/tp-run.md +++ b/agents/tp-run.md @@ -16,7 +16,7 @@ tools: - Glob - Bash model: haiku -token_pilot_version: "0.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 2b08618d34a61f00aafccbda9fed6d83243296dedb83440edbd2d5c28bb6dbc4 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-session-restorer.md b/agents/tp-session-restorer.md index c1d1f46..72660d4 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 529374ed728f5eed5b758b3be3da65624783c0bf0c1a253d7d661a843eb5f767 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-ship-coordinator.md b/agents/tp-ship-coordinator.md index 1c27aa4..21cfd2c 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: a60f6ae110eb3138064bce074e8ba26fa0ce5f4659df1624a9d9d3646803391b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-spec-writer.md b/agents/tp-spec-writer.md index f2670d3..f79a804 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.48.0" +token_pilot_version: "0.49.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 46207f6..2e28b12 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: be81eed53a3720d146cf89e4a14a7a56577633f7c84c234c412ab70d64c05b11 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-triage.md b/agents/tp-test-triage.md index 86d9150..75dc6c7 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 362ecf4cb03b059421ea26933473700900073dc38b3a7fe271208dfb1ae14f90 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-writer.md b/agents/tp-test-writer.md index 9cd5bc8..5ecb028 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.48.0" +token_pilot_version: "0.49.0" token_pilot_body_hash: 269f2fe22ff4517c277d3f56ca67d8a5527b93290ab21079a83ba7af22c1b5a9 requiredMcpServers: - "token-pilot" diff --git a/package-lock.json b/package-lock.json index da8a136..931d546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "token-pilot", - "version": "0.48.0", + "version": "0.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "token-pilot", - "version": "0.48.0", + "version": "0.49.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 351671a..d0f7e00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.48.0", + "version": "0.49.0", "description": "Save up to 80% tokens when AI reads code \u2014 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/server.json b/server.json index d4ad605..38816d4 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/Digital-Threads/token-pilot", "source": "github" }, - "version": "0.48.0", + "version": "0.49.0", "packages": [ { "registryType": "npm", "identifier": "token-pilot", - "version": "0.48.0", + "version": "0.49.0", "transport": { "type": "stdio" }, diff --git a/src/hooks/subagent-stop.ts b/src/hooks/subagent-stop.ts index e001ae9..eda4cfc 100644 --- a/src/hooks/subagent-stop.ts +++ b/src/hooks/subagent-stop.ts @@ -32,6 +32,13 @@ import { readFileSync } from "node:fs"; import type { HookEvent } from "../core/event-log.js"; +import { estimateTokens } from "../core/token-estimator.js"; +import { + parseAgentBudget, + decideBudgetAdvice, + appendOverBudgetLog, + loadAgentBody, +} from "./post-task.js"; export interface SubagentStopInput { hook_event_name?: string; @@ -103,6 +110,110 @@ export function tokensFromTranscript(path: string | undefined): number { return out > 0 ? out : lastTotal; } +/** + * Size of the subagent's FINAL reply, in tokens. + * + * Distinct from `tokensFromTranscript`, which sums everything the agent + * ever emitted. The tp-* agents declare "Response budget: ~N tokens", + * and that budget is about the answer handed back to the caller — not + * about the thinking and tool calls along the way. So we take the last + * assistant turn carrying text and measure only that. + * + * The SubagentStop payload also has a `last_assistant_message` field + * (present in the 2.1.220 bundle), but nothing in this codebase has ever + * read it, so we have no evidence it is populated. The transcript is the + * source we already rely on, so it stays the single source here. + * + * Returns 0 when the path is missing, unreadable, or the agent finished + * without a text reply — never throws. + */ +export function finalResponseTokens(path: string | undefined): number { + if (!path || typeof path !== "string") return 0; + let raw: string; + try { + raw = readFileSync(path, "utf-8"); + } catch { + return 0; + } + let lastText = ""; + 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?: { role?: string; content?: unknown }; + } + ).message; + if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue; + const text = msg.content + .filter( + (b): b is { type: string; text: string } => + typeof b === "object" && + b !== null && + (b as { type?: unknown }).type === "text" && + typeof (b as { text?: unknown }).text === "string", + ) + .map((b) => b.text) + .join(""); + if (text.trim()) lastText = text; + } + return lastText ? estimateTokens(lastText) : 0; +} + +/** + * Response-budget watchdog for tp-* subagents. + * + * Lived in PostToolUse:Task since v0.37.0, but that hook does not fire + * for the dispatch tool on current Claude Code — so the check never ran, + * `over-budget.log` never appeared, and every recorded task event carried + * a null budget. SubagentStop is where completions actually arrive. + * + * Returns an advisory string when the reply overran the declared budget, + * or null in every other case (non-tp agent, no budget declared, agent + * file missing, transcript unreadable). Never throws: a watchdog must not + * be able to break subagent completion. + */ +export async function checkSubagentBudget( + projectRoot: string, + homeDir: string, + input: Pick, +): Promise { + try { + const agent = input.agent_type; + if (typeof agent !== "string" || !agent.startsWith("tp-")) return null; + + const actualTokens = finalResponseTokens(input.agent_transcript_path); + if (actualTokens <= 0) return null; + + const body = await loadAgentBody(projectRoot, homeDir, agent); + const budget = body ? parseAgentBudget(body) : null; + if (budget == null) return null; + + const decision = decideBudgetAdvice({ + agentName: agent, + budget, + actualTokens, + }); + if (!decision.overBudget) return null; + + await appendOverBudgetLog(projectRoot, { + ts: Date.now(), + agent, + budget, + actualTokens, + overByRatio: decision.overByRatio, + }); + return decision.message; + } catch { + return null; + } +} + /** * Build the `event:"task"` record from a SubagentStop payload. Pure — * no I/O except the optional transcript token read, which the caller diff --git a/src/index.ts b/src/index.ts index 9e772d2..7c5d6ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -415,6 +415,7 @@ export async function main(cliArgs = process.argv.slice(2)): Promise { buildSubagentTaskEvent, decideSubagentFeedback, renderSubagentFeedback, + checkSubagentBudget, } = await import("./hooks/subagent-stop.js"); const ev = buildSubagentTaskEvent(input, Date.now()); if (ev) { @@ -422,6 +423,18 @@ export async function main(cliArgs = process.argv.slice(2)): Promise { await appendEvent(process.cwd(), ev); } + // v0.49.0 — the tp-* response-budget watchdog. It used to hang off + // PostToolUse:Task, which never fires here, so it has been silently + // dead: no over-budget.log, every task event with a null budget. + // The log write is unconditional (that is the record); surfacing it + // in the transcript rides the same feature gate as the feedback + // below, since SubagentStop additionalContext needs CC 2.1.163+. + const budgetMessage = await checkSubagentBudget( + process.cwd(), + homedir(), + input, + ); + // v0.41.0 — optional SubagentStop feedback. Returning // hookSpecificOutput.additionalContext from SubagentStop is a // Claude Code 2.1.163+ feature; on older Claude Code it is @@ -445,9 +458,13 @@ export async function main(cliArgs = process.argv.slice(2)): Promise { }; } } - const rendered = renderSubagentFeedback( + const combined = [ + budgetMessage, decideSubagentFeedback(input, { workflow: wf }), - ); + ] + .filter(Boolean) + .join("\n"); + const rendered = renderSubagentFeedback(combined || null); if (rendered) process.stdout.write(rendered); } }); diff --git a/tests/hooks/subagent-budget.test.ts b/tests/hooks/subagent-budget.test.ts new file mode 100644 index 0000000..cca0d41 --- /dev/null +++ b/tests/hooks/subagent-budget.test.ts @@ -0,0 +1,198 @@ +/** + * v0.49.0 — the tp-* response-budget watchdog, revived. + * + * The budget check has lived in PostToolUse:Task since v0.37.0, but that + * hook does not fire for the dispatch tool on current Claude Code (noted + * in installer.ts since v0.39.3). Consequence: `.token-pilot/over-budget.log` + * never appeared and every recorded task event carried a null budget — the + * watchdog has been dead for months without anyone noticing. + * + * SubagentStop is where subagent completions actually arrive, so the check + * belongs here. The measured value is the size of the agent's FINAL reply, + * not the sum of everything it emitted along the way: the agents declare + * "Response budget: ~N tokens", which is about the answer they hand back. + */ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, writeFile, mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + finalResponseTokens, + checkSubagentBudget, +} from "../../src/hooks/subagent-stop.ts"; + +/** One JSONL line in the shape Claude Code writes for a subagent turn. */ +function assistantLine(text: string): string { + return JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text }], + usage: { output_tokens: 10 }, + }, + }); +} + +function toolUseLine(): string { + return JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "Read", input: {} }], + usage: { output_tokens: 40 }, + }, + }); +} + +describe("finalResponseTokens", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "tp-budget-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function transcript(lines: string[]): Promise { + const p = join(dir, "agent.jsonl"); + await writeFile(p, lines.join("\n") + "\n"); + return p; + } + + it("returns 0 for a missing or unreadable path", async () => { + expect(finalResponseTokens(undefined)).toBe(0); + expect(finalResponseTokens(join(dir, "nope.jsonl"))).toBe(0); + }); + + it("measures the last assistant text, not the whole transcript", async () => { + const p = await transcript([ + assistantLine("x".repeat(4000)), + assistantLine("short answer"), + ]); + // Only the final reply counts, so this stays tiny despite the 4000-char + // turn before it. + expect(finalResponseTokens(p)).toBeLessThan(10); + expect(finalResponseTokens(p)).toBeGreaterThan(0); + }); + + it("ignores tool_use blocks that follow the last text block", async () => { + const p = await transcript([ + assistantLine("the real answer"), + toolUseLine(), + ]); + expect(finalResponseTokens(p)).toBeGreaterThan(0); + expect(finalResponseTokens(p)).toBeLessThan(10); + }); + + it("survives malformed lines", async () => { + const p = await transcript([ + "not json{{{", + assistantLine("still readable"), + "", + ]); + expect(finalResponseTokens(p)).toBeGreaterThan(0); + }); + + it("returns 0 when the agent produced no text reply", async () => { + const p = await transcript([toolUseLine()]); + expect(finalResponseTokens(p)).toBe(0); + }); +}); + +describe("checkSubagentBudget", () => { + let projectRoot: string; + let homeDir: string; + + /** Write an agent definition carrying a response budget. */ + async function writeAgent(name: string, budget: number | null) { + const agentsDir = join(projectRoot, ".claude", "agents"); + await mkdir(agentsDir, { recursive: true }); + const body = + budget === null + ? `---\nname: ${name}\n---\n\nNo budget declared.\n` + : `---\nname: ${name}\n---\n\nResponse budget: ~${budget} tokens\n`; + await writeFile(join(agentsDir, `${name}.md`), body); + } + + async function writeTranscript(text: string): Promise { + const p = join(projectRoot, "agent.jsonl"); + await writeFile(p, assistantLine(text) + "\n"); + return p; + } + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "tp-budget-proj-")); + homeDir = await mkdtemp(join(tmpdir(), "tp-budget-home-")); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true }); + }); + + it("ignores agents that are not tp-*", async () => { + const transcriptPath = await writeTranscript("x".repeat(8000)); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "general-purpose", + agent_transcript_path: transcriptPath, + }); + expect(msg).toBeNull(); + }); + + it("stays silent when the reply fits the budget", async () => { + await writeAgent("tp-debugger", 600); + const transcriptPath = await writeTranscript("a short answer"); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "tp-debugger", + agent_transcript_path: transcriptPath, + }); + expect(msg).toBeNull(); + }); + + it("flags a reply that overruns the budget and logs it", async () => { + await writeAgent("tp-debugger", 100); + const transcriptPath = await writeTranscript("x".repeat(8000)); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "tp-debugger", + agent_transcript_path: transcriptPath, + }); + expect(msg).toContain("tp-debugger"); + expect(msg).toContain("100"); + + const log = await readFile( + join(projectRoot, ".token-pilot", "over-budget.log"), + "utf-8", + ); + expect(log).toContain("tp-debugger"); + }); + + it("stays silent when the agent declares no budget", async () => { + await writeAgent("tp-debugger", null); + const transcriptPath = await writeTranscript("x".repeat(8000)); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "tp-debugger", + agent_transcript_path: transcriptPath, + }); + expect(msg).toBeNull(); + }); + + it("stays silent when the agent file cannot be found", async () => { + const transcriptPath = await writeTranscript("x".repeat(8000)); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "tp-nonexistent", + agent_transcript_path: transcriptPath, + }); + expect(msg).toBeNull(); + }); + + it("never throws when the transcript is missing", async () => { + await writeAgent("tp-debugger", 100); + const msg = await checkSubagentBudget(projectRoot, homeDir, { + agent_type: "tp-debugger", + agent_transcript_path: join(projectRoot, "gone.jsonl"), + }); + expect(msg).toBeNull(); + }); +});