token-pilot 0.29.0 → 0.30.1

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 (65) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -4
  3. package/CHANGELOG.md +35 -0
  4. package/README.md +57 -384
  5. package/agents/tp-api-surface-tracker.md +1 -1
  6. package/agents/tp-audit-scanner.md +1 -1
  7. package/agents/tp-commit-writer.md +1 -1
  8. package/agents/tp-context-engineer.md +1 -1
  9. package/agents/tp-dead-code-finder.md +1 -1
  10. package/agents/tp-debugger.md +1 -1
  11. package/agents/tp-dep-health.md +1 -1
  12. package/agents/tp-doc-writer.md +1 -1
  13. package/agents/tp-history-explorer.md +1 -1
  14. package/agents/tp-impact-analyzer.md +1 -1
  15. package/agents/tp-incident-timeline.md +1 -1
  16. package/agents/tp-incremental-builder.md +1 -1
  17. package/agents/tp-migration-scout.md +1 -1
  18. package/agents/tp-onboard.md +1 -1
  19. package/agents/tp-performance-profiler.md +1 -1
  20. package/agents/tp-pr-reviewer.md +1 -1
  21. package/agents/tp-refactor-planner.md +1 -1
  22. package/agents/tp-review-impact.md +1 -1
  23. package/agents/tp-run.md +1 -1
  24. package/agents/tp-session-restorer.md +1 -1
  25. package/agents/tp-ship-coordinator.md +1 -1
  26. package/agents/tp-spec-writer.md +1 -1
  27. package/agents/tp-test-coverage-gapper.md +1 -1
  28. package/agents/tp-test-triage.md +1 -1
  29. package/agents/tp-test-writer.md +1 -1
  30. package/dist/ast-index/client.d.ts +17 -2
  31. package/dist/ast-index/client.js +233 -107
  32. package/dist/cli/tool-audit.d.ts +5 -0
  33. package/dist/cli/tool-audit.js +9 -1
  34. package/dist/core/edit-prep-state.d.ts +42 -0
  35. package/dist/core/edit-prep-state.js +108 -0
  36. package/dist/core/policy-engine.d.ts +1 -5
  37. package/dist/core/policy-engine.js +9 -24
  38. package/dist/handlers/explore-area.js +6 -1
  39. package/dist/handlers/read-for-edit.d.ts +5 -5
  40. package/dist/handlers/read-for-edit.js +188 -110
  41. package/dist/hooks/installer.js +18 -0
  42. package/dist/hooks/pre-bash.d.ts +11 -1
  43. package/dist/hooks/pre-bash.js +51 -1
  44. package/dist/hooks/pre-edit.d.ts +69 -0
  45. package/dist/hooks/pre-edit.js +104 -0
  46. package/dist/hooks/pre-grep.d.ts +12 -1
  47. package/dist/hooks/pre-grep.js +39 -1
  48. package/dist/index.d.ts +30 -0
  49. package/dist/index.js +87 -22
  50. package/dist/server/enforcement-mode.d.ts +47 -0
  51. package/dist/server/enforcement-mode.js +59 -0
  52. package/dist/server/tool-definitions.d.ts +20 -0
  53. package/dist/server/tool-definitions.js +127 -12
  54. package/dist/server/tool-profiles.d.ts +19 -1
  55. package/dist/server/tool-profiles.js +38 -4
  56. package/dist/server.d.ts +2 -0
  57. package/dist/server.js +89 -21
  58. package/docs/agents.md +82 -0
  59. package/docs/configuration.md +117 -0
  60. package/docs/hooks.md +99 -0
  61. package/docs/installation.md +169 -0
  62. package/docs/tools.md +61 -0
  63. package/hooks/hooks.json +18 -0
  64. package/package.json +2 -2
  65. package/start.sh +19 -9
@@ -141,9 +141,50 @@ function detectHeavyPatternSingle(command) {
141
141
  "or `git diff --stat` / `git diff <path>` to scope. Re-run scoped to bypass.",
142
142
  };
143
143
  }
144
+ // 6. Test runners — suggest test_summary. Advisory only (allow + hint):
145
+ // tests are legitimate to run; we just want the token-lean summary by
146
+ // default. Tool-audit 2026-04-24 showed test_summary = 0 calls across
147
+ // three real projects — agents always go straight to the raw runner.
148
+ if (isTestRunnerCommand(cmd)) {
149
+ return {
150
+ kind: "advise",
151
+ reason: "Running tests via raw command dumps stdout into context. " +
152
+ 'Prefer mcp__token-pilot__test_summary(command="<your runner>") — ' +
153
+ "returns structured pass/fail/flaky counts and only the failing output, " +
154
+ "typically 70-90% fewer tokens than raw runner output.",
155
+ };
156
+ }
144
157
  return { kind: "allow" };
145
158
  }
146
- export function decidePreBash(input) {
159
+ /**
160
+ * Detect common test-runner invocations. Returns true for anything we'd
161
+ * route through `test_summary`. Kept as a pure string test so it's unit-
162
+ * testable without spinning up child processes.
163
+ */
164
+ export function isTestRunnerCommand(cmd) {
165
+ const trimmed = cmd.trim();
166
+ if (!trimmed)
167
+ return false;
168
+ // npm/yarn/pnpm run test[:suite], yarn workspace <x> test, etc.
169
+ if (/\b(?:npm|yarn|pnpm)\s+(?:run\s+)?test(?:[:\s]|$)/.test(trimmed)) {
170
+ return true;
171
+ }
172
+ if (/\byarn\s+workspace\s+\S+\s+test\b/.test(trimmed))
173
+ return true;
174
+ // Direct runner invocations (bare or via npx / pnpx / dlx wrappers)
175
+ if (/\b(?:npx|pnpx|pnpm dlx|yarn dlx)?\s*(?:vitest|jest|mocha|phpunit|rspec|pytest)\b/.test(trimmed)) {
176
+ return true;
177
+ }
178
+ // Go / Cargo native test drivers
179
+ if (/\bgo\s+test\b/.test(trimmed))
180
+ return true;
181
+ if (/\bcargo\s+test\b/.test(trimmed))
182
+ return true;
183
+ return false;
184
+ }
185
+ export function decidePreBash(input, mode = "deny") {
186
+ if (mode === "advisory")
187
+ return { kind: "allow" };
147
188
  if (input.tool_name !== "Bash")
148
189
  return { kind: "allow" };
149
190
  const cmd = input.tool_input?.command;
@@ -154,6 +195,15 @@ export function decidePreBash(input) {
154
195
  export function renderPreBashOutput(decision) {
155
196
  if (decision.kind === "allow")
156
197
  return null;
198
+ if (decision.kind === "advise") {
199
+ return JSON.stringify({
200
+ hookSpecificOutput: {
201
+ hookEventName: "PreToolUse",
202
+ permissionDecision: "allow",
203
+ additionalContext: decision.reason,
204
+ },
205
+ });
206
+ }
157
207
  return JSON.stringify({
158
208
  hookSpecificOutput: {
159
209
  hookEventName: "PreToolUse",
@@ -0,0 +1,69 @@
1
+ /**
2
+ * v0.30.0 — PreToolUse:Edit/MultiEdit/Write enforcement.
3
+ *
4
+ * Background: tool-audit data across three real projects (2026-04-24)
5
+ * showed Codex calling `read_for_edit` at 33% of its MCP volume while
6
+ * Claude sat at 0-1% despite our MCP instructions marking it MANDATORY.
7
+ * Text rules alone don't flip trained agent instincts. The pattern that
8
+ * did move Claude — pre-grep → find_usages — is hook-based deny.
9
+ *
10
+ * This hook closes the gap: before Claude executes Edit/MultiEdit/Write
11
+ * on an existing code file, we check a shared prep-state file that
12
+ * read_for_edit updates on every call. If the file isn't prepared we
13
+ * block (deny) or warn (advisory), depending on TOKEN_PILOT_MODE.
14
+ *
15
+ * Scope rules, in order:
16
+ * 1. Non-code files → allow (config, markdown, etc.)
17
+ * 2. Write on non-existent file → allow (new-file creation is fine)
18
+ * 3. TOKEN_PILOT_BYPASS=1 → allow (escape hatch)
19
+ * 4. advisory mode → allow + additionalContext hint
20
+ * 5. File already prepared → allow
21
+ * 6. Otherwise → deny with actionable message
22
+ *
23
+ * The decide function is pure — no I/O, no process.env reads — so it is
24
+ * trivially unit-testable. All side effects (existsSync, state read,
25
+ * enforcement-mode env) are resolved in the thin wrapper before the call.
26
+ */
27
+ import type { EnforcementMode } from "../server/enforcement-mode.js";
28
+ export interface PreEditInput {
29
+ tool_name?: string;
30
+ tool_input?: {
31
+ file_path?: string;
32
+ [k: string]: unknown;
33
+ };
34
+ }
35
+ export type PreEditDecision = {
36
+ kind: "allow";
37
+ } | {
38
+ kind: "advise";
39
+ message: string;
40
+ } | {
41
+ kind: "deny";
42
+ reason: string;
43
+ };
44
+ export interface PreEditContext {
45
+ /** Enforcement mode from TOKEN_PILOT_MODE */
46
+ mode: EnforcementMode;
47
+ /** File extension is a code file we care about */
48
+ isCodeFile: boolean;
49
+ /** The target file already exists on disk */
50
+ fileExists: boolean;
51
+ /** read_for_edit was called for this file recently */
52
+ isPrepared: boolean;
53
+ /** TOKEN_PILOT_BYPASS=1 set in env */
54
+ bypassed: boolean;
55
+ }
56
+ /**
57
+ * Pure decision function. Caller resolves all context (FS, env, state)
58
+ * beforehand so this stays a deterministic mapping input → decision.
59
+ */
60
+ export declare function decidePreEdit(input: PreEditInput, ctx: PreEditContext): PreEditDecision;
61
+ /**
62
+ * Render the Claude Code hook JSON response.
63
+ *
64
+ * - allow → no output (hook passes through with no side-effect)
65
+ * - advise → permissionDecision=allow + additionalContext hint
66
+ * - deny → permissionDecision=deny + reason
67
+ */
68
+ export declare function renderPreEditOutput(decision: PreEditDecision): string | null;
69
+ //# sourceMappingURL=pre-edit.d.ts.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * v0.30.0 — PreToolUse:Edit/MultiEdit/Write enforcement.
3
+ *
4
+ * Background: tool-audit data across three real projects (2026-04-24)
5
+ * showed Codex calling `read_for_edit` at 33% of its MCP volume while
6
+ * Claude sat at 0-1% despite our MCP instructions marking it MANDATORY.
7
+ * Text rules alone don't flip trained agent instincts. The pattern that
8
+ * did move Claude — pre-grep → find_usages — is hook-based deny.
9
+ *
10
+ * This hook closes the gap: before Claude executes Edit/MultiEdit/Write
11
+ * on an existing code file, we check a shared prep-state file that
12
+ * read_for_edit updates on every call. If the file isn't prepared we
13
+ * block (deny) or warn (advisory), depending on TOKEN_PILOT_MODE.
14
+ *
15
+ * Scope rules, in order:
16
+ * 1. Non-code files → allow (config, markdown, etc.)
17
+ * 2. Write on non-existent file → allow (new-file creation is fine)
18
+ * 3. TOKEN_PILOT_BYPASS=1 → allow (escape hatch)
19
+ * 4. advisory mode → allow + additionalContext hint
20
+ * 5. File already prepared → allow
21
+ * 6. Otherwise → deny with actionable message
22
+ *
23
+ * The decide function is pure — no I/O, no process.env reads — so it is
24
+ * trivially unit-testable. All side effects (existsSync, state read,
25
+ * enforcement-mode env) are resolved in the thin wrapper before the call.
26
+ */
27
+ /**
28
+ * Pure decision function. Caller resolves all context (FS, env, state)
29
+ * beforehand so this stays a deterministic mapping input → decision.
30
+ */
31
+ export function decidePreEdit(input, ctx) {
32
+ const toolName = input.tool_name ?? "";
33
+ if (toolName !== "Edit" && toolName !== "MultiEdit" && toolName !== "Write") {
34
+ return { kind: "allow" };
35
+ }
36
+ const filePath = input.tool_input?.file_path;
37
+ if (typeof filePath !== "string" || filePath.length === 0) {
38
+ return { kind: "allow" };
39
+ }
40
+ // Non-code files: config, markdown, JSON — Read-based edit-prep doesn't
41
+ // carry the same value, skip enforcement.
42
+ if (!ctx.isCodeFile)
43
+ return { kind: "allow" };
44
+ // Non-existent files are out of scope for the enforcement:
45
+ // - Write on a new file is legitimate new-file creation
46
+ // - Edit / MultiEdit on a missing path will error downstream in
47
+ // Claude Code itself — nothing for us to add there
48
+ if (!ctx.fileExists)
49
+ return { kind: "allow" };
50
+ // Explicit escape hatch. Documented as TOKEN_PILOT_BYPASS=1.
51
+ if (ctx.bypassed)
52
+ return { kind: "allow" };
53
+ // Already prepared → allow.
54
+ if (ctx.isPrepared)
55
+ return { kind: "allow" };
56
+ const suggestion = `mcp__token-pilot__read_for_edit(path="${filePath}", symbol="<target>")`;
57
+ // advisory mode: inject a non-blocking hint. The agent still runs the
58
+ // Edit, but next time should see the pattern.
59
+ if (ctx.mode === "advisory") {
60
+ return {
61
+ kind: "advise",
62
+ message: `File "${filePath}" was not prepared with read_for_edit. ` +
63
+ `Consider calling ${suggestion} first — the exact old_string it returns is what Edit actually needs. ` +
64
+ `Edit built from smart_read / Read snippets frequently mismatches on whitespace.`,
65
+ };
66
+ }
67
+ // deny / strict: hard block with an actionable message.
68
+ const reason = `File "${filePath}" was not prepared with read_for_edit. ` +
69
+ `Call ${suggestion} FIRST to obtain the exact old_string for Edit — ` +
70
+ `this is the canonical flow. Building old_string from smart_read or Read ` +
71
+ `snippets diverges from disk (whitespace, line-number prefixes) and Edit ` +
72
+ `silently mismatches. ` +
73
+ `Escape hatch: set TOKEN_PILOT_BYPASS=1 in the environment, or switch to ` +
74
+ `TOKEN_PILOT_MODE=advisory for warn-only behaviour.`;
75
+ return { kind: "deny", reason };
76
+ }
77
+ /**
78
+ * Render the Claude Code hook JSON response.
79
+ *
80
+ * - allow → no output (hook passes through with no side-effect)
81
+ * - advise → permissionDecision=allow + additionalContext hint
82
+ * - deny → permissionDecision=deny + reason
83
+ */
84
+ export function renderPreEditOutput(decision) {
85
+ if (decision.kind === "allow")
86
+ return null;
87
+ if (decision.kind === "advise") {
88
+ return JSON.stringify({
89
+ hookSpecificOutput: {
90
+ hookEventName: "PreToolUse",
91
+ permissionDecision: "allow",
92
+ additionalContext: decision.message,
93
+ },
94
+ });
95
+ }
96
+ return JSON.stringify({
97
+ hookSpecificOutput: {
98
+ hookEventName: "PreToolUse",
99
+ permissionDecision: "deny",
100
+ permissionDecisionReason: decision.reason,
101
+ },
102
+ });
103
+ }
104
+ //# sourceMappingURL=pre-edit.js.map
@@ -20,6 +20,7 @@
20
20
  * find_usages after the block, we keep it. If they bypass via `-E` or
21
21
  * raw shell, we soften to advisory.
22
22
  */
23
+ import type { EnforcementMode } from "../server/enforcement-mode.js";
23
24
  export interface PreGrepInput {
24
25
  tool_name?: string;
25
26
  tool_input?: {
@@ -31,10 +32,20 @@ export interface PreGrepInput {
31
32
  }
32
33
  export type PreGrepDecision = {
33
34
  kind: "allow";
35
+ } | {
36
+ kind: "advise";
37
+ reason: string;
34
38
  } | {
35
39
  kind: "deny";
36
40
  reason: string;
37
41
  };
42
+ /**
43
+ * Shapes that look like TODO / FIXME / HACK / XXX / BUG tag scans —
44
+ * route these to `code_audit` which returns deduplicated, categorised
45
+ * results instead of N raw grep hits. Zero code_audit calls across three
46
+ * projects (tool-audit 2026-04-24) = agents reach for Grep every time.
47
+ */
48
+ export declare function isTodoScanPattern(pattern: string): boolean;
38
49
  /**
39
50
  * Heuristic: does `pattern` look like a code identifier worth sending
40
51
  * through find_usages?
@@ -51,7 +62,7 @@ export declare function isSymbolLikePattern(pattern: string): boolean;
51
62
  * Pure decision function. Given a PreToolUse hook input for Grep,
52
63
  * return whether to allow or deny (with a suggestion).
53
64
  */
54
- export declare function decidePreGrep(input: PreGrepInput): PreGrepDecision;
65
+ export declare function decidePreGrep(input: PreGrepInput, mode?: EnforcementMode): PreGrepDecision;
55
66
  /**
56
67
  * Render the Claude Code hook JSON response.
57
68
  */
@@ -20,6 +20,18 @@
20
20
  * find_usages after the block, we keep it. If they bypass via `-E` or
21
21
  * raw shell, we soften to advisory.
22
22
  */
23
+ /**
24
+ * Shapes that look like TODO / FIXME / HACK / XXX / BUG tag scans —
25
+ * route these to `code_audit` which returns deduplicated, categorised
26
+ * results instead of N raw grep hits. Zero code_audit calls across three
27
+ * projects (tool-audit 2026-04-24) = agents reach for Grep every time.
28
+ */
29
+ export function isTodoScanPattern(pattern) {
30
+ // Strip common grep-alternation syntax to compare the symbol cores
31
+ const normalised = pattern.replace(/[()\s]/g, "").toUpperCase();
32
+ const tagRe = /^(TODO|FIXME|HACK|XXX|BUG|NOTE|OPTIMIZE|REFACTOR)(\|(TODO|FIXME|HACK|XXX|BUG|NOTE|OPTIMIZE|REFACTOR))*$/;
33
+ return tagRe.test(normalised);
34
+ }
23
35
  /**
24
36
  * Heuristic: does `pattern` look like a code identifier worth sending
25
37
  * through find_usages?
@@ -64,13 +76,30 @@ export function isSymbolLikePattern(pattern) {
64
76
  * Pure decision function. Given a PreToolUse hook input for Grep,
65
77
  * return whether to allow or deny (with a suggestion).
66
78
  */
67
- export function decidePreGrep(input) {
79
+ export function decidePreGrep(input, mode = "deny") {
68
80
  if (input.tool_name !== "Grep")
69
81
  return { kind: "allow" };
70
82
  const pattern = input.tool_input?.pattern;
71
83
  if (typeof pattern !== "string" || pattern.length === 0) {
72
84
  return { kind: "allow" };
73
85
  }
86
+ // TODO / FIXME / HACK tag scan → route to code_audit. Emitted as an
87
+ // advisory ("allow" + hint) regardless of enforcement mode: blocking
88
+ // would frustrate a legitimate one-off scan, but nudging the agent
89
+ // toward code_audit compounds the benefit across a session.
90
+ if (isTodoScanPattern(pattern)) {
91
+ return {
92
+ kind: "advise",
93
+ reason: `Grep pattern "${pattern}" is a TODO / FIXME / HACK scan. ` +
94
+ `Prefer mcp__token-pilot__code_audit — it returns deduplicated, ` +
95
+ `categorised tags across the project with file/line references, ` +
96
+ `typically 3-5× fewer tokens than raw Grep and ignores generated/` +
97
+ `vendored code automatically.`,
98
+ };
99
+ }
100
+ // Advisory mode disables the symbol-like deny (legacy behaviour).
101
+ if (mode === "advisory")
102
+ return { kind: "allow" };
74
103
  if (!isSymbolLikePattern(pattern))
75
104
  return { kind: "allow" };
76
105
  const reason = `Grep pattern "${pattern}" looks like a code identifier. ` +
@@ -87,6 +116,15 @@ export function decidePreGrep(input) {
87
116
  export function renderPreGrepOutput(decision) {
88
117
  if (decision.kind === "allow")
89
118
  return null;
119
+ if (decision.kind === "advise") {
120
+ return JSON.stringify({
121
+ hookSpecificOutput: {
122
+ hookEventName: "PreToolUse",
123
+ permissionDecision: "allow",
124
+ additionalContext: decision.reason,
125
+ },
126
+ });
127
+ }
90
128
  return JSON.stringify({
91
129
  hookSpecificOutput: {
92
130
  hookEventName: "PreToolUse",
package/dist/index.d.ts CHANGED
@@ -3,6 +3,18 @@ import type { HookMode } from "./types.js";
3
3
  export declare const CODE_EXTENSIONS: Set<string>;
4
4
  export declare function getVersion(): string;
5
5
  export declare function main(cliArgs?: string[]): Promise<void>;
6
+ /**
7
+ * Defensive check for the Claude Code plugin `start.sh` bug (fixed 2026-04-24,
8
+ * but older installs still in the wild). If the caller passed the plugin's own
9
+ * cache dir as projectRoot, every relative path like `front/src/File.php` gets
10
+ * resolved inside the plugin install instead of the user's repo (ENOENT).
11
+ *
12
+ * Matches the canonical Claude Code plugin cache pattern
13
+ * ~/.claude/plugins/cache/token-pilot/token-pilot/<version>/
14
+ * on both POSIX and Windows separators. Intentionally narrow — does NOT match
15
+ * dev installs (cloning the repo and running against itself stays legal).
16
+ */
17
+ export declare function looksLikePluginCacheDir(candidate: string): boolean;
6
18
  export declare function startServer(cliArgs?: string[]): Promise<void>;
7
19
  export interface HookReadAdaptiveOptions {
8
20
  adaptiveThreshold?: boolean;
@@ -16,6 +28,24 @@ export declare function handleHookRead(filePathArg?: string, mode?: HookMode, de
16
28
  * wrapping.
17
29
  */
18
30
  export declare function runHookReadDispatch(filePathArg: string | undefined, mode: HookMode, denyThresholdArg?: number, projectRootArg?: string, adaptive?: HookReadAdaptiveOptions): Promise<string | null>;
31
+ /**
32
+ * PreToolUse:Edit / MultiEdit / Write enforcement.
33
+ *
34
+ * v0.30.0 upgraded this from a passive advisory hint into a real gate.
35
+ * The previous implementation always returned `allow` + a TIP; Claude
36
+ * ignored the TIP and kept building Edit's old_string from smart_read
37
+ * snippets (tool-audit 2026-04-24: read_for_edit = 0-1% of Claude calls
38
+ * vs 33% for Codex, which gets explicit prompt-level enforcement).
39
+ *
40
+ * New behaviour driven by TOKEN_PILOT_MODE:
41
+ * - advisory → allow + non-blocking hint when the file wasn't prepped
42
+ * - deny → block when the file wasn't prepped (the default)
43
+ * - strict → same as deny, plus event log for telemetry
44
+ *
45
+ * Pure decision logic lives in src/hooks/pre-edit.ts — this wrapper is
46
+ * responsible only for stdin parsing and I/O-bound context resolution
47
+ * (file existence, prep-state lookup, env vars).
48
+ */
19
49
  export declare function handleHookEdit(): void;
20
50
  export declare function handleInstallHook(projectRoot: string): Promise<void>;
21
51
  export declare function handleUninstallHook(projectRoot: string): Promise<void>;
package/dist/index.js CHANGED
@@ -16,8 +16,8 @@ process.stderr.on("error", (err) => {
16
16
  throw err;
17
17
  });
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
- import { readFileSync, realpathSync, appendFileSync, mkdirSync } from "node:fs";
20
- import { join } from "node:path";
19
+ import { existsSync, readFileSync, realpathSync, appendFileSync, mkdirSync, } from "node:fs";
20
+ import { join, resolve } from "node:path";
21
21
  import { homedir } from "node:os";
22
22
  import { execFile } from "node:child_process";
23
23
  import { promisify } from "node:util";
@@ -52,6 +52,9 @@ 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 { decidePreEdit, renderPreEditOutput, } from "./hooks/pre-edit.js";
56
+ import { isEditPrepared as isEditPreparedFn } from "./core/edit-prep-state.js";
57
+ import { parseEnforcementMode } from "./server/enforcement-mode.js";
55
58
  const execFileAsync = promisify(execFile);
56
59
  export const CODE_EXTENSIONS = new Set([
57
60
  "ts",
@@ -152,7 +155,7 @@ export async function main(cliArgs = process.argv.slice(2)) {
152
155
  try {
153
156
  const stdin = readFileSync(0, "utf-8");
154
157
  const input = JSON.parse(stdin);
155
- const decision = decidePreBash(input);
158
+ const decision = decidePreBash(input, parseEnforcementMode(process.env.TOKEN_PILOT_MODE));
156
159
  const rendered = renderPreBashOutput(decision);
157
160
  if (rendered)
158
161
  process.stdout.write(rendered);
@@ -169,7 +172,7 @@ export async function main(cliArgs = process.argv.slice(2)) {
169
172
  try {
170
173
  const stdin = readFileSync(0, "utf-8");
171
174
  const input = JSON.parse(stdin);
172
- const decision = decidePreGrep(input);
175
+ const decision = decidePreGrep(input, parseEnforcementMode(process.env.TOKEN_PILOT_MODE));
173
176
  const rendered = renderPreGrepOutput(decision);
174
177
  if (rendered)
175
178
  process.stdout.write(rendered);
@@ -292,11 +295,41 @@ export async function main(cliArgs = process.argv.slice(2)) {
292
295
  return;
293
296
  }
294
297
  }
298
+ /**
299
+ * Defensive check for the Claude Code plugin `start.sh` bug (fixed 2026-04-24,
300
+ * but older installs still in the wild). If the caller passed the plugin's own
301
+ * cache dir as projectRoot, every relative path like `front/src/File.php` gets
302
+ * resolved inside the plugin install instead of the user's repo (ENOENT).
303
+ *
304
+ * Matches the canonical Claude Code plugin cache pattern
305
+ * ~/.claude/plugins/cache/token-pilot/token-pilot/<version>/
306
+ * on both POSIX and Windows separators. Intentionally narrow — does NOT match
307
+ * dev installs (cloning the repo and running against itself stays legal).
308
+ */
309
+ export function looksLikePluginCacheDir(candidate) {
310
+ if (!candidate)
311
+ return false;
312
+ try {
313
+ const resolved = resolve(candidate);
314
+ return /[\\/]plugins[\\/]cache[\\/]token-pilot[\\/]/.test(resolved);
315
+ }
316
+ catch {
317
+ return false;
318
+ }
319
+ }
295
320
  export async function startServer(cliArgs = process.argv.slice(2)) {
296
- let projectRoot = cliArgs[0] || process.cwd();
321
+ // Defensive: ignore a poisoned cliArgs[0] pointing into the plugin install
322
+ // dir. Fall through to the INIT_CWD / PWD / cwd detection below — same
323
+ // behaviour as if the argument had never been passed.
324
+ let explicitRoot = cliArgs[0];
325
+ if (explicitRoot && looksLikePluginCacheDir(explicitRoot)) {
326
+ console.error(`[token-pilot] ignoring "${explicitRoot}" — looks like the plugin cache dir (start.sh bug). Auto-detecting project root instead.`);
327
+ explicitRoot = "";
328
+ }
329
+ let projectRoot = explicitRoot || process.cwd();
297
330
  // Detect git root for reliable project root
298
331
  // Try multiple sources: args[0] → INIT_CWD (npm/npx invoking dir) → PWD → cwd
299
- if (!cliArgs[0]) {
332
+ if (!explicitRoot) {
300
333
  const candidates = [
301
334
  process.env.INIT_CWD, // npm/npx sets this to invoking directory
302
335
  process.env.PWD, // shell working directory (may differ from cwd)
@@ -386,6 +419,7 @@ export async function startServer(cliArgs = process.argv.slice(2)) {
386
419
  });
387
420
  const server = await createServer(projectRoot, {
388
421
  skipAstIndex: isDangerousRoot(projectRoot),
422
+ enforcementMode: parseEnforcementMode(process.env.TOKEN_PILOT_MODE),
389
423
  });
390
424
  const transport = new StdioServerTransport();
391
425
  await server.connect(transport);
@@ -556,34 +590,65 @@ async function runHookReadDispatchImpl(filePathArg, mode, denyThreshold, project
556
590
  },
557
591
  });
558
592
  }
593
+ /**
594
+ * PreToolUse:Edit / MultiEdit / Write enforcement.
595
+ *
596
+ * v0.30.0 upgraded this from a passive advisory hint into a real gate.
597
+ * The previous implementation always returned `allow` + a TIP; Claude
598
+ * ignored the TIP and kept building Edit's old_string from smart_read
599
+ * snippets (tool-audit 2026-04-24: read_for_edit = 0-1% of Claude calls
600
+ * vs 33% for Codex, which gets explicit prompt-level enforcement).
601
+ *
602
+ * New behaviour driven by TOKEN_PILOT_MODE:
603
+ * - advisory → allow + non-blocking hint when the file wasn't prepped
604
+ * - deny → block when the file wasn't prepped (the default)
605
+ * - strict → same as deny, plus event log for telemetry
606
+ *
607
+ * Pure decision logic lives in src/hooks/pre-edit.ts — this wrapper is
608
+ * responsible only for stdin parsing and I/O-bound context resolution
609
+ * (file existence, prep-state lookup, env vars).
610
+ */
559
611
  export function handleHookEdit() {
560
- // Parse stdin for Edit tool_input
561
- let filePath;
612
+ let input;
562
613
  try {
563
614
  const stdin = readFileSync(0, "utf-8");
564
- const input = JSON.parse(stdin);
565
- filePath = input?.tool_input?.file_path;
615
+ input = JSON.parse(stdin);
566
616
  }
567
617
  catch {
568
618
  process.exit(0);
569
619
  }
570
- if (!filePath) {
620
+ const filePath = input.tool_input?.file_path;
621
+ if (typeof filePath !== "string" || filePath.length === 0) {
571
622
  process.exit(0);
572
623
  }
624
+ const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
573
625
  const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
574
- // Only add context for code files
575
- if (!CODE_EXTENSIONS.has(ext)) {
576
- process.exit(0);
626
+ const isCodeFile = CODE_EXTENSIONS.has(ext);
627
+ const mode = parseEnforcementMode(process.env.TOKEN_PILOT_MODE);
628
+ const bypassed = process.env.TOKEN_PILOT_BYPASS === "1";
629
+ // Existence check must be sync + cheap — the hook is on the request hot path.
630
+ let fileExists = false;
631
+ try {
632
+ fileExists = existsSync(filePath);
577
633
  }
578
- // Add additionalContext suggesting read_for_edit — doesn't block Edit
579
- const context = JSON.stringify({
580
- hookSpecificOutput: {
581
- hookEventName: "PreToolUse",
582
- permissionDecision: "allow",
583
- additionalContext: `TIP: Use read_for_edit("${filePath}", symbol="<name>") to get minimal raw code for Edit's old_string — 97% fewer tokens than Read.`,
584
- },
634
+ catch {
635
+ // If we can't even stat it, fall back to "does not exist" so Write-on-new
636
+ // still flows through; Edit on a missing file would error anyway.
637
+ fileExists = false;
638
+ }
639
+ const isPrepared = isCodeFile
640
+ ? isEditPreparedFn(projectRoot, filePath)
641
+ : false;
642
+ const decision = decidePreEdit(input, {
643
+ mode,
644
+ isCodeFile,
645
+ fileExists,
646
+ isPrepared,
647
+ bypassed,
585
648
  });
586
- process.stdout.write(context);
649
+ const rendered = renderPreEditOutput(decision);
650
+ if (rendered)
651
+ process.stdout.write(rendered);
587
652
  process.exit(0);
588
653
  }
589
654
  export async function handleInstallHook(projectRoot) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * v0.30.0 — TOKEN_PILOT_MODE enforcement modes.
3
+ *
4
+ * Controls how aggressively token-pilot blocks heavy native tools and
5
+ * caps MCP tool output sizes. Three modes:
6
+ *
7
+ * advisory — hooks always allow, no MCP output caps. Observation-only.
8
+ * Use when measuring baseline token usage or debugging.
9
+ *
10
+ * deny — DEFAULT. Hooks deny heavy Bash/Grep patterns and suggest
11
+ * cheaper MCP alternatives. No auto-caps on MCP output.
12
+ * This is the "smart redirect" mode — the agent learns the
13
+ * right tool but can still produce large MCP responses.
14
+ *
15
+ * strict — deny + MCP output auto-caps. smart_read is capped at
16
+ * max_tokens=2000 when the caller doesn't set it; explore_area
17
+ * defaults include=['outline'] when the caller doesn't set it.
18
+ * Cap values are v0.30.0 initial estimates — tune from real
19
+ * tool-audit data in a follow-up PR (#8).
20
+ *
21
+ * Set via TOKEN_PILOT_MODE environment variable (case-insensitive, trimmed).
22
+ * Unknown values fall back to "deny" with a warning.
23
+ *
24
+ * Separate from `hooks.mode` (HookMode) which controls only the PreToolUse:Read
25
+ * hook (deny-enhanced vs advisory for large file reads). TOKEN_PILOT_MODE
26
+ * covers Bash and Grep hooks plus MCP output caps.
27
+ */
28
+ export type EnforcementMode = "advisory" | "deny" | "strict";
29
+ export declare const ENFORCEMENT_MODE_NAMES: readonly ["advisory", "deny", "strict"];
30
+ /**
31
+ * Parse TOKEN_PILOT_MODE from an env-var string. Returns "deny" for
32
+ * missing or empty values. Emits a warning for unrecognised values.
33
+ */
34
+ export declare function parseEnforcementMode(raw: string | undefined, warn?: (msg: string) => void): EnforcementMode;
35
+ /**
36
+ * The cap applied to smart_read max_tokens in strict mode when the
37
+ * caller has not supplied an explicit max_tokens.
38
+ * v0.30.0 initial estimate — tune from tool-audit data.
39
+ */
40
+ export declare const STRICT_SMART_READ_MAX_TOKENS = 2000;
41
+ /**
42
+ * The include sections applied to explore_area in strict mode when the
43
+ * caller has not supplied an explicit include array.
44
+ * v0.30.0 initial estimate — outline-only keeps footprint minimal.
45
+ */
46
+ export declare const STRICT_EXPLORE_AREA_INCLUDE: Array<"outline" | "imports" | "tests" | "changes">;
47
+ //# sourceMappingURL=enforcement-mode.d.ts.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * v0.30.0 — TOKEN_PILOT_MODE enforcement modes.
3
+ *
4
+ * Controls how aggressively token-pilot blocks heavy native tools and
5
+ * caps MCP tool output sizes. Three modes:
6
+ *
7
+ * advisory — hooks always allow, no MCP output caps. Observation-only.
8
+ * Use when measuring baseline token usage or debugging.
9
+ *
10
+ * deny — DEFAULT. Hooks deny heavy Bash/Grep patterns and suggest
11
+ * cheaper MCP alternatives. No auto-caps on MCP output.
12
+ * This is the "smart redirect" mode — the agent learns the
13
+ * right tool but can still produce large MCP responses.
14
+ *
15
+ * strict — deny + MCP output auto-caps. smart_read is capped at
16
+ * max_tokens=2000 when the caller doesn't set it; explore_area
17
+ * defaults include=['outline'] when the caller doesn't set it.
18
+ * Cap values are v0.30.0 initial estimates — tune from real
19
+ * tool-audit data in a follow-up PR (#8).
20
+ *
21
+ * Set via TOKEN_PILOT_MODE environment variable (case-insensitive, trimmed).
22
+ * Unknown values fall back to "deny" with a warning.
23
+ *
24
+ * Separate from `hooks.mode` (HookMode) which controls only the PreToolUse:Read
25
+ * hook (deny-enhanced vs advisory for large file reads). TOKEN_PILOT_MODE
26
+ * covers Bash and Grep hooks plus MCP output caps.
27
+ */
28
+ export const ENFORCEMENT_MODE_NAMES = [
29
+ "advisory",
30
+ "deny",
31
+ "strict",
32
+ ];
33
+ /**
34
+ * Parse TOKEN_PILOT_MODE from an env-var string. Returns "deny" for
35
+ * missing or empty values. Emits a warning for unrecognised values.
36
+ */
37
+ export function parseEnforcementMode(raw, warn = (m) => process.stderr.write(m + "\n")) {
38
+ if (!raw || raw.trim() === "")
39
+ return "deny";
40
+ const v = raw.trim().toLowerCase();
41
+ if (v === "advisory" || v === "deny" || v === "strict")
42
+ return v;
43
+ warn(`[token-pilot] Unknown TOKEN_PILOT_MODE="${raw}", falling back to "deny". ` +
44
+ `Valid values: advisory | deny | strict.`);
45
+ return "deny";
46
+ }
47
+ /**
48
+ * The cap applied to smart_read max_tokens in strict mode when the
49
+ * caller has not supplied an explicit max_tokens.
50
+ * v0.30.0 initial estimate — tune from tool-audit data.
51
+ */
52
+ export const STRICT_SMART_READ_MAX_TOKENS = 2000;
53
+ /**
54
+ * The include sections applied to explore_area in strict mode when the
55
+ * caller has not supplied an explicit include array.
56
+ * v0.30.0 initial estimate — outline-only keeps footprint minimal.
57
+ */
58
+ export const STRICT_EXPLORE_AREA_INCLUDE = ["outline"];
59
+ //# sourceMappingURL=enforcement-mode.js.map