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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/agents/tp-api-surface-tracker.md +10 -2
- package/agents/tp-audit-scanner.md +10 -2
- package/agents/tp-commit-writer.md +10 -2
- package/agents/tp-context-engineer.md +10 -2
- package/agents/tp-dead-code-finder.md +10 -2
- package/agents/tp-debugger.md +10 -2
- package/agents/tp-dep-health.md +10 -2
- package/agents/tp-doc-writer.md +10 -2
- package/agents/tp-history-explorer.md +10 -2
- package/agents/tp-impact-analyzer.md +10 -2
- package/agents/tp-incident-timeline.md +10 -2
- package/agents/tp-incremental-builder.md +10 -2
- package/agents/tp-migration-scout.md +10 -2
- package/agents/tp-onboard.md +10 -2
- package/agents/tp-performance-profiler.md +10 -2
- package/agents/tp-pr-reviewer.md +10 -2
- package/agents/tp-refactor-planner.md +10 -2
- package/agents/tp-review-impact.md +10 -2
- package/agents/tp-run.md +10 -2
- package/agents/tp-session-restorer.md +10 -2
- package/agents/tp-ship-coordinator.md +10 -2
- package/agents/tp-spec-writer.md +10 -2
- package/agents/tp-test-coverage-gapper.md +10 -2
- package/agents/tp-test-triage.md +10 -2
- package/agents/tp-test-writer.md +10 -2
- package/dist/cli/stats.d.ts +2 -0
- package/dist/cli/stats.js +46 -1
- package/dist/core/agent-matcher.d.ts +115 -0
- package/dist/core/agent-matcher.js +326 -0
- package/dist/core/event-log.d.ts +14 -1
- package/dist/core/validation.d.ts +13 -9
- package/dist/core/validation.js +180 -134
- package/dist/handlers/call-tree.d.ts +35 -0
- package/dist/handlers/call-tree.js +70 -0
- package/dist/hooks/installer.js +9 -0
- package/dist/hooks/post-task.d.ts +15 -0
- package/dist/hooks/post-task.js +102 -19
- package/dist/hooks/pre-task.d.ts +71 -0
- package/dist/hooks/pre-task.js +125 -0
- package/dist/hooks/session-start.d.ts +2 -0
- package/dist/hooks/session-start.js +49 -0
- package/dist/index.js +29 -0
- package/dist/server/tool-definitions.d.ts +65 -0
- package/dist/server/tool-definitions.js +18 -0
- package/dist/server.js +36 -1
- package/hooks/hooks.json +9 -0
- 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
|
package/dist/hooks/installer.js
CHANGED
|
@@ -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
|
package/dist/hooks/post-task.js
CHANGED
|
@@ -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
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|