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.
Files changed (53) 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 +1 -1
  4. package/agents/tp-audit-scanner.md +1 -1
  5. package/agents/tp-commit-writer.md +1 -1
  6. package/agents/tp-context-engineer.md +1 -1
  7. package/agents/tp-dead-code-finder.md +1 -1
  8. package/agents/tp-debugger.md +1 -1
  9. package/agents/tp-dep-health.md +1 -1
  10. package/agents/tp-doc-writer.md +1 -1
  11. package/agents/tp-history-explorer.md +1 -1
  12. package/agents/tp-impact-analyzer.md +1 -1
  13. package/agents/tp-incident-timeline.md +1 -1
  14. package/agents/tp-incremental-builder.md +1 -1
  15. package/agents/tp-migration-scout.md +1 -1
  16. package/agents/tp-onboard.md +1 -1
  17. package/agents/tp-performance-profiler.md +1 -1
  18. package/agents/tp-pr-reviewer.md +1 -1
  19. package/agents/tp-refactor-planner.md +1 -1
  20. package/agents/tp-review-impact.md +1 -1
  21. package/agents/tp-run.md +1 -1
  22. package/agents/tp-session-restorer.md +1 -1
  23. package/agents/tp-ship-coordinator.md +1 -1
  24. package/agents/tp-spec-writer.md +1 -1
  25. package/agents/tp-test-coverage-gapper.md +1 -1
  26. package/agents/tp-test-triage.md +1 -1
  27. package/agents/tp-test-writer.md +1 -1
  28. package/dist/ast-index/client.js +17 -1
  29. package/dist/cli/install-agents.d.ts +18 -0
  30. package/dist/cli/install-agents.js +88 -1
  31. package/dist/cli/stats.js +9 -2
  32. package/dist/core/error-log.d.ts +86 -0
  33. package/dist/core/error-log.js +228 -0
  34. package/dist/core/event-log.d.ts +49 -1
  35. package/dist/core/event-log.js +114 -0
  36. package/dist/core/validation.d.ts +25 -9
  37. package/dist/core/validation.js +212 -136
  38. package/dist/handlers/call-tree.d.ts +35 -0
  39. package/dist/handlers/call-tree.js +70 -0
  40. package/dist/handlers/smart-log.js +7 -2
  41. package/dist/hooks/installer.d.ts +40 -0
  42. package/dist/hooks/installer.js +145 -2
  43. package/dist/hooks/pre-task.js +44 -10
  44. package/dist/hooks/safe-runner.d.ts +48 -0
  45. package/dist/hooks/safe-runner.js +73 -0
  46. package/dist/hooks/session-start.d.ts +2 -0
  47. package/dist/hooks/session-start.js +49 -0
  48. package/dist/index.d.ts +11 -0
  49. package/dist/index.js +284 -63
  50. package/dist/server/tool-definitions.d.ts +65 -0
  51. package/dist/server/tool-definitions.js +18 -0
  52. package/dist/server.js +36 -1
  53. 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
- '--', ref,
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
@@ -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
- // and replace with working ones using absolute paths
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
@@ -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
- // 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" };
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". Respect that.
76
- if (containsEscape(description))
77
- return { kind: "allow" };
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
- return { kind: "allow" };
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;