token-pilot 0.19.2 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/hooks/hooks.json +30 -0
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +165 -0
- package/README.md +194 -313
- package/dist/agents/tp-audit-scanner.md +49 -0
- package/dist/agents/tp-commit-writer.md +41 -0
- package/dist/agents/tp-dead-code-finder.md +43 -0
- package/dist/agents/tp-debugger.md +45 -0
- package/dist/agents/tp-history-explorer.md +43 -0
- package/dist/agents/tp-impact-analyzer.md +44 -0
- package/dist/agents/tp-migration-scout.md +43 -0
- package/dist/agents/tp-onboard.md +40 -0
- package/dist/agents/tp-pr-reviewer.md +41 -0
- package/dist/agents/tp-refactor-planner.md +42 -0
- package/dist/agents/tp-run.md +48 -0
- package/dist/agents/tp-session-restorer.md +47 -0
- package/dist/agents/tp-test-triage.md +40 -0
- package/dist/agents/tp-test-writer.md +46 -0
- package/dist/cli/agent-frontmatter.d.ts +48 -0
- package/dist/cli/agent-frontmatter.js +189 -0
- package/dist/cli/bless-agents.d.ts +65 -0
- package/dist/cli/bless-agents.js +307 -0
- package/dist/cli/claudeignore.d.ts +33 -0
- package/dist/cli/claudeignore.js +88 -0
- package/dist/cli/claudemd-hygiene.d.ts +26 -0
- package/dist/cli/claudemd-hygiene.js +43 -0
- package/dist/cli/doctor-drift.d.ts +31 -0
- package/dist/cli/doctor-drift.js +130 -0
- package/dist/cli/doctor-env-check.d.ts +25 -0
- package/dist/cli/doctor-env-check.js +91 -0
- package/dist/cli/install-agents.d.ts +108 -0
- package/dist/cli/install-agents.js +402 -0
- package/dist/cli/save-doc.d.ts +42 -0
- package/dist/cli/save-doc.js +145 -0
- package/dist/cli/scan-agents.d.ts +46 -0
- package/dist/cli/scan-agents.js +227 -0
- package/dist/cli/stats.d.ts +36 -0
- package/dist/cli/stats.js +131 -0
- package/dist/cli/typo-guard.d.ts +27 -0
- package/dist/cli/typo-guard.js +119 -0
- package/dist/cli/unbless-agents.d.ts +33 -0
- package/dist/cli/unbless-agents.js +85 -0
- package/dist/cli/uninstall-agents.d.ts +36 -0
- package/dist/cli/uninstall-agents.js +117 -0
- package/dist/config/defaults.d.ts +1 -1
- package/dist/config/defaults.js +14 -8
- package/dist/config/loader.d.ts +1 -1
- package/dist/config/loader.js +105 -11
- package/dist/core/context-registry.d.ts +16 -1
- package/dist/core/context-registry.js +60 -28
- package/dist/core/event-log.d.ts +79 -0
- package/dist/core/event-log.js +190 -0
- package/dist/core/session-registry.d.ts +43 -0
- package/dist/core/session-registry.js +113 -0
- package/dist/core/session-savings.d.ts +19 -0
- package/dist/core/session-savings.js +60 -0
- package/dist/handlers/session-budget.d.ts +32 -0
- package/dist/handlers/session-budget.js +61 -0
- package/dist/handlers/session-snapshot-persist.d.ts +22 -0
- package/dist/handlers/session-snapshot-persist.js +76 -0
- package/dist/hooks/adaptive-threshold.d.ts +27 -0
- package/dist/hooks/adaptive-threshold.js +46 -0
- package/dist/hooks/format-deny-message.d.ts +21 -0
- package/dist/hooks/format-deny-message.js +147 -0
- package/dist/hooks/installer.js +130 -31
- package/dist/hooks/path-safety.d.ts +16 -0
- package/dist/hooks/path-safety.js +34 -0
- package/dist/hooks/post-bash.d.ts +46 -0
- package/dist/hooks/post-bash.js +77 -0
- package/dist/hooks/post-task.d.ts +67 -0
- package/dist/hooks/post-task.js +136 -0
- package/dist/hooks/session-start.d.ts +45 -0
- package/dist/hooks/session-start.js +179 -0
- package/dist/hooks/summary-ast-index.d.ts +28 -0
- package/dist/hooks/summary-ast-index.js +122 -0
- package/dist/hooks/summary-head-tail.d.ts +15 -0
- package/dist/hooks/summary-head-tail.js +78 -0
- package/dist/hooks/summary-pipeline.d.ts +35 -0
- package/dist/hooks/summary-pipeline.js +63 -0
- package/dist/hooks/summary-regex.d.ts +14 -0
- package/dist/hooks/summary-regex.js +130 -0
- package/dist/hooks/summary-types.d.ts +29 -0
- package/dist/hooks/summary-types.js +9 -0
- package/dist/index.d.ts +15 -3
- package/dist/index.js +538 -149
- package/dist/integration/context-mode-detector.d.ts +7 -1
- package/dist/integration/context-mode-detector.js +51 -15
- package/dist/server/tool-definitions.d.ts +149 -0
- package/dist/server/tool-definitions.js +424 -202
- package/dist/server.d.ts +1 -1
- package/dist/server.js +456 -179
- package/dist/templates/agent-builder.d.ts +49 -0
- package/dist/templates/agent-builder.js +104 -0
- package/dist/types.d.ts +38 -4
- package/package.json +4 -2
- package/skills/stats/SKILL.md +13 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive threshold: lower the effective denyThreshold as the Read-hook
|
|
3
|
+
* sees more suppressed-token activity in this session, so large-file reads
|
|
4
|
+
* become stricter the chattier the agent has been with big files.
|
|
5
|
+
*
|
|
6
|
+
* Piecewise curve, opt-in only:
|
|
7
|
+
* pressure < 30% of budget → base threshold unchanged
|
|
8
|
+
* pressure ≥ 30%, < 60% → base × 0.75
|
|
9
|
+
* pressure ≥ 60%, < 80% → base × 0.5
|
|
10
|
+
* pressure ≥ 80% → base × 0.3 (minimum 50 lines)
|
|
11
|
+
*
|
|
12
|
+
* Burn fraction = sessionSavedTokens / sessionBudgetTokens, where
|
|
13
|
+
* `sessionSavedTokens` is the sum of `savedTokens` entries in
|
|
14
|
+
* hook-events.jsonl for the current session_id. This is a PROXY for how
|
|
15
|
+
* aggressively the agent has been trying to pull large files, not a
|
|
16
|
+
* measurement of Claude Code's actual context-window occupancy — Token
|
|
17
|
+
* Pilot has no visibility into that. If the agent reads many files with
|
|
18
|
+
* bounded `offset/limit`, none of that contributes to the burn signal.
|
|
19
|
+
*/
|
|
20
|
+
const MIN_FLOOR_LINES = 50;
|
|
21
|
+
export function computeEffectiveThreshold(input) {
|
|
22
|
+
const { baseThreshold, sessionSavedTokens, sessionBudgetTokens, enabled } = input;
|
|
23
|
+
if (!enabled)
|
|
24
|
+
return baseThreshold;
|
|
25
|
+
if (!Number.isFinite(sessionBudgetTokens) || sessionBudgetTokens <= 0) {
|
|
26
|
+
return baseThreshold;
|
|
27
|
+
}
|
|
28
|
+
if (!Number.isFinite(sessionSavedTokens) || sessionSavedTokens <= 0) {
|
|
29
|
+
return baseThreshold;
|
|
30
|
+
}
|
|
31
|
+
const burn = sessionSavedTokens / sessionBudgetTokens;
|
|
32
|
+
let multiplier;
|
|
33
|
+
if (burn < 0.3)
|
|
34
|
+
multiplier = 1;
|
|
35
|
+
else if (burn < 0.6)
|
|
36
|
+
multiplier = 0.75;
|
|
37
|
+
else if (burn < 0.8)
|
|
38
|
+
multiplier = 0.5;
|
|
39
|
+
else
|
|
40
|
+
multiplier = 0.3;
|
|
41
|
+
const scaled = Math.round(baseThreshold * multiplier);
|
|
42
|
+
if (multiplier === 1)
|
|
43
|
+
return baseThreshold;
|
|
44
|
+
return Math.max(scaled, MIN_FLOOR_LINES);
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=adaptive-threshold.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a HookSummary into the body of a PreToolUse deny message.
|
|
3
|
+
*
|
|
4
|
+
* The formatted string becomes `hookSpecificOutput.permissionDecisionReason`
|
|
5
|
+
* when the hook decides to block a Read. It is the ONLY output the agent
|
|
6
|
+
* sees for the blocked call, so it carries both the structural summary and
|
|
7
|
+
* the escape-hatch instructions.
|
|
8
|
+
*
|
|
9
|
+
* Kept separate from the hook entry point for unit-testability.
|
|
10
|
+
*/
|
|
11
|
+
import type { HookSummary } from "./summary-types.js";
|
|
12
|
+
import type { PipelineTier } from "./summary-pipeline.js";
|
|
13
|
+
export interface FormatOptions {
|
|
14
|
+
filePath: string;
|
|
15
|
+
summary: HookSummary;
|
|
16
|
+
tier: PipelineTier;
|
|
17
|
+
/** Soft cap on the rendered message token count (estimated). Default 1200. */
|
|
18
|
+
maxTokens?: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function formatDenyMessage(opts: FormatOptions): string;
|
|
21
|
+
//# sourceMappingURL=format-deny-message.d.ts.map
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a HookSummary into the body of a PreToolUse deny message.
|
|
3
|
+
*
|
|
4
|
+
* The formatted string becomes `hookSpecificOutput.permissionDecisionReason`
|
|
5
|
+
* when the hook decides to block a Read. It is the ONLY output the agent
|
|
6
|
+
* sees for the blocked call, so it carries both the structural summary and
|
|
7
|
+
* the escape-hatch instructions.
|
|
8
|
+
*
|
|
9
|
+
* Kept separate from the hook entry point for unit-testability.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_MAX_TOKENS = 1200;
|
|
12
|
+
function estimateTokens(text) {
|
|
13
|
+
if (text.length === 0)
|
|
14
|
+
return 0;
|
|
15
|
+
const charEstimate = Math.ceil(text.length / 4);
|
|
16
|
+
const whitespaceRatio = (text.match(/\s/g)?.length ?? 0) / text.length;
|
|
17
|
+
const adjustment = 1 - whitespaceRatio * 0.3;
|
|
18
|
+
return Math.ceil(charEstimate * adjustment);
|
|
19
|
+
}
|
|
20
|
+
function formatSignalLine(s) {
|
|
21
|
+
return `L${s.line}: ${s.text}`;
|
|
22
|
+
}
|
|
23
|
+
function header(opts) {
|
|
24
|
+
const { filePath, summary } = opts;
|
|
25
|
+
return (`File "${filePath}" has ${summary.totalLines} lines (~${summary.estimatedTokens} tokens).\n` +
|
|
26
|
+
`Read denied to save context; structural summary follows.`);
|
|
27
|
+
}
|
|
28
|
+
function footer() {
|
|
29
|
+
return [
|
|
30
|
+
"How to proceed:",
|
|
31
|
+
"- Structural overview (preferred): mcp__token-pilot__smart_read(path).",
|
|
32
|
+
"- For specific lines: Read(path, offset, limit) — bounded reads are passed through.",
|
|
33
|
+
"- For a single symbol: mcp__token-pilot__read_symbol(path, name).",
|
|
34
|
+
"- For edit context: mcp__token-pilot__read_for_edit(path, symbol).",
|
|
35
|
+
"- Full read (expensive): set TOKEN_PILOT_BYPASS=1 for this session.",
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
38
|
+
function partition(signals) {
|
|
39
|
+
const sections = {
|
|
40
|
+
imports: [],
|
|
41
|
+
exports: [],
|
|
42
|
+
declarations: [],
|
|
43
|
+
raws: [],
|
|
44
|
+
};
|
|
45
|
+
for (const s of signals) {
|
|
46
|
+
if (s.kind === "import")
|
|
47
|
+
sections.imports.push(s);
|
|
48
|
+
else if (s.kind === "export")
|
|
49
|
+
sections.exports.push(s);
|
|
50
|
+
else if (s.kind === "raw")
|
|
51
|
+
sections.raws.push(s);
|
|
52
|
+
else
|
|
53
|
+
sections.declarations.push(s);
|
|
54
|
+
}
|
|
55
|
+
return sections;
|
|
56
|
+
}
|
|
57
|
+
function renderSections(sections, note) {
|
|
58
|
+
const lines = [];
|
|
59
|
+
let signalLineCount = 0;
|
|
60
|
+
if (note) {
|
|
61
|
+
lines.push(`Note: ${note}`);
|
|
62
|
+
lines.push("");
|
|
63
|
+
}
|
|
64
|
+
if (sections.imports.length > 0) {
|
|
65
|
+
lines.push("=== Imports ===");
|
|
66
|
+
sections.imports.forEach((s) => {
|
|
67
|
+
lines.push(formatSignalLine(s));
|
|
68
|
+
signalLineCount++;
|
|
69
|
+
});
|
|
70
|
+
lines.push("");
|
|
71
|
+
}
|
|
72
|
+
if (sections.exports.length > 0) {
|
|
73
|
+
lines.push("=== Exports / Public symbols ===");
|
|
74
|
+
sections.exports.forEach((s) => {
|
|
75
|
+
lines.push(formatSignalLine(s));
|
|
76
|
+
signalLineCount++;
|
|
77
|
+
});
|
|
78
|
+
lines.push("");
|
|
79
|
+
}
|
|
80
|
+
if (sections.declarations.length > 0) {
|
|
81
|
+
lines.push("=== Declarations ===");
|
|
82
|
+
sections.declarations.forEach((s) => {
|
|
83
|
+
lines.push(formatSignalLine(s));
|
|
84
|
+
signalLineCount++;
|
|
85
|
+
});
|
|
86
|
+
lines.push("");
|
|
87
|
+
}
|
|
88
|
+
if (sections.raws.length > 0) {
|
|
89
|
+
lines.push("=== Content preview (head + tail) ===");
|
|
90
|
+
sections.raws.forEach((s) => {
|
|
91
|
+
lines.push(formatSignalLine(s));
|
|
92
|
+
signalLineCount++;
|
|
93
|
+
});
|
|
94
|
+
lines.push("");
|
|
95
|
+
}
|
|
96
|
+
return { body: lines.join("\n"), signalLineCount };
|
|
97
|
+
}
|
|
98
|
+
export function formatDenyMessage(opts) {
|
|
99
|
+
const maxTokens = opts.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
100
|
+
// Build with full signal list first.
|
|
101
|
+
let sections = partition(opts.summary.signals);
|
|
102
|
+
let { body } = renderSections(sections, opts.summary.note);
|
|
103
|
+
let message = [header(opts), "", body, footer()].join("\n");
|
|
104
|
+
let trimmed = false;
|
|
105
|
+
// If we overflow, drop signals from the END of each section in lockstep
|
|
106
|
+
// until we fit. Keeps the overall signal distribution intact.
|
|
107
|
+
while (estimateTokens(message) > maxTokens) {
|
|
108
|
+
const totalSignals = sections.imports.length +
|
|
109
|
+
sections.exports.length +
|
|
110
|
+
sections.declarations.length +
|
|
111
|
+
sections.raws.length;
|
|
112
|
+
if (totalSignals === 0)
|
|
113
|
+
break;
|
|
114
|
+
// Trim 10 % of remaining signals per pass (minimum 1) to converge quickly.
|
|
115
|
+
const drop = Math.max(1, Math.floor(totalSignals * 0.1));
|
|
116
|
+
let toDrop = drop;
|
|
117
|
+
for (const bucket of [
|
|
118
|
+
"raws",
|
|
119
|
+
"declarations",
|
|
120
|
+
"exports",
|
|
121
|
+
"imports",
|
|
122
|
+
]) {
|
|
123
|
+
while (toDrop > 0 && sections[bucket].length > 0) {
|
|
124
|
+
sections[bucket].pop();
|
|
125
|
+
toDrop--;
|
|
126
|
+
}
|
|
127
|
+
if (toDrop === 0)
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
trimmed = true;
|
|
131
|
+
({ body } = renderSections(sections, opts.summary.note));
|
|
132
|
+
message = [header(opts), "", body, footer()].join("\n");
|
|
133
|
+
}
|
|
134
|
+
if (trimmed) {
|
|
135
|
+
const trimmedNote = "\n(trimmed to fit budget; call mcp__token-pilot__outline(path) for full structure)";
|
|
136
|
+
message = [
|
|
137
|
+
header(opts),
|
|
138
|
+
"",
|
|
139
|
+
body.trimEnd(),
|
|
140
|
+
trimmedNote,
|
|
141
|
+
"",
|
|
142
|
+
footer(),
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
145
|
+
return message;
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=format-deny-message.js.map
|
package/dist/hooks/installer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from
|
|
2
|
-
import { resolve, dirname } from
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
3
|
/**
|
|
4
4
|
* Build hook command that works in any shell (/bin/sh, bash, etc.)
|
|
5
5
|
* Uses absolute paths to node + script to avoid PATH/nvm issues.
|
|
@@ -21,7 +21,7 @@ function createHookConfig(options) {
|
|
|
21
21
|
hooks: [
|
|
22
22
|
{
|
|
23
23
|
type: "command",
|
|
24
|
-
command: buildHookCommand(
|
|
24
|
+
command: buildHookCommand("hook-read", options),
|
|
25
25
|
},
|
|
26
26
|
],
|
|
27
27
|
},
|
|
@@ -30,7 +30,37 @@ function createHookConfig(options) {
|
|
|
30
30
|
hooks: [
|
|
31
31
|
{
|
|
32
32
|
type: "command",
|
|
33
|
-
command: buildHookCommand(
|
|
33
|
+
command: buildHookCommand("hook-edit", options),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
SessionStart: [
|
|
39
|
+
{
|
|
40
|
+
hooks: [
|
|
41
|
+
{
|
|
42
|
+
type: "command",
|
|
43
|
+
command: buildHookCommand("hook-session-start", options),
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
PostToolUse: [
|
|
49
|
+
{
|
|
50
|
+
matcher: "Bash",
|
|
51
|
+
hooks: [
|
|
52
|
+
{
|
|
53
|
+
type: "command",
|
|
54
|
+
command: buildHookCommand("hook-post-bash", options),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
matcher: "Task",
|
|
60
|
+
hooks: [
|
|
61
|
+
{
|
|
62
|
+
type: "command",
|
|
63
|
+
command: buildHookCommand("hook-post-task", options),
|
|
34
64
|
},
|
|
35
65
|
],
|
|
36
66
|
},
|
|
@@ -46,9 +76,13 @@ export async function installHook(projectRoot, options) {
|
|
|
46
76
|
// Skip auto-install when running as a Claude Code plugin —
|
|
47
77
|
// the plugin system already registers hooks via .claude-plugin/hooks/hooks.json
|
|
48
78
|
if (process.env.CLAUDE_PLUGIN_ROOT) {
|
|
49
|
-
return {
|
|
79
|
+
return {
|
|
80
|
+
installed: false,
|
|
81
|
+
fatal: false,
|
|
82
|
+
message: "Running as plugin — hooks registered via plugin system.",
|
|
83
|
+
};
|
|
50
84
|
}
|
|
51
|
-
const settingsPath = resolve(projectRoot,
|
|
85
|
+
const settingsPath = resolve(projectRoot, ".claude", "settings.json");
|
|
52
86
|
const hookConfig = createHookConfig(options);
|
|
53
87
|
try {
|
|
54
88
|
// Ensure .claude dir exists
|
|
@@ -56,7 +90,7 @@ export async function installHook(projectRoot, options) {
|
|
|
56
90
|
let settings = {};
|
|
57
91
|
// Try to read existing settings
|
|
58
92
|
try {
|
|
59
|
-
const raw = await readFile(settingsPath,
|
|
93
|
+
const raw = await readFile(settingsPath, "utf-8");
|
|
60
94
|
try {
|
|
61
95
|
settings = JSON.parse(raw);
|
|
62
96
|
}
|
|
@@ -70,7 +104,7 @@ export async function installHook(projectRoot, options) {
|
|
|
70
104
|
}
|
|
71
105
|
}
|
|
72
106
|
catch (err) {
|
|
73
|
-
if (err?.code !==
|
|
107
|
+
if (err?.code !== "ENOENT") {
|
|
74
108
|
return {
|
|
75
109
|
installed: false,
|
|
76
110
|
fatal: true,
|
|
@@ -81,24 +115,32 @@ export async function installHook(projectRoot, options) {
|
|
|
81
115
|
}
|
|
82
116
|
// Check which Token Pilot hooks already exist
|
|
83
117
|
const existingHooks = settings.hooks?.PreToolUse;
|
|
84
|
-
const isTokenPilotHook = (h) => h.hooks?.some((hook) => hook.command?.includes(
|
|
118
|
+
const isTokenPilotHook = (h) => h.hooks?.some((hook) => hook.command?.includes("token-pilot"));
|
|
85
119
|
if (Array.isArray(existingHooks)) {
|
|
86
120
|
// Remove old broken hooks (bare "token-pilot" without absolute path)
|
|
87
121
|
// and replace with working ones using absolute paths
|
|
88
|
-
const oldBrokenHooks = existingHooks.filter((h) => isTokenPilotHook(h) &&
|
|
89
|
-
|
|
122
|
+
const oldBrokenHooks = existingHooks.filter((h) => isTokenPilotHook(h) &&
|
|
123
|
+
h.hooks?.some((hook) => hook.command?.match(/^token-pilot\s/)));
|
|
90
124
|
if (oldBrokenHooks.length > 0 && options?.scriptPath) {
|
|
91
125
|
// Remove old broken hooks, will re-add with absolute paths below
|
|
92
126
|
settings.hooks.PreToolUse = existingHooks.filter((h) => !isTokenPilotHook(h));
|
|
93
127
|
}
|
|
94
128
|
else {
|
|
95
|
-
const hasRead = existingHooks.some((h) => h.matcher ===
|
|
96
|
-
const hasEdit = existingHooks.some((h) => h.matcher ===
|
|
97
|
-
|
|
98
|
-
|
|
129
|
+
const hasRead = existingHooks.some((h) => h.matcher === "Read" && isTokenPilotHook(h));
|
|
130
|
+
const hasEdit = existingHooks.some((h) => h.matcher === "Edit" && isTokenPilotHook(h));
|
|
131
|
+
const hasSessionStart = Array.isArray(settings.hooks?.SessionStart) &&
|
|
132
|
+
settings.hooks.SessionStart.some(isTokenPilotHook);
|
|
133
|
+
const hasPostBashHook = Array.isArray(settings.hooks?.PostToolUse) &&
|
|
134
|
+
settings.hooks.PostToolUse.some(isTokenPilotHook);
|
|
135
|
+
if (hasRead && hasEdit && hasSessionStart && hasPostBashHook) {
|
|
136
|
+
return {
|
|
137
|
+
installed: false,
|
|
138
|
+
fatal: false,
|
|
139
|
+
message: "Token Pilot hooks already installed.",
|
|
140
|
+
};
|
|
99
141
|
}
|
|
100
142
|
}
|
|
101
|
-
// Add missing hooks
|
|
143
|
+
// Add missing PreToolUse hooks
|
|
102
144
|
for (const hookDef of hookConfig.hooks.PreToolUse) {
|
|
103
145
|
const exists = settings.hooks.PreToolUse.some((h) => h.matcher === hookDef.matcher && isTokenPilotHook(h));
|
|
104
146
|
if (!exists) {
|
|
@@ -112,7 +154,30 @@ export async function installHook(projectRoot, options) {
|
|
|
112
154
|
settings.hooks = {};
|
|
113
155
|
settings.hooks.PreToolUse = hookConfig.hooks.PreToolUse;
|
|
114
156
|
}
|
|
115
|
-
|
|
157
|
+
// Install SessionStart hook idempotently
|
|
158
|
+
const existingSessionStart = settings.hooks?.SessionStart;
|
|
159
|
+
const hasSessionStart = Array.isArray(existingSessionStart) &&
|
|
160
|
+
existingSessionStart.some(isTokenPilotHook);
|
|
161
|
+
if (!hasSessionStart) {
|
|
162
|
+
if (!settings.hooks)
|
|
163
|
+
settings.hooks = {};
|
|
164
|
+
if (!Array.isArray(settings.hooks.SessionStart)) {
|
|
165
|
+
settings.hooks.SessionStart = [];
|
|
166
|
+
}
|
|
167
|
+
settings.hooks.SessionStart.push(...hookConfig.hooks.SessionStart);
|
|
168
|
+
}
|
|
169
|
+
// Install PostToolUse (Bash advisor) hook idempotently
|
|
170
|
+
const existingPost = settings.hooks?.PostToolUse;
|
|
171
|
+
const hasPostBash = Array.isArray(existingPost) && existingPost.some(isTokenPilotHook);
|
|
172
|
+
if (!hasPostBash) {
|
|
173
|
+
if (!settings.hooks)
|
|
174
|
+
settings.hooks = {};
|
|
175
|
+
if (!Array.isArray(settings.hooks.PostToolUse)) {
|
|
176
|
+
settings.hooks.PostToolUse = [];
|
|
177
|
+
}
|
|
178
|
+
settings.hooks.PostToolUse.push(...hookConfig.hooks.PostToolUse);
|
|
179
|
+
}
|
|
180
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
116
181
|
return {
|
|
117
182
|
installed: true,
|
|
118
183
|
fatal: false,
|
|
@@ -121,33 +186,63 @@ export async function installHook(projectRoot, options) {
|
|
|
121
186
|
}
|
|
122
187
|
catch (err) {
|
|
123
188
|
const msg = err instanceof Error ? err.message : String(err);
|
|
124
|
-
return {
|
|
189
|
+
return {
|
|
190
|
+
installed: false,
|
|
191
|
+
fatal: true,
|
|
192
|
+
message: `Failed to install hook: ${msg}`,
|
|
193
|
+
};
|
|
125
194
|
}
|
|
126
195
|
}
|
|
127
196
|
/**
|
|
128
197
|
* Remove Token Pilot hook from Claude Code settings.
|
|
129
198
|
*/
|
|
130
199
|
export async function uninstallHook(projectRoot) {
|
|
131
|
-
const settingsPath = resolve(projectRoot,
|
|
200
|
+
const settingsPath = resolve(projectRoot, ".claude", "settings.json");
|
|
132
201
|
try {
|
|
133
|
-
const raw = await readFile(settingsPath,
|
|
202
|
+
const raw = await readFile(settingsPath, "utf-8");
|
|
134
203
|
const settings = JSON.parse(raw);
|
|
135
|
-
|
|
136
|
-
|
|
204
|
+
const hasPreToolUse = !!settings.hooks?.PreToolUse;
|
|
205
|
+
const hasSessionStart = !!settings.hooks?.SessionStart;
|
|
206
|
+
const hasPostToolUse = !!settings.hooks?.PostToolUse;
|
|
207
|
+
if (!hasPreToolUse && !hasSessionStart && !hasPostToolUse) {
|
|
208
|
+
return { removed: false, fatal: false, message: "No hooks to remove." };
|
|
209
|
+
}
|
|
210
|
+
const isTokenPilotHook = (h) => h.hooks?.some((hook) => hook.command?.includes("token-pilot"));
|
|
211
|
+
if (Array.isArray(settings.hooks?.PreToolUse)) {
|
|
212
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((h) => !isTokenPilotHook(h));
|
|
213
|
+
if (settings.hooks.PreToolUse.length === 0) {
|
|
214
|
+
delete settings.hooks.PreToolUse;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (Array.isArray(settings.hooks?.SessionStart)) {
|
|
218
|
+
settings.hooks.SessionStart = settings.hooks.SessionStart.filter((h) => !isTokenPilotHook(h));
|
|
219
|
+
if (settings.hooks.SessionStart.length === 0) {
|
|
220
|
+
delete settings.hooks.SessionStart;
|
|
221
|
+
}
|
|
137
222
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
223
|
+
if (Array.isArray(settings.hooks?.PostToolUse)) {
|
|
224
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((h) => !isTokenPilotHook(h));
|
|
225
|
+
if (settings.hooks.PostToolUse.length === 0) {
|
|
226
|
+
delete settings.hooks.PostToolUse;
|
|
227
|
+
}
|
|
141
228
|
}
|
|
142
|
-
if (Object.keys(settings.hooks).length === 0) {
|
|
229
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
143
230
|
delete settings.hooks;
|
|
144
231
|
}
|
|
145
|
-
await writeFile(settingsPath, JSON.stringify(settings, null, 2) +
|
|
146
|
-
return {
|
|
232
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
233
|
+
return {
|
|
234
|
+
removed: true,
|
|
235
|
+
fatal: false,
|
|
236
|
+
message: "Token Pilot hook removed.",
|
|
237
|
+
};
|
|
147
238
|
}
|
|
148
239
|
catch (err) {
|
|
149
|
-
if (err?.code ===
|
|
150
|
-
return {
|
|
240
|
+
if (err?.code === "ENOENT") {
|
|
241
|
+
return {
|
|
242
|
+
removed: false,
|
|
243
|
+
fatal: false,
|
|
244
|
+
message: "Settings file not found.",
|
|
245
|
+
};
|
|
151
246
|
}
|
|
152
247
|
if (err instanceof SyntaxError) {
|
|
153
248
|
return {
|
|
@@ -156,7 +251,11 @@ export async function uninstallHook(projectRoot) {
|
|
|
156
251
|
message: `Settings file contains invalid JSON: ${settingsPath}. Fix it manually before uninstalling hooks.`,
|
|
157
252
|
};
|
|
158
253
|
}
|
|
159
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
removed: false,
|
|
256
|
+
fatal: true,
|
|
257
|
+
message: `Failed to process settings: ${err?.message ?? err}`,
|
|
258
|
+
};
|
|
160
259
|
}
|
|
161
260
|
}
|
|
162
261
|
//# sourceMappingURL=installer.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-safety check used by the PreToolUse hook before reading any file.
|
|
3
|
+
*
|
|
4
|
+
* Resolves both the target file and the project root through realpath
|
|
5
|
+
* (so symlinks cannot escape the sandbox), then requires the resolved
|
|
6
|
+
* file path to fall inside the resolved project directory. On any error
|
|
7
|
+
* (missing file, permission denied, realpath loop) we refuse — the hook
|
|
8
|
+
* will then pass-through rather than risk reading an attacker-crafted
|
|
9
|
+
* path.
|
|
10
|
+
*
|
|
11
|
+
* Sibling directories that share a common prefix (e.g. `/tmp/proj`
|
|
12
|
+
* vs `/tmp/proj-evil`) are rejected by forcing a path-separator on the
|
|
13
|
+
* normalised root.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isPathWithinProject(filePath: string, projectRoot: string): boolean;
|
|
16
|
+
//# sourceMappingURL=path-safety.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-safety check used by the PreToolUse hook before reading any file.
|
|
3
|
+
*
|
|
4
|
+
* Resolves both the target file and the project root through realpath
|
|
5
|
+
* (so symlinks cannot escape the sandbox), then requires the resolved
|
|
6
|
+
* file path to fall inside the resolved project directory. On any error
|
|
7
|
+
* (missing file, permission denied, realpath loop) we refuse — the hook
|
|
8
|
+
* will then pass-through rather than risk reading an attacker-crafted
|
|
9
|
+
* path.
|
|
10
|
+
*
|
|
11
|
+
* Sibling directories that share a common prefix (e.g. `/tmp/proj`
|
|
12
|
+
* vs `/tmp/proj-evil`) are rejected by forcing a path-separator on the
|
|
13
|
+
* normalised root.
|
|
14
|
+
*/
|
|
15
|
+
import { realpathSync } from "node:fs";
|
|
16
|
+
import { resolve, sep } from "node:path";
|
|
17
|
+
export function isPathWithinProject(filePath, projectRoot) {
|
|
18
|
+
if (!filePath || !projectRoot)
|
|
19
|
+
return false;
|
|
20
|
+
let resolvedFile;
|
|
21
|
+
let resolvedRoot;
|
|
22
|
+
try {
|
|
23
|
+
resolvedFile = realpathSync(resolve(filePath));
|
|
24
|
+
resolvedRoot = realpathSync(resolve(projectRoot));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (resolvedFile === resolvedRoot)
|
|
30
|
+
return true;
|
|
31
|
+
const prefix = resolvedRoot.endsWith(sep) ? resolvedRoot : resolvedRoot + sep;
|
|
32
|
+
return resolvedFile.startsWith(prefix);
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=path-safety.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TP-jzh — Bash output advisor.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's PostToolUse hook cannot modify or truncate `tool_response`
|
|
5
|
+
* (verified via Claude Code docs 2026-04-18 — the `updatedMCPToolOutput`
|
|
6
|
+
* field is MCP-only). The agent has already seen the full stdout by the
|
|
7
|
+
* time our hook fires.
|
|
8
|
+
*
|
|
9
|
+
* So the feature becomes an *advisory*: when Bash stdout is large, we
|
|
10
|
+
* append one line via `additionalContext` pointing the agent at cheaper
|
|
11
|
+
* alternatives (`mcp__token-pilot__test_summary` for tests, bounded
|
|
12
|
+
* commands, head/tail piping). The agent notices before it repeats the
|
|
13
|
+
* mistake on the next turn.
|
|
14
|
+
*/
|
|
15
|
+
export interface PostBashHookInput {
|
|
16
|
+
tool_name?: string;
|
|
17
|
+
tool_response?: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface PostBashAdvice {
|
|
20
|
+
/** Null when no advice is needed. */
|
|
21
|
+
additionalContext: string | null;
|
|
22
|
+
/** For telemetry: approximate output size the advisor saw. */
|
|
23
|
+
outputChars: number;
|
|
24
|
+
}
|
|
25
|
+
declare const LARGE_OUTPUT_THRESHOLD_CHARS = 8000;
|
|
26
|
+
/**
|
|
27
|
+
* Pure decision function. Given a PostToolUse hook input for the Bash
|
|
28
|
+
* tool, return advice text (or null to stay silent).
|
|
29
|
+
*/
|
|
30
|
+
export interface PostBashAdviceOptions {
|
|
31
|
+
thresholdChars?: number;
|
|
32
|
+
/**
|
|
33
|
+
* When true, the advice also mentions context-mode — runs the command
|
|
34
|
+
* in a sandbox so only stdout enters context. Caller detects whether
|
|
35
|
+
* context-mode is installed and passes the flag.
|
|
36
|
+
*/
|
|
37
|
+
contextModeAvailable?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare function decidePostBashAdvice(input: PostBashHookInput, thresholdCharsOrOpts?: number | PostBashAdviceOptions): PostBashAdvice;
|
|
40
|
+
/**
|
|
41
|
+
* Render the JSON payload Claude Code expects. Returns null for silent
|
|
42
|
+
* pass-through so the caller can simply `exit(0)` with no stdout.
|
|
43
|
+
*/
|
|
44
|
+
export declare function renderPostBashHookOutput(advice: PostBashAdvice): string | null;
|
|
45
|
+
export { LARGE_OUTPUT_THRESHOLD_CHARS };
|
|
46
|
+
//# sourceMappingURL=post-bash.d.ts.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TP-jzh — Bash output advisor.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's PostToolUse hook cannot modify or truncate `tool_response`
|
|
5
|
+
* (verified via Claude Code docs 2026-04-18 — the `updatedMCPToolOutput`
|
|
6
|
+
* field is MCP-only). The agent has already seen the full stdout by the
|
|
7
|
+
* time our hook fires.
|
|
8
|
+
*
|
|
9
|
+
* So the feature becomes an *advisory*: when Bash stdout is large, we
|
|
10
|
+
* append one line via `additionalContext` pointing the agent at cheaper
|
|
11
|
+
* alternatives (`mcp__token-pilot__test_summary` for tests, bounded
|
|
12
|
+
* commands, head/tail piping). The agent notices before it repeats the
|
|
13
|
+
* mistake on the next turn.
|
|
14
|
+
*/
|
|
15
|
+
const LARGE_OUTPUT_THRESHOLD_CHARS = 8000;
|
|
16
|
+
function extractStdout(tool_response) {
|
|
17
|
+
if (!tool_response)
|
|
18
|
+
return "";
|
|
19
|
+
if (typeof tool_response === "string")
|
|
20
|
+
return tool_response;
|
|
21
|
+
if (typeof tool_response === "object") {
|
|
22
|
+
const r = tool_response;
|
|
23
|
+
const parts = [];
|
|
24
|
+
for (const key of ["stdout", "output", "content"]) {
|
|
25
|
+
const v = r[key];
|
|
26
|
+
if (typeof v === "string")
|
|
27
|
+
parts.push(v);
|
|
28
|
+
}
|
|
29
|
+
return parts.join("\n");
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
function countLines(s) {
|
|
34
|
+
if (s === "")
|
|
35
|
+
return 0;
|
|
36
|
+
return s.split(/\r?\n/).length;
|
|
37
|
+
}
|
|
38
|
+
export function decidePostBashAdvice(input, thresholdCharsOrOpts = LARGE_OUTPUT_THRESHOLD_CHARS) {
|
|
39
|
+
const opts = typeof thresholdCharsOrOpts === "number"
|
|
40
|
+
? { thresholdChars: thresholdCharsOrOpts }
|
|
41
|
+
: thresholdCharsOrOpts;
|
|
42
|
+
const threshold = opts.thresholdChars ?? LARGE_OUTPUT_THRESHOLD_CHARS;
|
|
43
|
+
if (input.tool_name !== "Bash") {
|
|
44
|
+
return { additionalContext: null, outputChars: 0 };
|
|
45
|
+
}
|
|
46
|
+
const stdout = extractStdout(input.tool_response);
|
|
47
|
+
const chars = stdout.length;
|
|
48
|
+
if (chars < threshold) {
|
|
49
|
+
return { additionalContext: null, outputChars: chars };
|
|
50
|
+
}
|
|
51
|
+
const lines = countLines(stdout);
|
|
52
|
+
const roughTokens = Math.ceil(chars / 4);
|
|
53
|
+
const contextModeLine = opts.contextModeAvailable
|
|
54
|
+
? " Or run via mcp__context-mode__execute — sandbox keeps stdout out of your window."
|
|
55
|
+
: "";
|
|
56
|
+
const msg = `⚠ Bash output was large (~${lines} lines, ~${roughTokens} tokens). ` +
|
|
57
|
+
`Consider mcp__token-pilot__test_summary for test runs, or bounded commands ` +
|
|
58
|
+
`(head/tail, --oneline, git log -n <N>, grep -m <N>) to keep context lean.` +
|
|
59
|
+
contextModeLine;
|
|
60
|
+
return { additionalContext: msg, outputChars: chars };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Render the JSON payload Claude Code expects. Returns null for silent
|
|
64
|
+
* pass-through so the caller can simply `exit(0)` with no stdout.
|
|
65
|
+
*/
|
|
66
|
+
export function renderPostBashHookOutput(advice) {
|
|
67
|
+
if (!advice.additionalContext)
|
|
68
|
+
return null;
|
|
69
|
+
return JSON.stringify({
|
|
70
|
+
hookSpecificOutput: {
|
|
71
|
+
hookEventName: "PostToolUse",
|
|
72
|
+
additionalContext: advice.additionalContext,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export { LARGE_OUTPUT_THRESHOLD_CHARS };
|
|
77
|
+
//# sourceMappingURL=post-bash.js.map
|