token-pilot 0.31.0 → 0.33.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 +1 -1
- package/agents/tp-audit-scanner.md +1 -1
- package/agents/tp-commit-writer.md +1 -1
- package/agents/tp-context-engineer.md +1 -1
- package/agents/tp-dead-code-finder.md +1 -1
- package/agents/tp-debugger.md +1 -1
- package/agents/tp-dep-health.md +1 -1
- package/agents/tp-doc-writer.md +1 -1
- package/agents/tp-history-explorer.md +1 -1
- package/agents/tp-impact-analyzer.md +1 -1
- package/agents/tp-incident-timeline.md +1 -1
- package/agents/tp-incremental-builder.md +1 -1
- package/agents/tp-migration-scout.md +1 -1
- package/agents/tp-onboard.md +1 -1
- package/agents/tp-performance-profiler.md +1 -1
- package/agents/tp-pr-reviewer.md +1 -1
- package/agents/tp-refactor-planner.md +1 -1
- package/agents/tp-review-impact.md +1 -1
- package/agents/tp-run.md +1 -1
- package/agents/tp-session-restorer.md +1 -1
- package/agents/tp-ship-coordinator.md +1 -1
- package/agents/tp-spec-writer.md +1 -1
- package/agents/tp-test-coverage-gapper.md +1 -1
- package/agents/tp-test-triage.md +1 -1
- package/agents/tp-test-writer.md +1 -1
- package/dist/ast-index/client.js +17 -1
- package/dist/cli/install-agents.d.ts +18 -0
- package/dist/cli/install-agents.js +88 -1
- package/dist/cli/stats.js +9 -2
- package/dist/core/error-log.d.ts +86 -0
- package/dist/core/error-log.js +228 -0
- package/dist/core/event-log.d.ts +49 -1
- package/dist/core/event-log.js +114 -0
- package/dist/core/validation.d.ts +25 -9
- package/dist/core/validation.js +212 -136
- package/dist/handlers/call-tree.d.ts +35 -0
- package/dist/handlers/call-tree.js +70 -0
- package/dist/handlers/smart-log.js +7 -2
- package/dist/hooks/installer.d.ts +40 -0
- package/dist/hooks/installer.js +145 -2
- package/dist/hooks/pre-task.js +44 -10
- package/dist/hooks/safe-runner.d.ts +48 -0
- package/dist/hooks/safe-runner.js +73 -0
- package/dist/hooks/session-start.d.ts +2 -0
- package/dist/hooks/session-start.js +49 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +284 -63
- package/dist/server/tool-definitions.d.ts +65 -0
- package/dist/server/tool-definitions.js +18 -0
- package/dist/server.js +36 -1
- 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
|
|
@@ -14,14 +14,19 @@ const MAX_COUNT = 50;
|
|
|
14
14
|
export async function handleSmartLog(args, projectRoot) {
|
|
15
15
|
const count = Math.min(args.count ?? 10, MAX_COUNT);
|
|
16
16
|
const ref = args.ref ?? 'HEAD';
|
|
17
|
-
// Build git log command with --numstat for file stats
|
|
17
|
+
// Build git log command with --numstat for file stats.
|
|
18
|
+
// v0.33.0 (B6) — `ref` MUST be a revision argument, not a pathspec.
|
|
19
|
+
// The previous version pushed `'--', ref` which made git interpret
|
|
20
|
+
// `HEAD` as a path (`git log -- HEAD`) and silently returned empty.
|
|
21
|
+
// Adding a path then produced `git log -- HEAD -- foo.ts` — invalid.
|
|
22
|
+
// Correct order: `git log <flags> <ref> [-- <path>]`.
|
|
18
23
|
const gitArgs = [
|
|
19
24
|
'log',
|
|
20
25
|
`--format=${RECORD_SEPARATOR}%h${FIELD_SEPARATOR}%ad${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%s`,
|
|
21
26
|
'--date=short',
|
|
22
27
|
'--numstat',
|
|
23
28
|
`-n`, `${count}`,
|
|
24
|
-
|
|
29
|
+
ref,
|
|
25
30
|
];
|
|
26
31
|
if (args.path) {
|
|
27
32
|
gitArgs.push('--', args.path);
|
|
@@ -14,6 +14,20 @@ export interface HookInstallOptions {
|
|
|
14
14
|
/** Absolute path to the node binary. Defaults to process.execPath. */
|
|
15
15
|
nodeExecPath?: string;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Detect a stale token-pilot hook command — one that points at a
|
|
19
|
+
* pinned npx-cache snapshot (`npx/_npx/<hash>/...`) or any other
|
|
20
|
+
* version-pinned path that won't follow plugin upgrades.
|
|
21
|
+
*
|
|
22
|
+
* v0.33.0 fix: users who ran `npx token-pilot init` early on got
|
|
23
|
+
* settings.json entries with literal `~/.npm/_npx/<hash>/...` paths.
|
|
24
|
+
* When the npx cache rotates or token-pilot publishes a new minor,
|
|
25
|
+
* those entries silently call the old binary, missing every hook
|
|
26
|
+
* shipped after install (e.g. v0.31.0 Task hooks). Removing the
|
|
27
|
+
* stale entry lets the next install or the bundled plugin's
|
|
28
|
+
* `hooks/hooks.json` (CLAUDE_PLUGIN_ROOT) take over.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isStaleTokenPilotHookCommand(cmd: unknown): boolean;
|
|
17
31
|
/**
|
|
18
32
|
* Install Token Pilot hook into Claude Code settings.
|
|
19
33
|
* Creates or updates .claude/settings.json with PreToolUse hook.
|
|
@@ -23,4 +37,30 @@ export declare function installHook(projectRoot: string, options?: HookInstallOp
|
|
|
23
37
|
* Remove Token Pilot hook from Claude Code settings.
|
|
24
38
|
*/
|
|
25
39
|
export declare function uninstallHook(projectRoot: string): Promise<HookUninstallResult>;
|
|
40
|
+
export interface CleanStaleResult {
|
|
41
|
+
scanned: string[];
|
|
42
|
+
cleaned: string[];
|
|
43
|
+
staleEntriesRemoved: number;
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Scan a settings.json (user-level or project-level) and remove every
|
|
48
|
+
* token-pilot hook entry whose command points at a pinned npx-cache
|
|
49
|
+
* snapshot or a literal plugin-cache version path. The plugin's bundled
|
|
50
|
+
* `hooks/hooks.json` (resolved through `${CLAUDE_PLUGIN_ROOT}` at
|
|
51
|
+
* runtime) supersedes them.
|
|
52
|
+
*
|
|
53
|
+
* Pure-ish: writes only when something changed. Never throws — bad JSON
|
|
54
|
+
* or missing file are reported in the result so callers (CLI, init)
|
|
55
|
+
* can surface them without aborting.
|
|
56
|
+
*/
|
|
57
|
+
export declare function cleanStaleHookEntries(settingsPath: string): Promise<CleanStaleResult>;
|
|
58
|
+
/**
|
|
59
|
+
* Inspect `~/.claude/settings.json` to determine whether the user has
|
|
60
|
+
* enabled the bundled `token-pilot` plugin in Claude Code. When true,
|
|
61
|
+
* the plugin's own `hooks/hooks.json` is the source of truth and any
|
|
62
|
+
* additional hook entries written by the npm CLI are duplicates that
|
|
63
|
+
* also lock the user to whichever binary path the CLI captured.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isTokenPilotPluginEnabled(homeDir: string): Promise<boolean>;
|
|
26
66
|
//# sourceMappingURL=installer.d.ts.map
|
package/dist/hooks/installer.js
CHANGED
|
@@ -12,6 +12,40 @@ function buildHookCommand(action, options) {
|
|
|
12
12
|
}
|
|
13
13
|
return `token-pilot ${action}`;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Detect a stale token-pilot hook command — one that points at a
|
|
17
|
+
* pinned npx-cache snapshot (`npx/_npx/<hash>/...`) or any other
|
|
18
|
+
* version-pinned path that won't follow plugin upgrades.
|
|
19
|
+
*
|
|
20
|
+
* v0.33.0 fix: users who ran `npx token-pilot init` early on got
|
|
21
|
+
* settings.json entries with literal `~/.npm/_npx/<hash>/...` paths.
|
|
22
|
+
* When the npx cache rotates or token-pilot publishes a new minor,
|
|
23
|
+
* those entries silently call the old binary, missing every hook
|
|
24
|
+
* shipped after install (e.g. v0.31.0 Task hooks). Removing the
|
|
25
|
+
* stale entry lets the next install or the bundled plugin's
|
|
26
|
+
* `hooks/hooks.json` (CLAUDE_PLUGIN_ROOT) take over.
|
|
27
|
+
*/
|
|
28
|
+
export function isStaleTokenPilotHookCommand(cmd) {
|
|
29
|
+
if (typeof cmd !== "string")
|
|
30
|
+
return false;
|
|
31
|
+
if (!cmd.includes("token-pilot"))
|
|
32
|
+
return false;
|
|
33
|
+
// npm/npx cache hash — always stale (will rotate)
|
|
34
|
+
if (/\/_npx\/[0-9a-f]+\//.test(cmd))
|
|
35
|
+
return true;
|
|
36
|
+
// Pinned plugin-cache version path — old version that may not
|
|
37
|
+
// contain a hook handler the new settings entry references.
|
|
38
|
+
// Match `/plugins/cache/token-pilot/token-pilot/<version>/`.
|
|
39
|
+
const pinned = cmd.match(/\/plugins\/cache\/token-pilot\/token-pilot\/([^/]+)\//);
|
|
40
|
+
if (pinned) {
|
|
41
|
+
// The plugin runtime always uses ${CLAUDE_PLUGIN_ROOT} which
|
|
42
|
+
// resolves to the *current* version dir. A literal version in
|
|
43
|
+
// the path means someone wrote it from a CLI that captured the
|
|
44
|
+
// dir at install time — stale by definition.
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
15
49
|
function createHookConfig(options) {
|
|
16
50
|
return {
|
|
17
51
|
hooks: {
|
|
@@ -154,9 +188,11 @@ export async function installHook(projectRoot, options) {
|
|
|
154
188
|
const isTokenPilotHook = (h) => h.hooks?.some((hook) => hook.command?.includes("token-pilot"));
|
|
155
189
|
if (Array.isArray(existingHooks)) {
|
|
156
190
|
// Remove old broken hooks (bare "token-pilot" without absolute path)
|
|
157
|
-
//
|
|
191
|
+
// OR stale npx-cache / pinned-version paths (v0.33.0)
|
|
192
|
+
// and replace with working ones using absolute paths.
|
|
158
193
|
const oldBrokenHooks = existingHooks.filter((h) => isTokenPilotHook(h) &&
|
|
159
|
-
h.hooks?.some((hook) => hook.command?.match(/^token-pilot\s/)
|
|
194
|
+
h.hooks?.some((hook) => hook.command?.match(/^token-pilot\s/) ||
|
|
195
|
+
isStaleTokenPilotHookCommand(hook.command)));
|
|
160
196
|
if (oldBrokenHooks.length > 0 && options?.scriptPath) {
|
|
161
197
|
// Remove old broken hooks, will re-add with absolute paths below
|
|
162
198
|
settings.hooks.PreToolUse = existingHooks.filter((h) => !isTokenPilotHook(h));
|
|
@@ -309,4 +345,111 @@ export async function uninstallHook(projectRoot) {
|
|
|
309
345
|
};
|
|
310
346
|
}
|
|
311
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* Scan a settings.json (user-level or project-level) and remove every
|
|
350
|
+
* token-pilot hook entry whose command points at a pinned npx-cache
|
|
351
|
+
* snapshot or a literal plugin-cache version path. The plugin's bundled
|
|
352
|
+
* `hooks/hooks.json` (resolved through `${CLAUDE_PLUGIN_ROOT}` at
|
|
353
|
+
* runtime) supersedes them.
|
|
354
|
+
*
|
|
355
|
+
* Pure-ish: writes only when something changed. Never throws — bad JSON
|
|
356
|
+
* or missing file are reported in the result so callers (CLI, init)
|
|
357
|
+
* can surface them without aborting.
|
|
358
|
+
*/
|
|
359
|
+
export async function cleanStaleHookEntries(settingsPath) {
|
|
360
|
+
const result = {
|
|
361
|
+
scanned: [settingsPath],
|
|
362
|
+
cleaned: [],
|
|
363
|
+
staleEntriesRemoved: 0,
|
|
364
|
+
message: "",
|
|
365
|
+
};
|
|
366
|
+
let raw;
|
|
367
|
+
try {
|
|
368
|
+
raw = await readFile(settingsPath, "utf-8");
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
if (err?.code === "ENOENT") {
|
|
372
|
+
result.message = `No settings at ${settingsPath} — nothing to migrate.`;
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
result.message = `Cannot read ${settingsPath}: ${err?.message ?? err}`;
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
let settings;
|
|
379
|
+
try {
|
|
380
|
+
settings = JSON.parse(raw);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
result.message = `Invalid JSON in ${settingsPath} — skipped (fix manually).`;
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
const sections = ["PreToolUse", "PostToolUse", "SessionStart"];
|
|
387
|
+
let removed = 0;
|
|
388
|
+
for (const section of sections) {
|
|
389
|
+
const arr = settings.hooks?.[section];
|
|
390
|
+
if (!Array.isArray(arr))
|
|
391
|
+
continue;
|
|
392
|
+
const filtered = arr.filter((entry) => {
|
|
393
|
+
const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
394
|
+
const hasStale = inner.some((h) => isStaleTokenPilotHookCommand(h?.command));
|
|
395
|
+
if (hasStale) {
|
|
396
|
+
removed++;
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
return true;
|
|
400
|
+
});
|
|
401
|
+
if (filtered.length !== arr.length) {
|
|
402
|
+
if (filtered.length === 0) {
|
|
403
|
+
delete settings.hooks[section];
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
settings.hooks[section] = filtered;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (removed === 0) {
|
|
411
|
+
result.message = `No stale token-pilot hook entries in ${settingsPath}.`;
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
// Drop empty hooks container so JSON stays clean.
|
|
415
|
+
if (settings.hooks &&
|
|
416
|
+
typeof settings.hooks === "object" &&
|
|
417
|
+
Object.keys(settings.hooks).length === 0) {
|
|
418
|
+
delete settings.hooks;
|
|
419
|
+
}
|
|
420
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
421
|
+
result.cleaned.push(settingsPath);
|
|
422
|
+
result.staleEntriesRemoved = removed;
|
|
423
|
+
result.message = `Removed ${removed} stale token-pilot hook entr${removed === 1 ? "y" : "ies"} from ${settingsPath}.`;
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Inspect `~/.claude/settings.json` to determine whether the user has
|
|
428
|
+
* enabled the bundled `token-pilot` plugin in Claude Code. When true,
|
|
429
|
+
* the plugin's own `hooks/hooks.json` is the source of truth and any
|
|
430
|
+
* additional hook entries written by the npm CLI are duplicates that
|
|
431
|
+
* also lock the user to whichever binary path the CLI captured.
|
|
432
|
+
*/
|
|
433
|
+
export async function isTokenPilotPluginEnabled(homeDir) {
|
|
434
|
+
const settingsPath = resolve(homeDir, ".claude", "settings.json");
|
|
435
|
+
let raw;
|
|
436
|
+
try {
|
|
437
|
+
raw = await readFile(settingsPath, "utf-8");
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
let settings;
|
|
443
|
+
try {
|
|
444
|
+
settings = JSON.parse(raw);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
const enabled = settings?.enabledPlugins;
|
|
450
|
+
if (!enabled || typeof enabled !== "object")
|
|
451
|
+
return false;
|
|
452
|
+
// keys look like `token-pilot@token-pilot` — match prefix.
|
|
453
|
+
return Object.entries(enabled).some(([key, val]) => val === true && typeof key === "string" && key.startsWith("token-pilot@"));
|
|
454
|
+
}
|
|
312
455
|
//# sourceMappingURL=installer.js.map
|
package/dist/hooks/pre-task.js
CHANGED
|
@@ -54,6 +54,19 @@ function containsEscape(description) {
|
|
|
54
54
|
const n = description.toLowerCase();
|
|
55
55
|
return ESCAPE_PHRASES.some((p) => n.includes(p));
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* v0.33.0 (B14) — generic context appended to every advice payload
|
|
59
|
+
* for non-tp-* dispatches. Subagents like `general-purpose` and
|
|
60
|
+
* `code-analyzer` don't know about the token-pilot MCP tools and
|
|
61
|
+
* loop on raw `Read` even after `hook-pre-read` denies them. This
|
|
62
|
+
* paragraph lands in their context window before they take their
|
|
63
|
+
* first action and tells them what to use instead.
|
|
64
|
+
*/
|
|
65
|
+
const SUBAGENT_TOOL_GUIDE = "When working in this task: prefer `mcp__token-pilot__smart_read` " +
|
|
66
|
+
"(file structure), `read_symbol` (one function/class), and " +
|
|
67
|
+
"`find_usages` (semantic search) over raw Read/Grep. The token-pilot " +
|
|
68
|
+
"PreToolUse hooks block large-file Read and unbounded Grep — use " +
|
|
69
|
+
"the MCP tools or pass `offset`/`limit` to Read.";
|
|
57
70
|
/**
|
|
58
71
|
* Pure decision function. Caller resolves all context (env, mode,
|
|
59
72
|
* agent index) up front so this stays a plain input → output mapping.
|
|
@@ -67,23 +80,44 @@ export function decidePreTask(input, ctx) {
|
|
|
67
80
|
if (typeof subagentType === "string" && subagentType.startsWith("tp-")) {
|
|
68
81
|
return { kind: "allow" };
|
|
69
82
|
}
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
// v0.33.0 (B4) — TOKEN_PILOT_FORCE_SUBAGENTS=1 with an empty agent
|
|
84
|
+
// catalog used to silently allow every Task call (no matches → no
|
|
85
|
+
// suggestion). That defeats the env's only purpose. Fail loud
|
|
86
|
+
// instead: tell the user to install the templates.
|
|
87
|
+
const indexEmpty = !ctx.agentIndex.agents || ctx.agentIndex.agents.length === 0;
|
|
88
|
+
if (ctx.force && indexEmpty) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "deny",
|
|
91
|
+
reason: "TOKEN_PILOT_FORCE_SUBAGENTS=1 is set but no tp-* agents are " +
|
|
92
|
+
"installed in this project (or `~/.claude/agents/`). " +
|
|
93
|
+
"Run `npx token-pilot install-agents --scope=project` first, " +
|
|
94
|
+
"or unset TOKEN_PILOT_FORCE_SUBAGENTS.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// No description → nothing to match against. Inject the generic
|
|
98
|
+
// tool-guide so the subagent still picks tp-tools (B14).
|
|
99
|
+
if (!description || description.length === 0) {
|
|
100
|
+
return { kind: "advise", message: SUBAGENT_TOOL_GUIDE };
|
|
101
|
+
}
|
|
74
102
|
// Author-blessed escape clauses — user is explicitly saying
|
|
75
|
-
// "this is broad".
|
|
76
|
-
if (containsEscape(description))
|
|
77
|
-
return { kind: "
|
|
103
|
+
// "this is broad". Inject the tool-guide but no agent suggestion.
|
|
104
|
+
if (containsEscape(description)) {
|
|
105
|
+
return { kind: "advise", message: SUBAGENT_TOOL_GUIDE };
|
|
106
|
+
}
|
|
78
107
|
const hit = matchTpAgent(description, ctx.agentIndex);
|
|
79
|
-
if (!hit)
|
|
80
|
-
|
|
108
|
+
if (!hit) {
|
|
109
|
+
// No specific tp-* match. Still send the generic tool-guide so
|
|
110
|
+
// the subagent learns about smart_read / read_symbol — covers the
|
|
111
|
+
// common code-analyzer / general-purpose loop on raw Read (B14).
|
|
112
|
+
return { kind: "advise", message: SUBAGENT_TOOL_GUIDE };
|
|
113
|
+
}
|
|
81
114
|
const suggestion = `Consider dispatching \`${hit.agent}\` instead of \`${subagentType || "general-purpose"}\` — ` +
|
|
82
115
|
`the description matches its trigger phrases (confidence: ${hit.confidence}). ` +
|
|
83
116
|
`tp-* agents run under a tighter budget and output in terse style, typically ` +
|
|
84
117
|
`~50-70 % fewer tokens than general-purpose. ` +
|
|
85
118
|
`Escape: add "ad-hoc" or "open-ended" to the description to bypass, or set ` +
|
|
86
|
-
`TOKEN_PILOT_MODE=advisory for warn-only behaviour
|
|
119
|
+
`TOKEN_PILOT_MODE=advisory for warn-only behaviour.\n\n` +
|
|
120
|
+
SUBAGENT_TOOL_GUIDE;
|
|
87
121
|
const hardBlock = ctx.force ||
|
|
88
122
|
ctx.mode === "strict" ||
|
|
89
123
|
(ctx.mode === "deny" && hit.confidence === "high" && ctx.force);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.34.0 — `runHookSafely` / `runHookEntryPoint`.
|
|
3
|
+
*
|
|
4
|
+
* Every token-pilot hook used to wrap its own try/catch. When the
|
|
5
|
+
* branch broke (B2 stale binary, B8 bad cwd, B10 ast-index init
|
|
6
|
+
* race), throws were swallowed silently and the user saw "nothing
|
|
7
|
+
* happens". This wrapper centralises the discipline:
|
|
8
|
+
*
|
|
9
|
+
* - Run the hook body
|
|
10
|
+
* - On throw → record one structured `HookErrorRecord` to the
|
|
11
|
+
* user-level error log
|
|
12
|
+
* - Optionally measure duration and emit a `diagnostic` event
|
|
13
|
+
* (Pack 3 — timing)
|
|
14
|
+
* - ALWAYS exit 0 — Claude Code must never see a hook error
|
|
15
|
+
* because that aborts the user's tool call.
|
|
16
|
+
*
|
|
17
|
+
* Hooks themselves keep responsibility for emitting domain-level
|
|
18
|
+
* diagnostics (matcher empty, WSL reject, etc.) — this wrapper is
|
|
19
|
+
* the safety net for unexpected throws.
|
|
20
|
+
*/
|
|
21
|
+
export interface RunHookOptions {
|
|
22
|
+
/** Hook name (matcher in hooks.json — e.g. "hook-pre-task"). */
|
|
23
|
+
hook: string;
|
|
24
|
+
/** Optional safe summary of the hook input — sanitised by caller. */
|
|
25
|
+
inputSummary?: Record<string, unknown>;
|
|
26
|
+
/** Plugin version, captured by caller and forwarded to the log. */
|
|
27
|
+
pluginVersion?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Run a hook body and swallow any throw into the structured error log.
|
|
31
|
+
* Returns true on success, false on caught error — useful when the
|
|
32
|
+
* caller still wants to take a fallback action (e.g. emit a generic
|
|
33
|
+
* permissionDecision to Claude before exiting).
|
|
34
|
+
*/
|
|
35
|
+
export declare function runHookSafely(options: RunHookOptions, fn: () => Promise<void> | void): Promise<boolean>;
|
|
36
|
+
/**
|
|
37
|
+
* The full entry-point wrapper used by `index.ts` cases. Wraps
|
|
38
|
+
* `runHookSafely` and additionally guarantees `process.exit(0)` at
|
|
39
|
+
* the end so a stray throw cannot leak a non-zero status to Claude.
|
|
40
|
+
*
|
|
41
|
+
* Pack 3: optionally measures duration and forwards it to the
|
|
42
|
+
* caller via `onTiming` so the timing diagnostic can be emitted
|
|
43
|
+
* after the hook body decided what to log to hook-events.jsonl.
|
|
44
|
+
*/
|
|
45
|
+
export declare function runHookEntryPoint(options: RunHookOptions & {
|
|
46
|
+
onTiming?: (durationMs: number) => Promise<void> | void;
|
|
47
|
+
}, fn: () => Promise<void> | void): Promise<never>;
|
|
48
|
+
//# sourceMappingURL=safe-runner.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.34.0 — `runHookSafely` / `runHookEntryPoint`.
|
|
3
|
+
*
|
|
4
|
+
* Every token-pilot hook used to wrap its own try/catch. When the
|
|
5
|
+
* branch broke (B2 stale binary, B8 bad cwd, B10 ast-index init
|
|
6
|
+
* race), throws were swallowed silently and the user saw "nothing
|
|
7
|
+
* happens". This wrapper centralises the discipline:
|
|
8
|
+
*
|
|
9
|
+
* - Run the hook body
|
|
10
|
+
* - On throw → record one structured `HookErrorRecord` to the
|
|
11
|
+
* user-level error log
|
|
12
|
+
* - Optionally measure duration and emit a `diagnostic` event
|
|
13
|
+
* (Pack 3 — timing)
|
|
14
|
+
* - ALWAYS exit 0 — Claude Code must never see a hook error
|
|
15
|
+
* because that aborts the user's tool call.
|
|
16
|
+
*
|
|
17
|
+
* Hooks themselves keep responsibility for emitting domain-level
|
|
18
|
+
* diagnostics (matcher empty, WSL reject, etc.) — this wrapper is
|
|
19
|
+
* the safety net for unexpected throws.
|
|
20
|
+
*/
|
|
21
|
+
import { appendError, classifyError } from "../core/error-log.js";
|
|
22
|
+
/**
|
|
23
|
+
* Run a hook body and swallow any throw into the structured error log.
|
|
24
|
+
* Returns true on success, false on caught error — useful when the
|
|
25
|
+
* caller still wants to take a fallback action (e.g. emit a generic
|
|
26
|
+
* permissionDecision to Claude before exiting).
|
|
27
|
+
*/
|
|
28
|
+
export async function runHookSafely(options, fn) {
|
|
29
|
+
try {
|
|
30
|
+
await fn();
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
const e = err;
|
|
35
|
+
await appendError({
|
|
36
|
+
ts: Date.now(),
|
|
37
|
+
hook: options.hook,
|
|
38
|
+
level: "error",
|
|
39
|
+
code: classifyError(err),
|
|
40
|
+
msg: e?.message ?? String(err),
|
|
41
|
+
stack: e?.stack,
|
|
42
|
+
input: options.inputSummary,
|
|
43
|
+
pluginVersion: options.pluginVersion,
|
|
44
|
+
nodeVersion: process.version,
|
|
45
|
+
platform: process.platform,
|
|
46
|
+
});
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The full entry-point wrapper used by `index.ts` cases. Wraps
|
|
52
|
+
* `runHookSafely` and additionally guarantees `process.exit(0)` at
|
|
53
|
+
* the end so a stray throw cannot leak a non-zero status to Claude.
|
|
54
|
+
*
|
|
55
|
+
* Pack 3: optionally measures duration and forwards it to the
|
|
56
|
+
* caller via `onTiming` so the timing diagnostic can be emitted
|
|
57
|
+
* after the hook body decided what to log to hook-events.jsonl.
|
|
58
|
+
*/
|
|
59
|
+
export async function runHookEntryPoint(options, fn) {
|
|
60
|
+
const started = Date.now();
|
|
61
|
+
await runHookSafely(options, fn);
|
|
62
|
+
const durationMs = Date.now() - started;
|
|
63
|
+
if (options.onTiming) {
|
|
64
|
+
try {
|
|
65
|
+
await options.onTiming(durationMs);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* timing emit must never affect exit */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=safe-runner.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.d.ts
CHANGED
|
@@ -15,6 +15,17 @@ export declare function main(cliArgs?: string[]): Promise<void>;
|
|
|
15
15
|
* dev installs (cloning the repo and running against itself stays legal).
|
|
16
16
|
*/
|
|
17
17
|
export declare function looksLikePluginCacheDir(candidate: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* v0.33.0 (B8) — reject candidates that are obviously not a project
|
|
20
|
+
* directory. Triggered by WSL launches where the shell starts in
|
|
21
|
+
* `C:\Windows\System32`, `/mnt/c/Windows/...`, or a UNC path. Without
|
|
22
|
+
* this guard, `git rev-parse --show-toplevel` either fails noisily or
|
|
23
|
+
* returns the Windows tree, leaving every subsequent git/MCP call
|
|
24
|
+
* looking at the wrong filesystem.
|
|
25
|
+
*
|
|
26
|
+
* Conservative — only matches paths we are certain are not user code.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isWindowsSystemPath(candidate: string): boolean;
|
|
18
29
|
export declare function startServer(cliArgs?: string[]): Promise<void>;
|
|
19
30
|
export interface HookReadAdaptiveOptions {
|
|
20
31
|
adaptiveThreshold?: boolean;
|