token-pilot 0.32.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 (46) 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 +12 -0
  37. package/dist/core/validation.js +38 -8
  38. package/dist/handlers/smart-log.js +7 -2
  39. package/dist/hooks/installer.d.ts +40 -0
  40. package/dist/hooks/installer.js +145 -2
  41. package/dist/hooks/pre-task.js +44 -10
  42. package/dist/hooks/safe-runner.d.ts +48 -0
  43. package/dist/hooks/safe-runner.js +73 -0
  44. package/dist/index.d.ts +11 -0
  45. package/dist/index.js +284 -63
  46. package/package.json +1 -1
@@ -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
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;