token-pilot 0.30.5 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/tp-api-surface-tracker.md +10 -2
  4. package/agents/tp-audit-scanner.md +10 -2
  5. package/agents/tp-commit-writer.md +10 -2
  6. package/agents/tp-context-engineer.md +10 -2
  7. package/agents/tp-dead-code-finder.md +10 -2
  8. package/agents/tp-debugger.md +10 -2
  9. package/agents/tp-dep-health.md +10 -2
  10. package/agents/tp-doc-writer.md +10 -2
  11. package/agents/tp-history-explorer.md +10 -2
  12. package/agents/tp-impact-analyzer.md +10 -2
  13. package/agents/tp-incident-timeline.md +10 -2
  14. package/agents/tp-incremental-builder.md +10 -2
  15. package/agents/tp-migration-scout.md +10 -2
  16. package/agents/tp-onboard.md +10 -2
  17. package/agents/tp-performance-profiler.md +10 -2
  18. package/agents/tp-pr-reviewer.md +10 -2
  19. package/agents/tp-refactor-planner.md +10 -2
  20. package/agents/tp-review-impact.md +10 -2
  21. package/agents/tp-run.md +10 -2
  22. package/agents/tp-session-restorer.md +10 -2
  23. package/agents/tp-ship-coordinator.md +10 -2
  24. package/agents/tp-spec-writer.md +10 -2
  25. package/agents/tp-test-coverage-gapper.md +10 -2
  26. package/agents/tp-test-triage.md +10 -2
  27. package/agents/tp-test-writer.md +10 -2
  28. package/dist/cli/stats.d.ts +2 -0
  29. package/dist/cli/stats.js +46 -1
  30. package/dist/core/agent-matcher.d.ts +115 -0
  31. package/dist/core/agent-matcher.js +326 -0
  32. package/dist/core/event-log.d.ts +14 -1
  33. package/dist/hooks/installer.js +9 -0
  34. package/dist/hooks/post-task.d.ts +15 -0
  35. package/dist/hooks/post-task.js +102 -19
  36. package/dist/hooks/pre-task.d.ts +71 -0
  37. package/dist/hooks/pre-task.js +125 -0
  38. package/dist/index.js +29 -0
  39. package/hooks/hooks.json +9 -0
  40. package/package.json +1 -1
@@ -14,7 +14,10 @@
14
14
  * Non-tp-* subagents are ignored (we only enforce our own contracts).
15
15
  */
16
16
  import { promises as fs } from "node:fs";
17
- import { join } from "node:path";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { buildAgentIndex, matchTpAgent, } from "../core/agent-matcher.js";
20
+ import { appendEvent } from "../core/event-log.js";
18
21
  export const OVER_BUDGET_LOG = "over-budget.log";
19
22
  /** Ratio above which we flag — 0.1 = 10 % grace. */
20
23
  export const OVER_BUDGET_TOLERANCE = 0.1;
@@ -100,6 +103,43 @@ export async function loadAgentBody(projectRoot, homeDir, agentName) {
100
103
  }
101
104
  return null;
102
105
  }
106
+ // ─── Cached tp-* agent index ─────────────────────────────────────────
107
+ // The hook subprocess is cold-started per Task post-event, but within
108
+ // that process we parse the agents directory once. Lookup cost is ~1 FS
109
+ // listing + 24 file reads, ~5-15 ms — below the noise floor of the hook
110
+ // round-trip. Kept as a process-level cache anyway for Pack 2 when the
111
+ // pre-task hook re-uses the same index on hot paths.
112
+ let _agentIndexCache = null;
113
+ /**
114
+ * Resolve the plugin's own `agents/` directory. The hook binary lives
115
+ * at `<plugin>/dist/index.js`, so agents/ is `../agents` from here.
116
+ * Allow an override for tests that want an isolated fixture dir.
117
+ */
118
+ export function defaultAgentsDir() {
119
+ // `import.meta.url` resolves to the bundled dist location, which is
120
+ // already one step below the repo root (`dist/hooks/post-task.js`).
121
+ // Walk up twice: `hooks` → `dist` → plugin root, then join `agents`.
122
+ try {
123
+ const here = fileURLToPath(import.meta.url);
124
+ return resolve(dirname(here), "..", "..", "agents");
125
+ }
126
+ catch {
127
+ // Not running as a bundled module (eg. vitest in-source) — fall
128
+ // back to CWD/agents. Production path uses the URL resolver above.
129
+ return resolve(process.cwd(), "agents");
130
+ }
131
+ }
132
+ /** Resolve (and cache) the tp-* agent index. Safe to call repeatedly. */
133
+ export async function getAgentIndex(dir = defaultAgentsDir()) {
134
+ if (_agentIndexCache)
135
+ return _agentIndexCache;
136
+ _agentIndexCache = await buildAgentIndex(dir);
137
+ return _agentIndexCache;
138
+ }
139
+ /** Test-only: clear the module-level cache between fixtures. */
140
+ export function _resetAgentIndexCache() {
141
+ _agentIndexCache = null;
142
+ }
103
143
  /**
104
144
  * Full post-Task processing: read frontmatter, count tokens, log over-budget.
105
145
  * Returns the advice message (or null) so the caller can optionally emit
@@ -108,29 +148,72 @@ export async function loadAgentBody(projectRoot, homeDir, agentName) {
108
148
  export async function processPostTask(projectRoot, homeDir, input) {
109
149
  if (input.tool_name !== "Task")
110
150
  return null;
111
- const agentName = input.tool_input?.subagent_type;
112
- if (typeof agentName !== "string" || !agentName.startsWith("tp-")) {
113
- return null;
151
+ const subagentType = input.tool_input?.subagent_type;
152
+ const description = input.tool_input?.description ?? "";
153
+ const actualTokens = extractSubagentTokens(input) ?? 0;
154
+ const isTpAgent = typeof subagentType === "string" && subagentType.startsWith("tp-");
155
+ // ─── existing tp-* budget logic (unchanged) ─────────────────────
156
+ let budget = null;
157
+ let decision = {
158
+ overBudget: false,
159
+ overByRatio: 0,
160
+ message: null,
161
+ };
162
+ if (isTpAgent && actualTokens > 0) {
163
+ const body = await loadAgentBody(projectRoot, homeDir, subagentType);
164
+ budget = body ? parseAgentBudget(body) : null;
165
+ decision = decideBudgetAdvice({
166
+ agentName: subagentType,
167
+ budget,
168
+ actualTokens,
169
+ });
170
+ if (decision.overBudget && budget != null) {
171
+ await appendOverBudgetLog(projectRoot, {
172
+ ts: Date.now(),
173
+ agent: subagentType,
174
+ budget,
175
+ actualTokens,
176
+ overByRatio: decision.overByRatio,
177
+ });
178
+ }
114
179
  }
115
- const actualTokens = extractSubagentTokens(input);
116
- if (actualTokens == null)
117
- return null;
118
- const body = await loadAgentBody(projectRoot, homeDir, agentName);
119
- const budget = body ? parseAgentBudget(body) : null;
120
- const decision = decideBudgetAdvice({
121
- agentName,
122
- budget,
123
- actualTokens,
124
- });
125
- if (decision.overBudget && budget != null) {
126
- await appendOverBudgetLog(projectRoot, {
180
+ // ─── v0.31.0 Task telemetry ────────────────────────────────────
181
+ // One event per Task call, regardless of tp-*. For non-tp agents we
182
+ // run the heuristic matcher so `stats --tasks` can surface routing
183
+ // misses (general-purpose picked when a tp-* would have fit).
184
+ // Silent on any error telemetry must never break hook dispatch.
185
+ try {
186
+ let matched = null;
187
+ let matchConfidence;
188
+ if (!isTpAgent && description.length > 0) {
189
+ const index = await getAgentIndex();
190
+ const hit = matchTpAgent(description, index);
191
+ if (hit) {
192
+ matched = hit.agent;
193
+ matchConfidence = hit.confidence;
194
+ }
195
+ }
196
+ await appendEvent(projectRoot, {
127
197
  ts: Date.now(),
128
- agent: agentName,
198
+ session_id: input.session_id ?? "",
199
+ agent_type: input.agent_type ?? null,
200
+ agent_id: input.agent_id ?? null,
201
+ event: "task",
202
+ file: "",
203
+ lines: 0,
204
+ estTokens: actualTokens,
205
+ summaryTokens: 0,
206
+ savedTokens: 0,
207
+ subagent_type: typeof subagentType === "string" ? subagentType : "",
208
+ matched_tp_agent: matched,
209
+ ...(matchConfidence ? { match_confidence: matchConfidence } : {}),
129
210
  budget,
130
- actualTokens,
131
- overByRatio: decision.overByRatio,
211
+ overBudget: decision.overBudget,
132
212
  });
133
213
  }
214
+ catch {
215
+ /* silent */
216
+ }
134
217
  return decision.message;
135
218
  }
136
219
  //# sourceMappingURL=post-task.js.map
@@ -0,0 +1,71 @@
1
+ /**
2
+ * v0.31.0 Pack 2 — PreToolUse:Task routing enforcement.
3
+ *
4
+ * Pack 1 (already shipped) built the matcher and telemetry. Pack 2 acts
5
+ * on that matcher: BEFORE a Task dispatch fires, we inspect
6
+ * `tool_input.subagent_type` + `tool_input.description`, heuristically
7
+ * match against the shipped `tp-*` catalog, and redirect (advise / deny)
8
+ * general-purpose calls that clearly fit a specialised agent.
9
+ *
10
+ * Why not straight-deny:
11
+ * - The pre-edit rollback in v0.30.4 taught us the cost of a false
12
+ * hard-block (stuck sessions, BYPASS env creep). Task routing has
13
+ * MORE ambiguity than Edit (descriptions are terse; recall on
14
+ * keyword match is imperfect), so the default mode = advise.
15
+ *
16
+ * Tier logic (first match wins):
17
+ *
18
+ * 1. tool_name !== "Task" → allow
19
+ * 2. subagent_type ∈ tp-* → allow
20
+ * 3. description contains an ESCAPE phrase → allow
21
+ * (ad-hoc / research / explore / multi-step / across the codebase)
22
+ * 4. matchTpAgent returns null → allow
23
+ * 5. TOKEN_PILOT_FORCE_SUBAGENTS=1 OR mode=strict → deny
24
+ * (hard-block: agent author opted into pedantic routing)
25
+ * 6. confidence=high → advise
26
+ * 7. confidence=low → advise (softer msg)
27
+ *
28
+ * Pure decide — all context (agent index, env, mode) is pre-resolved
29
+ * by the caller so the function stays deterministic and unit-testable.
30
+ */
31
+ import type { EnforcementMode } from "../server/enforcement-mode.js";
32
+ import type { AgentIndex } from "../core/agent-matcher.js";
33
+ export interface PreTaskInput {
34
+ tool_name?: string;
35
+ tool_input?: {
36
+ subagent_type?: string;
37
+ description?: string;
38
+ [k: string]: unknown;
39
+ };
40
+ }
41
+ export type PreTaskDecision = {
42
+ kind: "allow";
43
+ } | {
44
+ kind: "advise";
45
+ message: string;
46
+ } | {
47
+ kind: "deny";
48
+ reason: string;
49
+ };
50
+ export interface PreTaskContext {
51
+ /** Parsed enforcement mode. `strict` is the only hard-block tier. */
52
+ mode: EnforcementMode;
53
+ /** Agent catalog built at startup by buildAgentIndex. */
54
+ agentIndex: AgentIndex;
55
+ /** TOKEN_PILOT_FORCE_SUBAGENTS=1 — opt-in strictness regardless of mode. */
56
+ force: boolean;
57
+ }
58
+ /**
59
+ * Pure decision function. Caller resolves all context (env, mode,
60
+ * agent index) up front so this stays a plain input → output mapping.
61
+ */
62
+ export declare function decidePreTask(input: PreTaskInput, ctx: PreTaskContext): PreTaskDecision;
63
+ /**
64
+ * Render the Claude Code hook JSON response.
65
+ *
66
+ * - allow → no output (pass-through)
67
+ * - advise → permissionDecision=allow + additionalContext
68
+ * - deny → permissionDecision=deny + reason
69
+ */
70
+ export declare function renderPreTaskOutput(decision: PreTaskDecision): string | null;
71
+ //# sourceMappingURL=pre-task.d.ts.map
@@ -0,0 +1,125 @@
1
+ /**
2
+ * v0.31.0 Pack 2 — PreToolUse:Task routing enforcement.
3
+ *
4
+ * Pack 1 (already shipped) built the matcher and telemetry. Pack 2 acts
5
+ * on that matcher: BEFORE a Task dispatch fires, we inspect
6
+ * `tool_input.subagent_type` + `tool_input.description`, heuristically
7
+ * match against the shipped `tp-*` catalog, and redirect (advise / deny)
8
+ * general-purpose calls that clearly fit a specialised agent.
9
+ *
10
+ * Why not straight-deny:
11
+ * - The pre-edit rollback in v0.30.4 taught us the cost of a false
12
+ * hard-block (stuck sessions, BYPASS env creep). Task routing has
13
+ * MORE ambiguity than Edit (descriptions are terse; recall on
14
+ * keyword match is imperfect), so the default mode = advise.
15
+ *
16
+ * Tier logic (first match wins):
17
+ *
18
+ * 1. tool_name !== "Task" → allow
19
+ * 2. subagent_type ∈ tp-* → allow
20
+ * 3. description contains an ESCAPE phrase → allow
21
+ * (ad-hoc / research / explore / multi-step / across the codebase)
22
+ * 4. matchTpAgent returns null → allow
23
+ * 5. TOKEN_PILOT_FORCE_SUBAGENTS=1 OR mode=strict → deny
24
+ * (hard-block: agent author opted into pedantic routing)
25
+ * 6. confidence=high → advise
26
+ * 7. confidence=low → advise (softer msg)
27
+ *
28
+ * Pure decide — all context (agent index, env, mode) is pre-resolved
29
+ * by the caller so the function stays deterministic and unit-testable.
30
+ */
31
+ import { matchTpAgent } from "../core/agent-matcher.js";
32
+ /**
33
+ * Escape phrases that tell us the user genuinely wants open-ended
34
+ * general-purpose work. Short list of boilerplate — keeping it tight
35
+ * prevents the escape from eating otherwise-valid routing.
36
+ *
37
+ * All checks are lowercased substring matches. Author new entries here
38
+ * only when tool-audit shows a legitimate pattern getting false-flagged.
39
+ */
40
+ const ESCAPE_PHRASES = [
41
+ "ad-hoc",
42
+ "ad hoc",
43
+ "one-off",
44
+ "one off",
45
+ "open-ended",
46
+ "research across",
47
+ "explore multiple",
48
+ "multi-step",
49
+ "across the codebase",
50
+ "across the repo",
51
+ "general purpose",
52
+ ];
53
+ function containsEscape(description) {
54
+ const n = description.toLowerCase();
55
+ return ESCAPE_PHRASES.some((p) => n.includes(p));
56
+ }
57
+ /**
58
+ * Pure decision function. Caller resolves all context (env, mode,
59
+ * agent index) up front so this stays a plain input → output mapping.
60
+ */
61
+ export function decidePreTask(input, ctx) {
62
+ if (input.tool_name !== "Task")
63
+ return { kind: "allow" };
64
+ const subagentType = input.tool_input?.subagent_type ?? "";
65
+ const description = input.tool_input?.description ?? "";
66
+ // Already a tp-* — routing intent matches catalog. Let it run.
67
+ if (typeof subagentType === "string" && subagentType.startsWith("tp-")) {
68
+ return { kind: "allow" };
69
+ }
70
+ // No description → nothing to match against. Allow (Claude Code
71
+ // sometimes dispatches Task with only a subagent_type + session id).
72
+ if (!description || description.length === 0)
73
+ return { kind: "allow" };
74
+ // Author-blessed escape clauses — user is explicitly saying
75
+ // "this is broad". Respect that.
76
+ if (containsEscape(description))
77
+ return { kind: "allow" };
78
+ const hit = matchTpAgent(description, ctx.agentIndex);
79
+ if (!hit)
80
+ return { kind: "allow" };
81
+ const suggestion = `Consider dispatching \`${hit.agent}\` instead of \`${subagentType || "general-purpose"}\` — ` +
82
+ `the description matches its trigger phrases (confidence: ${hit.confidence}). ` +
83
+ `tp-* agents run under a tighter budget and output in terse style, typically ` +
84
+ `~50-70 % fewer tokens than general-purpose. ` +
85
+ `Escape: add "ad-hoc" or "open-ended" to the description to bypass, or set ` +
86
+ `TOKEN_PILOT_MODE=advisory for warn-only behaviour.`;
87
+ const hardBlock = ctx.force ||
88
+ ctx.mode === "strict" ||
89
+ (ctx.mode === "deny" && hit.confidence === "high" && ctx.force);
90
+ if (hardBlock) {
91
+ return {
92
+ kind: "deny",
93
+ reason: suggestion,
94
+ };
95
+ }
96
+ return { kind: "advise", message: suggestion };
97
+ }
98
+ /**
99
+ * Render the Claude Code hook JSON response.
100
+ *
101
+ * - allow → no output (pass-through)
102
+ * - advise → permissionDecision=allow + additionalContext
103
+ * - deny → permissionDecision=deny + reason
104
+ */
105
+ export function renderPreTaskOutput(decision) {
106
+ if (decision.kind === "allow")
107
+ return null;
108
+ if (decision.kind === "advise") {
109
+ return JSON.stringify({
110
+ hookSpecificOutput: {
111
+ hookEventName: "PreToolUse",
112
+ permissionDecision: "allow",
113
+ additionalContext: decision.message,
114
+ },
115
+ });
116
+ }
117
+ return JSON.stringify({
118
+ hookSpecificOutput: {
119
+ hookEventName: "PreToolUse",
120
+ permissionDecision: "deny",
121
+ permissionDecisionReason: decision.reason,
122
+ },
123
+ });
124
+ }
125
+ //# sourceMappingURL=pre-task.js.map
package/dist/index.js CHANGED
@@ -52,6 +52,8 @@ import { assessClaudeMd } from "./cli/claudemd-hygiene.js";
52
52
  import { decidePostBashAdvice, renderPostBashHookOutput, } from "./hooks/post-bash.js";
53
53
  import { decidePreBash, renderPreBashOutput } from "./hooks/pre-bash.js";
54
54
  import { decidePreGrep, renderPreGrepOutput } from "./hooks/pre-grep.js";
55
+ import { decidePreTask, renderPreTaskOutput } from "./hooks/pre-task.js";
56
+ import { getAgentIndex } from "./hooks/post-task.js";
55
57
  import { decidePreEdit, renderPreEditOutput, } from "./hooks/pre-edit.js";
56
58
  import { isEditPrepared as isEditPreparedFn } from "./core/edit-prep-state.js";
57
59
  import { maybeEmitEcosystemReminder } from "./cli/ecosystem-reminder.js";
@@ -184,6 +186,33 @@ export async function main(cliArgs = process.argv.slice(2)) {
184
186
  process.exit(0);
185
187
  return;
186
188
  }
189
+ case "hook-pre-task": {
190
+ // v0.31.0 Pack 2 — route general-purpose Task dispatches to a
191
+ // `tp-*` specialist when the description clearly matches. Default
192
+ // (deny / advisory mode) is a non-blocking advise; strict mode or
193
+ // TOKEN_PILOT_FORCE_SUBAGENTS=1 hard-denies on a high-confidence
194
+ // match. The matcher is lenient by design (false deny is much
195
+ // worse than a missed nudge — see pre-edit v0.30.4 rollback).
196
+ try {
197
+ const stdin = readFileSync(0, "utf-8");
198
+ const input = JSON.parse(stdin);
199
+ const agentIndex = await getAgentIndex();
200
+ const force = process.env.TOKEN_PILOT_FORCE_SUBAGENTS === "1";
201
+ const decision = decidePreTask(input, {
202
+ mode: parseEnforcementMode(process.env.TOKEN_PILOT_MODE),
203
+ agentIndex,
204
+ force,
205
+ });
206
+ const rendered = renderPreTaskOutput(decision);
207
+ if (rendered)
208
+ process.stdout.write(rendered);
209
+ }
210
+ catch {
211
+ /* silent — hook must not break */
212
+ }
213
+ process.exit(0);
214
+ return;
215
+ }
187
216
  case "hook-post-task": {
188
217
  try {
189
218
  const stdin = readFileSync(0, "utf-8");
package/hooks/hooks.json CHANGED
@@ -45,6 +45,15 @@
45
45
  "command": "node ${CLAUDE_PLUGIN_ROOT}/dist/index.js hook-pre-grep"
46
46
  }
47
47
  ]
48
+ },
49
+ {
50
+ "matcher": "Task",
51
+ "hooks": [
52
+ {
53
+ "type": "command",
54
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/dist/index.js hook-pre-task"
55
+ }
56
+ ]
48
57
  }
49
58
  ],
50
59
  "SessionStart": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-pilot",
3
- "version": "0.30.5",
3
+ "version": "0.31.0",
4
4
  "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",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",