token-pilot 0.19.2 → 0.22.2
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 +21 -0
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +129 -0
- package/README.md +172 -315
- 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-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-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/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 +121 -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/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 +509 -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
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,28 @@ 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),
|
|
34
55
|
},
|
|
35
56
|
],
|
|
36
57
|
},
|
|
@@ -46,9 +67,13 @@ export async function installHook(projectRoot, options) {
|
|
|
46
67
|
// Skip auto-install when running as a Claude Code plugin —
|
|
47
68
|
// the plugin system already registers hooks via .claude-plugin/hooks/hooks.json
|
|
48
69
|
if (process.env.CLAUDE_PLUGIN_ROOT) {
|
|
49
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
installed: false,
|
|
72
|
+
fatal: false,
|
|
73
|
+
message: "Running as plugin — hooks registered via plugin system.",
|
|
74
|
+
};
|
|
50
75
|
}
|
|
51
|
-
const settingsPath = resolve(projectRoot,
|
|
76
|
+
const settingsPath = resolve(projectRoot, ".claude", "settings.json");
|
|
52
77
|
const hookConfig = createHookConfig(options);
|
|
53
78
|
try {
|
|
54
79
|
// Ensure .claude dir exists
|
|
@@ -56,7 +81,7 @@ export async function installHook(projectRoot, options) {
|
|
|
56
81
|
let settings = {};
|
|
57
82
|
// Try to read existing settings
|
|
58
83
|
try {
|
|
59
|
-
const raw = await readFile(settingsPath,
|
|
84
|
+
const raw = await readFile(settingsPath, "utf-8");
|
|
60
85
|
try {
|
|
61
86
|
settings = JSON.parse(raw);
|
|
62
87
|
}
|
|
@@ -70,7 +95,7 @@ export async function installHook(projectRoot, options) {
|
|
|
70
95
|
}
|
|
71
96
|
}
|
|
72
97
|
catch (err) {
|
|
73
|
-
if (err?.code !==
|
|
98
|
+
if (err?.code !== "ENOENT") {
|
|
74
99
|
return {
|
|
75
100
|
installed: false,
|
|
76
101
|
fatal: true,
|
|
@@ -81,24 +106,32 @@ export async function installHook(projectRoot, options) {
|
|
|
81
106
|
}
|
|
82
107
|
// Check which Token Pilot hooks already exist
|
|
83
108
|
const existingHooks = settings.hooks?.PreToolUse;
|
|
84
|
-
const isTokenPilotHook = (h) => h.hooks?.some((hook) => hook.command?.includes(
|
|
109
|
+
const isTokenPilotHook = (h) => h.hooks?.some((hook) => hook.command?.includes("token-pilot"));
|
|
85
110
|
if (Array.isArray(existingHooks)) {
|
|
86
111
|
// Remove old broken hooks (bare "token-pilot" without absolute path)
|
|
87
112
|
// and replace with working ones using absolute paths
|
|
88
|
-
const oldBrokenHooks = existingHooks.filter((h) => isTokenPilotHook(h) &&
|
|
89
|
-
|
|
113
|
+
const oldBrokenHooks = existingHooks.filter((h) => isTokenPilotHook(h) &&
|
|
114
|
+
h.hooks?.some((hook) => hook.command?.match(/^token-pilot\s/)));
|
|
90
115
|
if (oldBrokenHooks.length > 0 && options?.scriptPath) {
|
|
91
116
|
// Remove old broken hooks, will re-add with absolute paths below
|
|
92
117
|
settings.hooks.PreToolUse = existingHooks.filter((h) => !isTokenPilotHook(h));
|
|
93
118
|
}
|
|
94
119
|
else {
|
|
95
|
-
const hasRead = existingHooks.some((h) => h.matcher ===
|
|
96
|
-
const hasEdit = existingHooks.some((h) => h.matcher ===
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
const hasRead = existingHooks.some((h) => h.matcher === "Read" && isTokenPilotHook(h));
|
|
121
|
+
const hasEdit = existingHooks.some((h) => h.matcher === "Edit" && isTokenPilotHook(h));
|
|
122
|
+
const hasSessionStart = Array.isArray(settings.hooks?.SessionStart) &&
|
|
123
|
+
settings.hooks.SessionStart.some(isTokenPilotHook);
|
|
124
|
+
const hasPostBashHook = Array.isArray(settings.hooks?.PostToolUse) &&
|
|
125
|
+
settings.hooks.PostToolUse.some(isTokenPilotHook);
|
|
126
|
+
if (hasRead && hasEdit && hasSessionStart && hasPostBashHook) {
|
|
127
|
+
return {
|
|
128
|
+
installed: false,
|
|
129
|
+
fatal: false,
|
|
130
|
+
message: "Token Pilot hooks already installed.",
|
|
131
|
+
};
|
|
99
132
|
}
|
|
100
133
|
}
|
|
101
|
-
// Add missing hooks
|
|
134
|
+
// Add missing PreToolUse hooks
|
|
102
135
|
for (const hookDef of hookConfig.hooks.PreToolUse) {
|
|
103
136
|
const exists = settings.hooks.PreToolUse.some((h) => h.matcher === hookDef.matcher && isTokenPilotHook(h));
|
|
104
137
|
if (!exists) {
|
|
@@ -112,7 +145,30 @@ export async function installHook(projectRoot, options) {
|
|
|
112
145
|
settings.hooks = {};
|
|
113
146
|
settings.hooks.PreToolUse = hookConfig.hooks.PreToolUse;
|
|
114
147
|
}
|
|
115
|
-
|
|
148
|
+
// Install SessionStart hook idempotently
|
|
149
|
+
const existingSessionStart = settings.hooks?.SessionStart;
|
|
150
|
+
const hasSessionStart = Array.isArray(existingSessionStart) &&
|
|
151
|
+
existingSessionStart.some(isTokenPilotHook);
|
|
152
|
+
if (!hasSessionStart) {
|
|
153
|
+
if (!settings.hooks)
|
|
154
|
+
settings.hooks = {};
|
|
155
|
+
if (!Array.isArray(settings.hooks.SessionStart)) {
|
|
156
|
+
settings.hooks.SessionStart = [];
|
|
157
|
+
}
|
|
158
|
+
settings.hooks.SessionStart.push(...hookConfig.hooks.SessionStart);
|
|
159
|
+
}
|
|
160
|
+
// Install PostToolUse (Bash advisor) hook idempotently
|
|
161
|
+
const existingPost = settings.hooks?.PostToolUse;
|
|
162
|
+
const hasPostBash = Array.isArray(existingPost) && existingPost.some(isTokenPilotHook);
|
|
163
|
+
if (!hasPostBash) {
|
|
164
|
+
if (!settings.hooks)
|
|
165
|
+
settings.hooks = {};
|
|
166
|
+
if (!Array.isArray(settings.hooks.PostToolUse)) {
|
|
167
|
+
settings.hooks.PostToolUse = [];
|
|
168
|
+
}
|
|
169
|
+
settings.hooks.PostToolUse.push(...hookConfig.hooks.PostToolUse);
|
|
170
|
+
}
|
|
171
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
116
172
|
return {
|
|
117
173
|
installed: true,
|
|
118
174
|
fatal: false,
|
|
@@ -121,33 +177,63 @@ export async function installHook(projectRoot, options) {
|
|
|
121
177
|
}
|
|
122
178
|
catch (err) {
|
|
123
179
|
const msg = err instanceof Error ? err.message : String(err);
|
|
124
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
installed: false,
|
|
182
|
+
fatal: true,
|
|
183
|
+
message: `Failed to install hook: ${msg}`,
|
|
184
|
+
};
|
|
125
185
|
}
|
|
126
186
|
}
|
|
127
187
|
/**
|
|
128
188
|
* Remove Token Pilot hook from Claude Code settings.
|
|
129
189
|
*/
|
|
130
190
|
export async function uninstallHook(projectRoot) {
|
|
131
|
-
const settingsPath = resolve(projectRoot,
|
|
191
|
+
const settingsPath = resolve(projectRoot, ".claude", "settings.json");
|
|
132
192
|
try {
|
|
133
|
-
const raw = await readFile(settingsPath,
|
|
193
|
+
const raw = await readFile(settingsPath, "utf-8");
|
|
134
194
|
const settings = JSON.parse(raw);
|
|
135
|
-
|
|
136
|
-
|
|
195
|
+
const hasPreToolUse = !!settings.hooks?.PreToolUse;
|
|
196
|
+
const hasSessionStart = !!settings.hooks?.SessionStart;
|
|
197
|
+
const hasPostToolUse = !!settings.hooks?.PostToolUse;
|
|
198
|
+
if (!hasPreToolUse && !hasSessionStart && !hasPostToolUse) {
|
|
199
|
+
return { removed: false, fatal: false, message: "No hooks to remove." };
|
|
137
200
|
}
|
|
138
|
-
|
|
139
|
-
if (settings.hooks
|
|
140
|
-
|
|
201
|
+
const isTokenPilotHook = (h) => h.hooks?.some((hook) => hook.command?.includes("token-pilot"));
|
|
202
|
+
if (Array.isArray(settings.hooks?.PreToolUse)) {
|
|
203
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((h) => !isTokenPilotHook(h));
|
|
204
|
+
if (settings.hooks.PreToolUse.length === 0) {
|
|
205
|
+
delete settings.hooks.PreToolUse;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (Array.isArray(settings.hooks?.SessionStart)) {
|
|
209
|
+
settings.hooks.SessionStart = settings.hooks.SessionStart.filter((h) => !isTokenPilotHook(h));
|
|
210
|
+
if (settings.hooks.SessionStart.length === 0) {
|
|
211
|
+
delete settings.hooks.SessionStart;
|
|
212
|
+
}
|
|
141
213
|
}
|
|
142
|
-
if (
|
|
214
|
+
if (Array.isArray(settings.hooks?.PostToolUse)) {
|
|
215
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((h) => !isTokenPilotHook(h));
|
|
216
|
+
if (settings.hooks.PostToolUse.length === 0) {
|
|
217
|
+
delete settings.hooks.PostToolUse;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
143
221
|
delete settings.hooks;
|
|
144
222
|
}
|
|
145
|
-
await writeFile(settingsPath, JSON.stringify(settings, null, 2) +
|
|
146
|
-
return {
|
|
223
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
224
|
+
return {
|
|
225
|
+
removed: true,
|
|
226
|
+
fatal: false,
|
|
227
|
+
message: "Token Pilot hook removed.",
|
|
228
|
+
};
|
|
147
229
|
}
|
|
148
230
|
catch (err) {
|
|
149
|
-
if (err?.code ===
|
|
150
|
-
return {
|
|
231
|
+
if (err?.code === "ENOENT") {
|
|
232
|
+
return {
|
|
233
|
+
removed: false,
|
|
234
|
+
fatal: false,
|
|
235
|
+
message: "Settings file not found.",
|
|
236
|
+
};
|
|
151
237
|
}
|
|
152
238
|
if (err instanceof SyntaxError) {
|
|
153
239
|
return {
|
|
@@ -156,7 +242,11 @@ export async function uninstallHook(projectRoot) {
|
|
|
156
242
|
message: `Settings file contains invalid JSON: ${settingsPath}. Fix it manually before uninstalling hooks.`,
|
|
157
243
|
};
|
|
158
244
|
}
|
|
159
|
-
return {
|
|
245
|
+
return {
|
|
246
|
+
removed: false,
|
|
247
|
+
fatal: true,
|
|
248
|
+
message: `Failed to process settings: ${err?.message ?? err}`,
|
|
249
|
+
};
|
|
160
250
|
}
|
|
161
251
|
}
|
|
162
252
|
//# 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
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStart reminder hook — Component 2 of the enforcement layer.
|
|
3
|
+
*
|
|
4
|
+
* On every session start / /clear / /compact, emits a compact additionalContext
|
|
5
|
+
* block containing the mandatory-tool rules and a list of tp-* subagents found
|
|
6
|
+
* in the project and user agent directories.
|
|
7
|
+
*
|
|
8
|
+
* Output contract: one JSON line on stdout, or exit 0 silent.
|
|
9
|
+
*/
|
|
10
|
+
export interface AgentEntry {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SessionStartConfig {
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
showStats: boolean;
|
|
17
|
+
maxReminderTokens: number;
|
|
18
|
+
}
|
|
19
|
+
export interface HandleSessionStartOptions {
|
|
20
|
+
projectRoot: string;
|
|
21
|
+
homeDir: string;
|
|
22
|
+
sessionStartConfig: SessionStartConfig;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Scan ~/.claude/agents/ and ./.claude/agents/ for tp-*.md agent definitions.
|
|
26
|
+
* Project directory takes precedence; duplicates (by name) are dropped.
|
|
27
|
+
*
|
|
28
|
+
* @param projectRoot - absolute path to the project root
|
|
29
|
+
* @param homeDir - home directory (injected for testability; defaults to os.homedir())
|
|
30
|
+
*/
|
|
31
|
+
export declare function scanAgents(projectRoot: string, homeDir: string): Promise<AgentEntry[]>;
|
|
32
|
+
/**
|
|
33
|
+
* Build the reminder message combining the mandatory-tool rules and the
|
|
34
|
+
* tp-* agent list. Enforces the maxReminderTokens budget by trimming the
|
|
35
|
+
* delegating list with "… and N more" if needed.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildReminderMessage(agents: AgentEntry[], maxReminderTokens: number): string;
|
|
38
|
+
/**
|
|
39
|
+
* Main handler for the hook-session-start CLI command.
|
|
40
|
+
*
|
|
41
|
+
* Returns the JSON string to write to stdout, or null for silent exit.
|
|
42
|
+
* Never throws — any error → null (fail-safe pass-through).
|
|
43
|
+
*/
|
|
44
|
+
export declare function handleSessionStart(opts: HandleSessionStartOptions): Promise<string | null>;
|
|
45
|
+
//# sourceMappingURL=session-start.d.ts.map
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStart reminder hook — Component 2 of the enforcement layer.
|
|
3
|
+
*
|
|
4
|
+
* On every session start / /clear / /compact, emits a compact additionalContext
|
|
5
|
+
* block containing the mandatory-tool rules and a list of tp-* subagents found
|
|
6
|
+
* in the project and user agent directories.
|
|
7
|
+
*
|
|
8
|
+
* Output contract: one JSON line on stdout, or exit 0 silent.
|
|
9
|
+
*/
|
|
10
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
11
|
+
import { join, basename } from "node:path";
|
|
12
|
+
import { loadLatestSnapshot } from "./../handlers/session-snapshot-persist.js";
|
|
13
|
+
const SNAPSHOT_FRESH_MS = 2 * 3600 * 1000; // 2h — enough to cover compaction/restart, tight enough that a new day's unrelated work doesn't inherit yesterday's thread
|
|
14
|
+
function extractSnapshotGoal(body) {
|
|
15
|
+
const m = body.match(/\*\*Goal:\*\*\s*(.+?)(?:\n|$)/);
|
|
16
|
+
return m ? m[1].trim().slice(0, 100) : null;
|
|
17
|
+
}
|
|
18
|
+
// ─── Agent scanner (subtask 2.2) ─────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Parse YAML-style frontmatter from a markdown file.
|
|
21
|
+
* Only handles simple key: value pairs (no nested, no arrays).
|
|
22
|
+
* Returns an object with extracted string fields.
|
|
23
|
+
*/
|
|
24
|
+
function parseFrontmatter(content) {
|
|
25
|
+
const result = {};
|
|
26
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
27
|
+
if (!match)
|
|
28
|
+
return result;
|
|
29
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
30
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
31
|
+
if (kv) {
|
|
32
|
+
result[kv[1]] = kv[2].trim();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Scan one agents directory for tp-*.md files and return parsed entries.
|
|
39
|
+
*/
|
|
40
|
+
async function scanDir(dir) {
|
|
41
|
+
let names;
|
|
42
|
+
try {
|
|
43
|
+
names = await readdir(dir);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const agents = [];
|
|
49
|
+
for (const filename of names) {
|
|
50
|
+
if (!filename.startsWith("tp-") || !filename.endsWith(".md"))
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
const content = await readFile(join(dir, filename), "utf-8");
|
|
54
|
+
const fm = parseFrontmatter(content);
|
|
55
|
+
const stem = basename(filename, ".md");
|
|
56
|
+
agents.push({
|
|
57
|
+
name: fm.name ?? stem,
|
|
58
|
+
description: fm.description ?? "",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Skip unreadable files
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return agents;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Scan ~/.claude/agents/ and ./.claude/agents/ for tp-*.md agent definitions.
|
|
69
|
+
* Project directory takes precedence; duplicates (by name) are dropped.
|
|
70
|
+
*
|
|
71
|
+
* @param projectRoot - absolute path to the project root
|
|
72
|
+
* @param homeDir - home directory (injected for testability; defaults to os.homedir())
|
|
73
|
+
*/
|
|
74
|
+
export async function scanAgents(projectRoot, homeDir) {
|
|
75
|
+
const projectAgentsDir = join(projectRoot, ".claude", "agents");
|
|
76
|
+
const homeAgentsDir = join(homeDir, ".claude", "agents");
|
|
77
|
+
const [projectAgents, homeAgents] = await Promise.all([
|
|
78
|
+
scanDir(projectAgentsDir),
|
|
79
|
+
scanDir(homeAgentsDir),
|
|
80
|
+
]);
|
|
81
|
+
// Merge: project agents first; home agents fill in names not already present
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const merged = [];
|
|
84
|
+
for (const agent of [...projectAgents, ...homeAgents]) {
|
|
85
|
+
if (!seen.has(agent.name)) {
|
|
86
|
+
seen.add(agent.name);
|
|
87
|
+
merged.push(agent);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return merged;
|
|
91
|
+
}
|
|
92
|
+
// ─── Message builder (subtask 2.3) ───────────────────────────────────────────
|
|
93
|
+
const MANDATORY_BLOCK = `[token-pilot active]
|
|
94
|
+
|
|
95
|
+
MANDATORY — for code files, use these before raw Read:
|
|
96
|
+
mcp__token-pilot__smart_read(path) — structural overview
|
|
97
|
+
mcp__token-pilot__read_symbol(path, sym) — one function / class
|
|
98
|
+
mcp__token-pilot__read_for_edit(path, sym)— exact text for editing
|
|
99
|
+
mcp__token-pilot__outline(path) — symbol list
|
|
100
|
+
Raw Read allowed only with offset/limit or TOKEN_PILOT_BYPASS=1.`;
|
|
101
|
+
function estimateTokens(text) {
|
|
102
|
+
// Fast approximation: chars / 4, adjusted for whitespace
|
|
103
|
+
if (text.length === 0)
|
|
104
|
+
return 0;
|
|
105
|
+
return Math.ceil(text.length / 4);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build the reminder message combining the mandatory-tool rules and the
|
|
109
|
+
* tp-* agent list. Enforces the maxReminderTokens budget by trimming the
|
|
110
|
+
* delegating list with "… and N more" if needed.
|
|
111
|
+
*/
|
|
112
|
+
export function buildReminderMessage(agents, maxReminderTokens) {
|
|
113
|
+
const agentLines = agents.length === 0
|
|
114
|
+
? " none installed — run: npx token-pilot install-agents"
|
|
115
|
+
: agents.map((a) => ` ${a.name} — ${a.description}`).join("\n");
|
|
116
|
+
const delegatingSection = `WHEN DELEGATING — use the right token-pilot-native subagent:\n${agentLines}`;
|
|
117
|
+
const full = `${MANDATORY_BLOCK}\n\n${delegatingSection}`;
|
|
118
|
+
if (estimateTokens(full) <= maxReminderTokens) {
|
|
119
|
+
return full;
|
|
120
|
+
}
|
|
121
|
+
// Trim agent list until we fit. Distinguish "all agents trimmed due to
|
|
122
|
+
// budget" (count remained >0 in the original list) from "no agents at
|
|
123
|
+
// all" — they look the same to `trimmedAgents.length === 0` but mean
|
|
124
|
+
// very different things to the caller (one requires install-agents; the
|
|
125
|
+
// other just reports "N more hidden").
|
|
126
|
+
let trimmedAgents = [...agents];
|
|
127
|
+
while (trimmedAgents.length > 0) {
|
|
128
|
+
trimmedAgents = trimmedAgents.slice(0, trimmedAgents.length - 1);
|
|
129
|
+
const dropped = agents.length - trimmedAgents.length;
|
|
130
|
+
const trimmedLines = trimmedAgents.length === 0
|
|
131
|
+
? ` … and ${dropped} more (reminder budget exhausted)`
|
|
132
|
+
: trimmedAgents
|
|
133
|
+
.map((a) => ` ${a.name} — ${a.description}`)
|
|
134
|
+
.join("\n") + `\n … and ${dropped} more`;
|
|
135
|
+
const candidate = `${MANDATORY_BLOCK}\n\nWHEN DELEGATING — use the right token-pilot-native subagent:\n${trimmedLines}`;
|
|
136
|
+
if (estimateTokens(candidate) <= maxReminderTokens) {
|
|
137
|
+
return candidate;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Last resort: just the mandatory block
|
|
141
|
+
return MANDATORY_BLOCK;
|
|
142
|
+
}
|
|
143
|
+
// ─── Handler (subtask 2.4) ───────────────────────────────────────────────────
|
|
144
|
+
/**
|
|
145
|
+
* Main handler for the hook-session-start CLI command.
|
|
146
|
+
*
|
|
147
|
+
* Returns the JSON string to write to stdout, or null for silent exit.
|
|
148
|
+
* Never throws — any error → null (fail-safe pass-through).
|
|
149
|
+
*/
|
|
150
|
+
export async function handleSessionStart(opts) {
|
|
151
|
+
try {
|
|
152
|
+
if (!opts.sessionStartConfig.enabled) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const agents = await scanAgents(opts.projectRoot, opts.homeDir);
|
|
156
|
+
let message = buildReminderMessage(agents, opts.sessionStartConfig.maxReminderTokens);
|
|
157
|
+
// TP-340: surface a fresh snapshot so the new session can resume.
|
|
158
|
+
const snap = await loadLatestSnapshot(opts.projectRoot);
|
|
159
|
+
if (snap && snap.ageMs < SNAPSHOT_FRESH_MS) {
|
|
160
|
+
const minutes = Math.round(snap.ageMs / 60000);
|
|
161
|
+
const age = minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`;
|
|
162
|
+
const goal = extractSnapshotGoal(snap.body);
|
|
163
|
+
const goalClause = goal ? ` (goal: "${goal}")` : "";
|
|
164
|
+
message += `\n\n[token-pilot] session_snapshot from ${age}${goalClause}. Read .token-pilot/snapshots/latest.md to resume — or ignore if unrelated.`;
|
|
165
|
+
}
|
|
166
|
+
const output = {
|
|
167
|
+
hookSpecificOutput: {
|
|
168
|
+
hookEventName: "SessionStart",
|
|
169
|
+
additionalContext: message,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
return JSON.stringify(output);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Fail-safe: never block the session
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=session-start.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primary hook-summary parser: spawns the bundled `ast-index` binary with
|
|
3
|
+
* `ast-index outline <path>` and maps the returned outline entries to
|
|
4
|
+
* SignalLine[]. Returns null when the binary is unavailable or the
|
|
5
|
+
* subprocess fails — the pipeline then falls back to regex / head+tail.
|
|
6
|
+
*
|
|
7
|
+
* Short-lived: the hook process spawns the binary once per invocation.
|
|
8
|
+
* The long-running AstIndexClient used by the MCP server is intentionally
|
|
9
|
+
* NOT reused here to keep the hook's startup cost minimal.
|
|
10
|
+
*/
|
|
11
|
+
import type { HookSummary } from "./summary-types.js";
|
|
12
|
+
type ExecFn = (binary: string, args: string[], opts: {
|
|
13
|
+
timeout: number;
|
|
14
|
+
}) => Promise<{
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}>;
|
|
18
|
+
export interface AstIndexSummaryOptions {
|
|
19
|
+
/** Explicit binary path. `null` means "no binary available" → returns null. Omit to resolve via findBinary. */
|
|
20
|
+
binaryPath?: string | null;
|
|
21
|
+
/** Subprocess timeout (ms). Default 4000. */
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
/** Injectable spawner for tests. */
|
|
24
|
+
exec?: ExecFn;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseAstIndexSummary(content: string, filePath: string, options?: AstIndexSummaryOptions): Promise<HookSummary | null>;
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=summary-ast-index.d.ts.map
|