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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -4
- package/CHANGELOG.md +35 -0
- package/README.md +57 -384
- 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.d.ts +17 -2
- package/dist/ast-index/client.js +233 -107
- package/dist/cli/tool-audit.d.ts +5 -0
- package/dist/cli/tool-audit.js +9 -1
- package/dist/core/edit-prep-state.d.ts +42 -0
- package/dist/core/edit-prep-state.js +108 -0
- package/dist/core/policy-engine.d.ts +1 -5
- package/dist/core/policy-engine.js +9 -24
- package/dist/handlers/explore-area.js +6 -1
- package/dist/handlers/read-for-edit.d.ts +5 -5
- package/dist/handlers/read-for-edit.js +188 -110
- package/dist/hooks/installer.js +18 -0
- package/dist/hooks/pre-bash.d.ts +11 -1
- package/dist/hooks/pre-bash.js +51 -1
- package/dist/hooks/pre-edit.d.ts +69 -0
- package/dist/hooks/pre-edit.js +104 -0
- package/dist/hooks/pre-grep.d.ts +12 -1
- package/dist/hooks/pre-grep.js +39 -1
- package/dist/index.d.ts +30 -0
- package/dist/index.js +87 -22
- package/dist/server/enforcement-mode.d.ts +47 -0
- package/dist/server/enforcement-mode.js +59 -0
- package/dist/server/tool-definitions.d.ts +20 -0
- package/dist/server/tool-definitions.js +127 -12
- package/dist/server/tool-profiles.d.ts +19 -1
- package/dist/server/tool-profiles.js +38 -4
- package/dist/server.d.ts +2 -0
- package/dist/server.js +89 -21
- package/docs/agents.md +82 -0
- package/docs/configuration.md +117 -0
- package/docs/hooks.md +99 -0
- package/docs/installation.md +169 -0
- package/docs/tools.md +61 -0
- package/hooks/hooks.json +18 -0
- package/package.json +2 -2
- package/start.sh +19 -9
package/dist/hooks/pre-bash.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/hooks/pre-grep.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/hooks/pre-grep.js
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
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
|
-
|
|
561
|
-
let filePath;
|
|
612
|
+
let input;
|
|
562
613
|
try {
|
|
563
614
|
const stdin = readFileSync(0, "utf-8");
|
|
564
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|