token-pilot 0.30.5 → 0.32.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 (49) 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/core/validation.d.ts +13 -9
  34. package/dist/core/validation.js +180 -134
  35. package/dist/handlers/call-tree.d.ts +35 -0
  36. package/dist/handlers/call-tree.js +70 -0
  37. package/dist/hooks/installer.js +9 -0
  38. package/dist/hooks/post-task.d.ts +15 -0
  39. package/dist/hooks/post-task.js +102 -19
  40. package/dist/hooks/pre-task.d.ts +71 -0
  41. package/dist/hooks/pre-task.js +125 -0
  42. package/dist/hooks/session-start.d.ts +2 -0
  43. package/dist/hooks/session-start.js +49 -0
  44. package/dist/index.js +29 -0
  45. package/dist/server/tool-definitions.d.ts +65 -0
  46. package/dist/server/tool-definitions.js +18 -0
  47. package/dist/server.js +36 -1
  48. package/hooks/hooks.json +9 -0
  49. package/package.json +1 -1
@@ -0,0 +1,35 @@
1
+ /**
2
+ * v0.32.0 — call_tree MCP tool.
3
+ *
4
+ * Thin wrapper over `AstIndexClient.callTree`. Produces a text tree of
5
+ * callers (depth-N) for one function. Complements `find_usages` which
6
+ * is flat (one level of refs): call_tree is recursive, so you see the
7
+ * full chain from leaves → entry points.
8
+ *
9
+ * Typical use cases:
10
+ * - debugging: "who eventually calls this helper"
11
+ * - refactor planning: "what breaks if I change this function's
12
+ * signature"
13
+ * - dead-code verification: "does anything actually reach this
14
+ * branch"
15
+ *
16
+ * Output shape is indented tree text, not JSON — the MCP-consuming
17
+ * model needs to read it, not diff it.
18
+ */
19
+ import type { AstIndexClient } from "../ast-index/client.js";
20
+ export interface CallTreeArgs {
21
+ /** Function / method name (unqualified, e.g. `fetchUser`). */
22
+ symbol: string;
23
+ /** Walk-up depth. Default 3, max 6 (anything deeper is overwhelming). */
24
+ depth?: number;
25
+ }
26
+ export declare function handleCallTree(args: CallTreeArgs, astIndex: AstIndexClient): Promise<{
27
+ content: Array<{
28
+ type: "text";
29
+ text: string;
30
+ }>;
31
+ meta: {
32
+ files: string[];
33
+ };
34
+ }>;
35
+ //# sourceMappingURL=call-tree.d.ts.map
@@ -0,0 +1,70 @@
1
+ const MAX_DEPTH = 6;
2
+ function renderNode(node, indent, out) {
3
+ const loc = node.file && node.line != null
4
+ ? ` — ${node.file}:${node.line}`
5
+ : node.file
6
+ ? ` — ${node.file}`
7
+ : "";
8
+ out.push(`${indent}${node.name}${loc}`);
9
+ if (node.callers && node.callers.length > 0) {
10
+ for (const child of node.callers) {
11
+ renderNode(child, indent + " ", out);
12
+ }
13
+ }
14
+ }
15
+ export async function handleCallTree(args, astIndex) {
16
+ if (astIndex.isDisabled() || astIndex.isOversized()) {
17
+ return {
18
+ content: [
19
+ {
20
+ type: "text",
21
+ text: "call_tree is disabled: " +
22
+ (astIndex.isDisabled()
23
+ ? "project root not detected. Call smart_read() on any project file first."
24
+ : "ast-index indexed >50k files (likely includes node_modules). Ensure node_modules is in .gitignore.") +
25
+ "\nAlternative: use find_usages(symbol) iteratively.",
26
+ },
27
+ ],
28
+ meta: { files: [] },
29
+ };
30
+ }
31
+ const symbol = args.symbol?.trim();
32
+ if (!symbol) {
33
+ return {
34
+ content: [{ type: "text", text: "call_tree: `symbol` is required." }],
35
+ meta: { files: [] },
36
+ };
37
+ }
38
+ const depth = Math.min(Math.max(1, Math.floor(args.depth ?? 3)), MAX_DEPTH);
39
+ const tree = await astIndex.callTree(symbol, depth);
40
+ if (!tree) {
41
+ return {
42
+ content: [
43
+ {
44
+ type: "text",
45
+ text: `No call-tree found for \`${symbol}\`. The symbol may be uncalled, unindexed, or ambiguous. Try find_usages("${symbol}") for a flat cross-reference list.`,
46
+ },
47
+ ],
48
+ meta: { files: [] },
49
+ };
50
+ }
51
+ const lines = [];
52
+ lines.push(`CALL TREE for \`${symbol}\` (depth ${depth}, callers of callers…):`);
53
+ lines.push("");
54
+ renderNode(tree, " ", lines);
55
+ lines.push("");
56
+ lines.push("Read bottom-up: indented entries call the parent. Root is the symbol you asked for.");
57
+ // Collect files for meta so downstream consumers can open them.
58
+ const files = new Set();
59
+ const collect = (n) => {
60
+ if (n.file)
61
+ files.add(n.file);
62
+ n.callers?.forEach(collect);
63
+ };
64
+ collect(tree);
65
+ return {
66
+ content: [{ type: "text", text: lines.join("\n") }],
67
+ meta: { files: [...files] },
68
+ };
69
+ }
70
+ //# sourceMappingURL=call-tree.js.map
@@ -61,6 +61,15 @@ function createHookConfig(options) {
61
61
  },
62
62
  ],
63
63
  },
64
+ {
65
+ matcher: "Task",
66
+ hooks: [
67
+ {
68
+ type: "command",
69
+ command: buildHookCommand("hook-pre-task", options),
70
+ },
71
+ ],
72
+ },
64
73
  ],
65
74
  SessionStart: [
66
75
  {
@@ -13,6 +13,7 @@
13
13
  * Silent on every failure — telemetry must never break the agent loop.
14
14
  * Non-tp-* subagents are ignored (we only enforce our own contracts).
15
15
  */
16
+ import { type AgentIndex } from "../core/agent-matcher.js";
16
17
  export declare const OVER_BUDGET_LOG = "over-budget.log";
17
18
  /** Ratio above which we flag — 0.1 = 10 % grace. */
18
19
  export declare const OVER_BUDGET_TOLERANCE = 0.1;
@@ -55,9 +56,23 @@ export interface PostTaskHookInput {
55
56
  tool_name?: string;
56
57
  tool_input?: {
57
58
  subagent_type?: string;
59
+ description?: string;
58
60
  };
59
61
  tool_response?: unknown;
62
+ session_id?: string;
63
+ agent_type?: string;
64
+ agent_id?: string;
60
65
  }
66
+ /**
67
+ * Resolve the plugin's own `agents/` directory. The hook binary lives
68
+ * at `<plugin>/dist/index.js`, so agents/ is `../agents` from here.
69
+ * Allow an override for tests that want an isolated fixture dir.
70
+ */
71
+ export declare function defaultAgentsDir(): string;
72
+ /** Resolve (and cache) the tp-* agent index. Safe to call repeatedly. */
73
+ export declare function getAgentIndex(dir?: string): Promise<AgentIndex>;
74
+ /** Test-only: clear the module-level cache between fixtures. */
75
+ export declare function _resetAgentIndexCache(): void;
61
76
  /**
62
77
  * Full post-Task processing: read frontmatter, count tokens, log over-budget.
63
78
  * Returns the advice message (or null) so the caller can optionally emit
@@ -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
@@ -7,6 +7,8 @@
7
7
  *
8
8
  * Output contract: one JSON line on stdout, or exit 0 silent.
9
9
  */
10
+ import { type HookEvent } from "../core/event-log.js";
11
+ export declare function buildSubagentAdoptionNudge(events: HookEvent[], now: number, windowDays?: number, minSample?: number, threshold?: number): string | null;
10
12
  export interface AgentEntry {
11
13
  name: string;
12
14
  description: string;
@@ -10,7 +10,43 @@
10
10
  import { readdir, readFile } from "node:fs/promises";
11
11
  import { join, basename } from "node:path";
12
12
  import { loadLatestSnapshot } from "./../handlers/session-snapshot-persist.js";
13
+ import { loadEvents } from "../core/event-log.js";
13
14
  const SNAPSHOT_FRESH_MS = 2 * 3600 * 1000; // 2h — enough to cover compaction/restart, tight enough that a new day's unrelated work doesn't inherit yesterday's thread
15
+ // ─── subagent adoption nudge (v0.32.0) ──────────────────────────────
16
+ // Pure function: takes the event log + current time, returns either a
17
+ // one-liner nudge string or null when there's nothing useful to say.
18
+ // Thresholds are module-level constants so tests can reference them.
19
+ const NUDGE_WINDOW_DAYS = 7;
20
+ /** Minimum Task events in window before we consider the sample big enough. */
21
+ const NUDGE_MIN_SAMPLE = 5;
22
+ /** Miss-rate (routable general-purpose dispatches / total) above which we nudge. */
23
+ const NUDGE_THRESHOLD = 0.5;
24
+ export function buildSubagentAdoptionNudge(events, now, windowDays = NUDGE_WINDOW_DAYS, minSample = NUDGE_MIN_SAMPLE, threshold = NUDGE_THRESHOLD) {
25
+ const cutoff = now - windowDays * 86_400_000;
26
+ const tasks = events.filter((e) => e.event === "task" && e.ts >= cutoff);
27
+ if (tasks.length < minSample)
28
+ return null;
29
+ const misses = tasks.filter((e) => typeof e.matched_tp_agent === "string" &&
30
+ e.matched_tp_agent.length > 0 &&
31
+ e.subagent_type !== e.matched_tp_agent);
32
+ if (misses.length === 0)
33
+ return null;
34
+ const rate = misses.length / tasks.length;
35
+ if (rate < threshold)
36
+ return null;
37
+ const pct = Math.round(rate * 100);
38
+ // Surface the top routing miss pair so the nudge is concrete, not abstract.
39
+ const pairCounts = new Map();
40
+ for (const m of misses) {
41
+ const key = `${m.subagent_type} → ${m.matched_tp_agent}`;
42
+ pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
43
+ }
44
+ const topPair = [...pairCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
45
+ const pairClause = topPair ? ` Top miss: ${topPair}.` : "";
46
+ return (`[token-pilot] subagent miss-rate ${pct}% over last ${windowDays}d ` +
47
+ `(${misses.length}/${tasks.length} Task calls could have used a tp-* specialist).${pairClause} ` +
48
+ `Run \`token-pilot stats --tasks\` for details, or set TOKEN_PILOT_FORCE_SUBAGENTS=1 to hard-block.`);
49
+ }
14
50
  function extractSnapshotGoal(body) {
15
51
  const m = body.match(/\*\*Goal:\*\*\s*(.+?)(?:\n|$)/);
16
52
  return m ? m[1].trim().slice(0, 100) : null;
@@ -215,6 +251,19 @@ export async function handleSessionStart(opts) {
215
251
  const goalClause = goal ? ` (goal: "${goal}")` : "";
216
252
  message += `\n\n[token-pilot] session_snapshot from ${age}${goalClause}. Read .token-pilot/snapshots/latest.md to resume — or ignore if unrelated.`;
217
253
  }
254
+ // v0.32.0 — subagent adoption nudge. Reads recent Task telemetry
255
+ // from hook-events.jsonl; when the main thread is picking
256
+ // general-purpose on routable work, surface a one-liner so the
257
+ // user / agent sees the miss rate without needing `stats --tasks`.
258
+ try {
259
+ const events = await loadEvents(opts.projectRoot);
260
+ const nudge = buildSubagentAdoptionNudge(events, Date.now());
261
+ if (nudge)
262
+ message += `\n\n${nudge}`;
263
+ }
264
+ catch {
265
+ /* silent — telemetry nudge is strictly opt-in */
266
+ }
218
267
  const output = {
219
268
  hookSpecificOutput: {
220
269
  hookEventName: "SessionStart",
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");