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.
Files changed (89) hide show
  1. package/.claude-plugin/hooks/hooks.json +21 -0
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +172 -315
  5. package/dist/agents/tp-commit-writer.md +41 -0
  6. package/dist/agents/tp-dead-code-finder.md +43 -0
  7. package/dist/agents/tp-debugger.md +45 -0
  8. package/dist/agents/tp-impact-analyzer.md +44 -0
  9. package/dist/agents/tp-migration-scout.md +43 -0
  10. package/dist/agents/tp-onboard.md +40 -0
  11. package/dist/agents/tp-pr-reviewer.md +41 -0
  12. package/dist/agents/tp-refactor-planner.md +42 -0
  13. package/dist/agents/tp-run.md +48 -0
  14. package/dist/agents/tp-test-triage.md +40 -0
  15. package/dist/agents/tp-test-writer.md +46 -0
  16. package/dist/cli/agent-frontmatter.d.ts +48 -0
  17. package/dist/cli/agent-frontmatter.js +189 -0
  18. package/dist/cli/bless-agents.d.ts +65 -0
  19. package/dist/cli/bless-agents.js +307 -0
  20. package/dist/cli/claudeignore.d.ts +33 -0
  21. package/dist/cli/claudeignore.js +88 -0
  22. package/dist/cli/claudemd-hygiene.d.ts +26 -0
  23. package/dist/cli/claudemd-hygiene.js +43 -0
  24. package/dist/cli/doctor-drift.d.ts +31 -0
  25. package/dist/cli/doctor-drift.js +130 -0
  26. package/dist/cli/doctor-env-check.d.ts +25 -0
  27. package/dist/cli/doctor-env-check.js +91 -0
  28. package/dist/cli/install-agents.d.ts +108 -0
  29. package/dist/cli/install-agents.js +402 -0
  30. package/dist/cli/save-doc.d.ts +42 -0
  31. package/dist/cli/save-doc.js +145 -0
  32. package/dist/cli/scan-agents.d.ts +46 -0
  33. package/dist/cli/scan-agents.js +227 -0
  34. package/dist/cli/stats.d.ts +36 -0
  35. package/dist/cli/stats.js +131 -0
  36. package/dist/cli/unbless-agents.d.ts +33 -0
  37. package/dist/cli/unbless-agents.js +85 -0
  38. package/dist/cli/uninstall-agents.d.ts +36 -0
  39. package/dist/cli/uninstall-agents.js +117 -0
  40. package/dist/config/defaults.d.ts +1 -1
  41. package/dist/config/defaults.js +14 -8
  42. package/dist/config/loader.d.ts +1 -1
  43. package/dist/config/loader.js +105 -11
  44. package/dist/core/context-registry.d.ts +16 -1
  45. package/dist/core/context-registry.js +60 -28
  46. package/dist/core/event-log.d.ts +79 -0
  47. package/dist/core/event-log.js +190 -0
  48. package/dist/core/session-registry.d.ts +43 -0
  49. package/dist/core/session-registry.js +113 -0
  50. package/dist/core/session-savings.d.ts +19 -0
  51. package/dist/core/session-savings.js +60 -0
  52. package/dist/handlers/session-budget.d.ts +32 -0
  53. package/dist/handlers/session-budget.js +61 -0
  54. package/dist/handlers/session-snapshot-persist.d.ts +22 -0
  55. package/dist/handlers/session-snapshot-persist.js +76 -0
  56. package/dist/hooks/adaptive-threshold.d.ts +27 -0
  57. package/dist/hooks/adaptive-threshold.js +46 -0
  58. package/dist/hooks/format-deny-message.d.ts +21 -0
  59. package/dist/hooks/format-deny-message.js +147 -0
  60. package/dist/hooks/installer.js +121 -31
  61. package/dist/hooks/path-safety.d.ts +16 -0
  62. package/dist/hooks/path-safety.js +34 -0
  63. package/dist/hooks/post-bash.d.ts +46 -0
  64. package/dist/hooks/post-bash.js +77 -0
  65. package/dist/hooks/session-start.d.ts +45 -0
  66. package/dist/hooks/session-start.js +179 -0
  67. package/dist/hooks/summary-ast-index.d.ts +28 -0
  68. package/dist/hooks/summary-ast-index.js +122 -0
  69. package/dist/hooks/summary-head-tail.d.ts +15 -0
  70. package/dist/hooks/summary-head-tail.js +78 -0
  71. package/dist/hooks/summary-pipeline.d.ts +35 -0
  72. package/dist/hooks/summary-pipeline.js +63 -0
  73. package/dist/hooks/summary-regex.d.ts +14 -0
  74. package/dist/hooks/summary-regex.js +130 -0
  75. package/dist/hooks/summary-types.d.ts +29 -0
  76. package/dist/hooks/summary-types.js +9 -0
  77. package/dist/index.d.ts +15 -3
  78. package/dist/index.js +509 -149
  79. package/dist/integration/context-mode-detector.d.ts +7 -1
  80. package/dist/integration/context-mode-detector.js +51 -15
  81. package/dist/server/tool-definitions.d.ts +149 -0
  82. package/dist/server/tool-definitions.js +424 -202
  83. package/dist/server.d.ts +1 -1
  84. package/dist/server.js +456 -179
  85. package/dist/templates/agent-builder.d.ts +49 -0
  86. package/dist/templates/agent-builder.js +104 -0
  87. package/dist/types.d.ts +38 -4
  88. package/package.json +4 -2
  89. package/skills/stats/SKILL.md +13 -2
package/dist/index.js CHANGED
@@ -1,65 +1,201 @@
1
1
  #!/usr/bin/env node
2
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { readFileSync, realpathSync, appendFileSync, mkdirSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- import { execFile } from 'node:child_process';
6
- import { promisify } from 'node:util';
7
- import { fileURLToPath } from 'node:url';
8
- import { createServer } from './server.js';
9
- import { installHook, uninstallHook } from './hooks/installer.js';
10
- import { findBinary, installBinary, checkBinaryUpdate, isNewerVersion } from './ast-index/binary-manager.js';
11
- import { loadConfig } from './config/loader.js';
12
- import { isDangerousRoot } from './core/validation.js';
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { readFileSync, realpathSync, appendFileSync, mkdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { fileURLToPath } from "node:url";
9
+ import { createServer } from "./server.js";
10
+ import { installHook, uninstallHook } from "./hooks/installer.js";
11
+ import { findBinary, installBinary, checkBinaryUpdate, isNewerVersion, } from "./ast-index/binary-manager.js";
12
+ import { loadConfig } from "./config/loader.js";
13
+ import { isDangerousRoot } from "./core/validation.js";
14
+ import { runSummaryPipeline } from "./hooks/summary-pipeline.js";
15
+ import { formatDenyMessage } from "./hooks/format-deny-message.js";
16
+ import { isPathWithinProject } from "./hooks/path-safety.js";
17
+ import { handleSessionStart } from "./hooks/session-start.js";
18
+ import { computeEffectiveThreshold } from "./hooks/adaptive-threshold.js";
19
+ import { loadSessionSavedTokens } from "./core/session-savings.js";
20
+ import { handleSaveDocCli, handleListDocsCli } from "./cli/save-doc.js";
21
+ import { isContextModeInstalledSync } from "./integration/context-mode-detector.js";
22
+ import { handleBlessAgents } from "./cli/bless-agents.js";
23
+ import { unblessAgents } from "./cli/unbless-agents.js";
24
+ import { detectDrift, formatDriftFinding } from "./cli/doctor-drift.js";
25
+ import { handleInstallAgents, maybeEmitStartupReminder, } from "./cli/install-agents.js";
26
+ import { handleUninstallAgents } from "./cli/uninstall-agents.js";
27
+ import { appendEvent, applyRetention, } from "./core/event-log.js";
28
+ import { handleStats } from "./cli/stats.js";
29
+ import { promptYesNo } from "./cli/install-agents.js";
30
+ import { runClaudeCodeEnvCheck } from "./cli/doctor-env-check.js";
31
+ import { claudeIgnoreStatus, writeDefaultClaudeIgnore, } from "./cli/claudeignore.js";
32
+ import { assessClaudeMd } from "./cli/claudemd-hygiene.js";
33
+ import { decidePostBashAdvice, renderPostBashHookOutput, } from "./hooks/post-bash.js";
13
34
  const execFileAsync = promisify(execFile);
14
35
  export const CODE_EXTENSIONS = new Set([
15
- 'ts', 'tsx', 'js', 'jsx', 'mjs', 'py', 'go', 'rs', 'java', 'kt', 'kts',
16
- 'swift', 'cs', 'cpp', 'cc', 'cxx', 'hpp', 'c', 'h', 'php', 'rb', 'scala',
17
- 'dart', 'lua', 'sh', 'bash', 'sql', 'r', 'vue', 'svelte', 'pl', 'pm',
18
- 'ex', 'exs', 'groovy', 'm', 'proto', 'bsl',
19
- 'lisp', 'lsp', 'cl', 'asd',
36
+ "ts",
37
+ "tsx",
38
+ "js",
39
+ "jsx",
40
+ "mjs",
41
+ "py",
42
+ "go",
43
+ "rs",
44
+ "java",
45
+ "kt",
46
+ "kts",
47
+ "swift",
48
+ "cs",
49
+ "cpp",
50
+ "cc",
51
+ "cxx",
52
+ "hpp",
53
+ "c",
54
+ "h",
55
+ "php",
56
+ "rb",
57
+ "scala",
58
+ "dart",
59
+ "lua",
60
+ "sh",
61
+ "bash",
62
+ "sql",
63
+ "r",
64
+ "vue",
65
+ "svelte",
66
+ "pl",
67
+ "pm",
68
+ "ex",
69
+ "exs",
70
+ "groovy",
71
+ "m",
72
+ "proto",
73
+ "bsl",
74
+ "lisp",
75
+ "lsp",
76
+ "cl",
77
+ "asd",
20
78
  ]);
21
79
  export function getVersion() {
22
80
  try {
23
- const pkgPath = new URL('../package.json', import.meta.url).pathname;
24
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
81
+ const pkgPath = new URL("../package.json", import.meta.url).pathname;
82
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
25
83
  return pkg.version;
26
84
  }
27
85
  catch {
28
- return '0.0.0';
86
+ return "0.0.0";
29
87
  }
30
88
  }
31
89
  export async function main(cliArgs = process.argv.slice(2)) {
32
90
  switch (cliArgs[0]) {
33
- case 'hook-read': {
91
+ case "hook-read": {
34
92
  const cfg = await loadConfig(process.cwd());
35
- handleHookRead(cliArgs[1], cfg.hooks.denyThreshold);
93
+ await handleHookRead(cliArgs[1], cfg.hooks.mode, cfg.hooks.denyThreshold, process.cwd(), {
94
+ adaptiveThreshold: cfg.hooks.adaptiveThreshold,
95
+ adaptiveBudgetTokens: cfg.hooks.adaptiveBudgetTokens,
96
+ });
36
97
  return;
37
98
  }
38
- case 'hook-edit':
99
+ case "hook-edit":
39
100
  handleHookEdit();
40
101
  return;
41
- case 'install-hook':
102
+ case "hook-post-bash": {
103
+ try {
104
+ const stdin = readFileSync(0, "utf-8");
105
+ const input = JSON.parse(stdin);
106
+ const advice = decidePostBashAdvice(input, {
107
+ contextModeAvailable: isContextModeInstalledSync(process.cwd()),
108
+ });
109
+ const rendered = renderPostBashHookOutput(advice);
110
+ if (rendered)
111
+ process.stdout.write(rendered);
112
+ }
113
+ catch {
114
+ /* silent — hook must not break */
115
+ }
116
+ process.exit(0);
117
+ return;
118
+ }
119
+ case "hook-session-start": {
120
+ const cfg = await loadConfig(process.cwd());
121
+ // `sessionStart.enabled` is independent of `hooks.mode` by design —
122
+ // a user may want the Read-blocking hook off (mode:"off") while still
123
+ // getting the tool-rules reminder at session start, or vice versa.
124
+ if (!cfg.sessionStart.enabled) {
125
+ process.exit(0);
126
+ }
127
+ const result = await handleSessionStart({
128
+ projectRoot: process.cwd(),
129
+ homeDir: homedir(),
130
+ sessionStartConfig: cfg.sessionStart,
131
+ });
132
+ if (result) {
133
+ process.stdout.write(result);
134
+ }
135
+ process.exit(0);
136
+ return;
137
+ }
138
+ case "install-hook":
42
139
  await handleInstallHook(cliArgs[1] || process.cwd());
43
140
  return;
44
- case 'uninstall-hook':
141
+ case "uninstall-hook":
45
142
  await handleUninstallHook(cliArgs[1] || process.cwd());
46
143
  return;
47
- case 'install-ast-index':
144
+ case "install-ast-index":
48
145
  await handleInstallAstIndex();
49
146
  return;
50
- case 'doctor':
147
+ case "doctor":
51
148
  await handleDoctor();
52
149
  return;
53
- case 'init':
150
+ case "bless-agents":
151
+ await handleBlessAgents(cliArgs.slice(1));
152
+ return;
153
+ case "unbless-agents": {
154
+ const args = cliArgs.slice(1);
155
+ const all = args.includes("--all");
156
+ const names = args.filter((a) => !a.startsWith("--"));
157
+ if (!all && names.length === 0) {
158
+ process.stderr.write("Usage: token-pilot unbless-agents <name>... | --all\n");
159
+ process.exit(1);
160
+ }
161
+ await unblessAgents({ projectRoot: process.cwd(), names, all });
162
+ return;
163
+ }
164
+ case "install-agents": {
165
+ const code = await handleInstallAgents(cliArgs.slice(1));
166
+ process.exit(code);
167
+ return;
168
+ }
169
+ case "uninstall-agents": {
170
+ const code = await handleUninstallAgents(cliArgs.slice(1));
171
+ process.exit(code);
172
+ return;
173
+ }
174
+ case "stats": {
175
+ const code = await handleStats(cliArgs.slice(1));
176
+ process.exit(code);
177
+ return;
178
+ }
179
+ case "save-doc": {
180
+ const code = await handleSaveDocCli(cliArgs.slice(1));
181
+ process.exit(code);
182
+ return;
183
+ }
184
+ case "list-docs": {
185
+ const code = await handleListDocsCli();
186
+ process.exit(code);
187
+ return;
188
+ }
189
+ case "init":
54
190
  await handleInit(cliArgs[1] || process.cwd());
55
191
  return;
56
- case '--version':
57
- case '-v':
192
+ case "--version":
193
+ case "-v":
58
194
  console.log(getVersion());
59
195
  process.exit(0);
60
196
  return;
61
- case '--help':
62
- case '-h':
197
+ case "--help":
198
+ case "-h":
63
199
  printHelp();
64
200
  return;
65
201
  default:
@@ -76,20 +212,20 @@ export async function startServer(cliArgs = process.argv.slice(2)) {
76
212
  process.env.INIT_CWD, // npm/npx sets this to invoking directory
77
213
  process.env.PWD, // shell working directory (may differ from cwd)
78
214
  process.cwd(), // Node.js working directory
79
- ].filter((c) => !!c && c !== '/');
215
+ ].filter((c) => !!c && c !== "/");
80
216
  let detected = false;
81
217
  for (const candidate of candidates) {
82
218
  if (isDangerousRoot(candidate))
83
219
  continue;
84
220
  try {
85
- const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
221
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
86
222
  cwd: candidate,
87
223
  timeout: 3000,
88
224
  });
89
225
  const gitRoot = stdout.trim();
90
226
  if (gitRoot && !isDangerousRoot(gitRoot)) {
91
227
  projectRoot = gitRoot;
92
- console.error(`[token-pilot] project root: ${projectRoot} (git from ${candidate === process.env.INIT_CWD ? 'INIT_CWD' : candidate === process.env.PWD ? 'PWD' : 'cwd'})`);
228
+ console.error(`[token-pilot] project root: ${projectRoot} (git from ${candidate === process.env.INIT_CWD ? "INIT_CWD" : candidate === process.env.PWD ? "PWD" : "cwd"})`);
93
229
  detected = true;
94
230
  break;
95
231
  }
@@ -100,10 +236,10 @@ export async function startServer(cliArgs = process.argv.slice(2)) {
100
236
  }
101
237
  if (!detected) {
102
238
  // Use best non-dangerous candidate as fallback even without git
103
- const fallback = candidates.find(c => !isDangerousRoot(c));
239
+ const fallback = candidates.find((c) => !isDangerousRoot(c));
104
240
  if (fallback) {
105
241
  projectRoot = fallback;
106
- console.error(`[token-pilot] project root: ${projectRoot} (${fallback === process.env.INIT_CWD ? 'INIT_CWD' : 'PWD'}, not a git repo)`);
242
+ console.error(`[token-pilot] project root: ${projectRoot} (${fallback === process.env.INIT_CWD ? "INIT_CWD" : "PWD"}, not a git repo)`);
107
243
  }
108
244
  else {
109
245
  console.error(`[token-pilot] project root: ${projectRoot} (cwd, not a git repo)`);
@@ -120,108 +256,222 @@ export async function startServer(cliArgs = process.argv.slice(2)) {
120
256
  // Non-blocking update check for all components (logs to stderr, never blocks startup)
121
257
  const config = await loadConfig(projectRoot);
122
258
  const binaryStatus = await findBinary(config.astIndex.binaryPath);
123
- checkAllUpdates(config, binaryStatus).catch(() => { });
259
+ checkAllUpdates(config, binaryStatus).catch(() => {
260
+ /* ignore */
261
+ });
262
+ // Phase 5 subtask 5.6 — one-time reminder when no tp-* agents installed.
263
+ // Non-blocking, silent on error, single-fire per process.
264
+ maybeEmitStartupReminder({
265
+ projectRoot,
266
+ homeDir: homedir(),
267
+ configSuppressed: config.agents?.reminder === false,
268
+ }).catch(() => {
269
+ /* ignore */
270
+ });
271
+ // Phase 6 subtask 6.2 — age + size retention on hook-events archives.
272
+ // Fire-and-forget: retention failures must never block startup.
273
+ applyRetention(projectRoot).catch(() => {
274
+ /* ignore */
275
+ });
124
276
  // Auto-install PreToolUse hook (non-blocking, Claude Code only)
125
277
  // Uses absolute paths to node + script so hooks work in /bin/sh (nvm, npx, etc.)
126
278
  let hookOptions;
127
279
  try {
128
- const rawPath = fileURLToPath(new URL('./index.js', import.meta.url));
129
- hookOptions = { scriptPath: realpathSync(rawPath), nodeExecPath: process.execPath };
280
+ const rawPath = fileURLToPath(new URL("./index.js", import.meta.url));
281
+ hookOptions = {
282
+ scriptPath: realpathSync(rawPath),
283
+ nodeExecPath: process.execPath,
284
+ };
130
285
  }
131
286
  catch {
132
287
  // Can't resolve script path (e.g. running from src/ in tests) — fall back to bare command
133
288
  }
134
- installHook(projectRoot, hookOptions).then(result => {
289
+ installHook(projectRoot, hookOptions)
290
+ .then((result) => {
135
291
  if (result.installed) {
136
292
  console.error(`[token-pilot] hook auto-installed: ${result.message}`);
137
293
  }
138
- }).catch(() => { });
294
+ })
295
+ .catch(() => {
296
+ /* ignore — not Claude Code or no .claude dir */
297
+ });
139
298
  const server = await createServer(projectRoot, {
140
299
  skipAstIndex: isDangerousRoot(projectRoot),
141
300
  });
142
301
  const transport = new StdioServerTransport();
143
302
  await server.connect(transport);
144
- process.on('SIGINT', async () => {
303
+ process.on("SIGINT", async () => {
145
304
  await server.close();
146
305
  process.exit(0);
147
306
  });
148
- process.on('SIGTERM', async () => {
307
+ process.on("SIGTERM", async () => {
149
308
  await server.close();
150
309
  process.exit(0);
151
310
  });
152
311
  }
153
- export function handleHookRead(filePathArg, denyThreshold = 300) {
154
- // Parse stdin (Claude Code hook format) to get tool_input
312
+ export async function handleHookRead(filePathArg, mode = "deny-enhanced", denyThreshold = 300, projectRoot = process.cwd(), adaptive = {}) {
313
+ // Mode 'off' hook is inert regardless of input.
314
+ if (mode === "off") {
315
+ process.exit(0);
316
+ }
317
+ const dispatchResult = await runHookReadDispatch(filePathArg, mode, denyThreshold, projectRoot, adaptive);
318
+ if (dispatchResult) {
319
+ process.stdout.write(dispatchResult);
320
+ }
321
+ process.exit(0);
322
+ }
323
+ /**
324
+ * Pure implementation of the hook-read dispatch — returns the JSON payload
325
+ * to write to stdout, or null when we should pass-through (no output).
326
+ * Extracted for testability; the outer handleHookRead adds the process.exit
327
+ * wrapping.
328
+ */
329
+ export async function runHookReadDispatch(filePathArg, mode, denyThresholdArg, projectRootArg, adaptive = {}) {
330
+ const denyThreshold = denyThresholdArg ?? 300;
331
+ const projectRoot = projectRootArg ?? process.cwd();
332
+ return runHookReadDispatchImpl(filePathArg, mode, denyThreshold, projectRoot, adaptive);
333
+ }
334
+ async function runHookReadDispatchImpl(filePathArg, mode, denyThreshold, projectRoot, adaptive = {}) {
335
+ if (mode === "off")
336
+ return null;
337
+ // Parse stdin to get tool_input + session/agent metadata, unless a
338
+ // filePath was supplied directly (tests, --filePath invocation).
155
339
  let filePath = filePathArg;
156
340
  let hasOffset = false;
157
341
  let hasLimit = false;
342
+ let sessionId = "";
343
+ let agentType = null;
344
+ let agentId = null;
158
345
  if (!filePath) {
159
346
  try {
160
- const stdin = readFileSync(0, 'utf-8');
347
+ const stdin = readFileSync(0, "utf-8");
161
348
  const input = JSON.parse(stdin);
162
349
  filePath = input?.tool_input?.file_path;
163
350
  hasOffset = input?.tool_input?.offset != null;
164
351
  hasLimit = input?.tool_input?.limit != null;
352
+ sessionId = typeof input?.session_id === "string" ? input.session_id : "";
353
+ agentType =
354
+ typeof input?.agent_type === "string" ? input.agent_type : null;
355
+ agentId = typeof input?.agent_id === "string" ? input.agent_id : null;
165
356
  }
166
357
  catch {
167
- process.exit(0);
358
+ return null;
168
359
  }
169
360
  }
170
- if (!filePath) {
171
- process.exit(0);
172
- }
173
- const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
174
- // Non-code files — allow Read without interference
175
- if (!CODE_EXTENSIONS.has(ext)) {
176
- process.exit(0);
177
- }
178
- // Bounded Read (has offset or limit) allow, AI is reading a specific section
179
- if (hasOffset || hasLimit) {
180
- process.exit(0);
361
+ if (!filePath)
362
+ return null;
363
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
364
+ if (!CODE_EXTENSIONS.has(ext))
365
+ return null;
366
+ // Bounded Reads are always passed through — the agent already narrowed scope.
367
+ if (hasOffset || hasLimit)
368
+ return null;
369
+ // Path safety: refuse to summarise any file outside the project root
370
+ // (traversal, symlinks pointing outside). Pass-through on failure so the
371
+ // agent is never blocked by a safety reject.
372
+ if (!isPathWithinProject(filePath, projectRoot)) {
373
+ try {
374
+ process.stderr.write(`[token-pilot] refusing to summarise "${filePath}" — outside project root. Hook passing through.\n`);
375
+ }
376
+ catch {
377
+ /* silent — hook must not break */
378
+ }
379
+ return null;
181
380
  }
182
- // Check file size
381
+ // Resolve effective threshold once (cheap if adaptive is off).
382
+ const effectiveThreshold = adaptive.adaptiveThreshold
383
+ ? computeEffectiveThreshold({
384
+ baseThreshold: denyThreshold,
385
+ sessionSavedTokens: loadSessionSavedTokens(projectRoot, sessionId),
386
+ sessionBudgetTokens: adaptive.adaptiveBudgetTokens ?? 100_000,
387
+ enabled: true,
388
+ })
389
+ : denyThreshold;
390
+ // Read file content + line count.
391
+ let fileContent = "";
183
392
  let lineCount = 0;
184
- let fileContent = '';
185
393
  try {
186
- fileContent = readFileSync(filePath, 'utf-8');
187
- lineCount = fileContent.split('\n').length;
188
- if (lineCount <= denyThreshold) {
189
- process.exit(0);
190
- }
394
+ fileContent = readFileSync(filePath, "utf-8");
395
+ lineCount = fileContent.split("\n").length;
396
+ if (lineCount <= effectiveThreshold)
397
+ return null;
191
398
  }
192
399
  catch {
193
- process.exit(0);
400
+ return null;
194
401
  }
195
- // Track denied read for session analytics
402
+ const charEst = Math.ceil(fileContent.length / 4);
403
+ const wsRatio = (fileContent.match(/\s/g)?.length ?? 0) / fileContent.length;
404
+ const estTokens = Math.ceil(charEst * (1 - wsRatio * 0.3));
405
+ // Legacy telemetry (hook-denied.jsonl) — retained for backward compatibility
406
+ // with existing loadDeniedReads() readers in session-analytics. Never block
407
+ // hook dispatch on failure.
196
408
  try {
197
- const charEst = Math.ceil(fileContent.length / 4);
198
- const wsRatio = (fileContent.match(/\s/g)?.length ?? 0) / fileContent.length;
199
- const estTokens = Math.ceil(charEst * (1 - wsRatio * 0.3));
200
- const entry = JSON.stringify({ filePath, lineCount, estimatedTokens: estTokens, timestamp: Date.now() });
201
- const dir = join(process.cwd(), '.token-pilot');
409
+ const entry = JSON.stringify({
410
+ filePath,
411
+ lineCount,
412
+ estimatedTokens: estTokens,
413
+ mode,
414
+ timestamp: Date.now(),
415
+ });
416
+ const dir = join(projectRoot, ".token-pilot");
202
417
  mkdirSync(dir, { recursive: true });
203
- appendFileSync(join(dir, 'hook-denied.jsonl'), entry + '\n');
418
+ appendFileSync(join(dir, "hook-denied.jsonl"), entry + "\n");
204
419
  }
205
420
  catch {
206
- // Silent fail — hook must not break
421
+ /* silent — hook must not break */
422
+ }
423
+ const writeEvent = async (eventKind, summaryTokens) => {
424
+ await appendEvent(projectRoot, {
425
+ ts: Date.now(),
426
+ session_id: sessionId,
427
+ agent_type: agentType,
428
+ agent_id: agentId,
429
+ event: eventKind,
430
+ file: filePath,
431
+ lines: lineCount,
432
+ estTokens,
433
+ summaryTokens,
434
+ savedTokens: Math.max(0, estTokens - summaryTokens),
435
+ });
436
+ };
437
+ if (mode === "advisory") {
438
+ const reason = `File "${filePath}" has ${lineCount} lines. Use mcp__token-pilot__smart_read("${filePath}") ` +
439
+ `for a structural overview, or mcp__token-pilot__read_for_edit("${filePath}", symbol="<name>") ` +
440
+ `for edit context. Bounded Read with offset/limit is still allowed.`;
441
+ await writeEvent("denied", Math.ceil(reason.length / 4));
442
+ return JSON.stringify({
443
+ hookSpecificOutput: {
444
+ hookEventName: "PreToolUse",
445
+ permissionDecision: "deny",
446
+ permissionDecisionReason: reason,
447
+ },
448
+ });
207
449
  }
208
- // Large code file, unbounded Read → DENY
209
- // permissionDecisionReason is shown to Claude (not user) per official docs
210
- const deny = JSON.stringify({
450
+ // mode === 'deny-enhanced'
451
+ const pipelineResult = await runSummaryPipeline(fileContent, filePath);
452
+ if (pipelineResult.kind === "pass-through") {
453
+ await writeEvent("pass-through", 0);
454
+ return null;
455
+ }
456
+ const message = formatDenyMessage({
457
+ filePath,
458
+ summary: pipelineResult.summary,
459
+ tier: pipelineResult.tier,
460
+ });
461
+ await writeEvent("denied", Math.ceil(message.length / 4));
462
+ return JSON.stringify({
211
463
  hookSpecificOutput: {
212
464
  hookEventName: "PreToolUse",
213
465
  permissionDecision: "deny",
214
- permissionDecisionReason: `File "${filePath}" has ${lineCount} lines. Use smart_read("${filePath}") for structural overview, or read_for_edit("${filePath}", symbol="<name>") for edit context. Bounded Read with offset/limit is still allowed.`,
466
+ permissionDecisionReason: message,
215
467
  },
216
468
  });
217
- process.stdout.write(deny);
218
- process.exit(0);
219
469
  }
220
470
  export function handleHookEdit() {
221
471
  // Parse stdin for Edit tool_input
222
472
  let filePath;
223
473
  try {
224
- const stdin = readFileSync(0, 'utf-8');
474
+ const stdin = readFileSync(0, "utf-8");
225
475
  const input = JSON.parse(stdin);
226
476
  filePath = input?.tool_input?.file_path;
227
477
  }
@@ -231,7 +481,7 @@ export function handleHookEdit() {
231
481
  if (!filePath) {
232
482
  process.exit(0);
233
483
  }
234
- const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
484
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
235
485
  // Only add context for code files
236
486
  if (!CODE_EXTENSIONS.has(ext)) {
237
487
  process.exit(0);
@@ -250,8 +500,11 @@ export function handleHookEdit() {
250
500
  export async function handleInstallHook(projectRoot) {
251
501
  let hookOptions;
252
502
  try {
253
- const rawPath = fileURLToPath(new URL('./index.js', import.meta.url));
254
- hookOptions = { scriptPath: realpathSync(rawPath), nodeExecPath: process.execPath };
503
+ const rawPath = fileURLToPath(new URL("./index.js", import.meta.url));
504
+ hookOptions = {
505
+ scriptPath: realpathSync(rawPath),
506
+ nodeExecPath: process.execPath,
507
+ };
255
508
  }
256
509
  catch {
257
510
  // Fall back to bare command
@@ -290,23 +543,23 @@ export async function handleInstallAstIndex() {
290
543
  }
291
544
  export async function handleDoctor() {
292
545
  const version = getVersion();
293
- const { existsSync } = await import('node:fs');
294
- const { join } = await import('node:path');
546
+ const { existsSync } = await import("node:fs");
547
+ const { join } = await import("node:path");
295
548
  const cwd = process.cwd();
296
549
  console.log(`token-pilot doctor v${version}\n`);
297
550
  // ── Environment ──
298
551
  const nodeVersion = process.version;
299
552
  const nodeMajor = parseInt(nodeVersion.slice(1), 10);
300
- console.log(`Node.js: ${nodeVersion} ${nodeMajor >= 18 ? '' : '✗ (requires >=18)'}`);
301
- const configPath = join(cwd, '.token-pilot.json');
302
- console.log(`config: ${existsSync(configPath) ? configPath + '' : 'default (no .token-pilot.json)'}`);
303
- const gitDir = join(cwd, '.git');
304
- console.log(`git repo: ${existsSync(gitDir) ? 'yes ✓' : 'no (read_diff/git features unavailable)'}`);
305
- console.log('');
553
+ console.log(`Node.js: ${nodeVersion} ${nodeMajor >= 18 ? "" : "✗ (requires >=18)"}`);
554
+ const configPath = join(cwd, ".token-pilot.json");
555
+ console.log(`config: ${existsSync(configPath) ? configPath + "" : "default (no .token-pilot.json)"}`);
556
+ const gitDir = join(cwd, ".git");
557
+ console.log(`git repo: ${existsSync(gitDir) ? "yes ✓" : "no (read_diff/git features unavailable)"}`);
558
+ console.log("");
306
559
  // ── token-pilot ──
307
- console.log('── token-pilot ──');
560
+ console.log("── token-pilot ──");
308
561
  console.log(` installed: ${version}`);
309
- const tpLatest = await checkNpmLatest('token-pilot');
562
+ const tpLatest = await checkNpmLatest("token-pilot");
310
563
  if (tpLatest) {
311
564
  if (isNewerVersion(version, tpLatest)) {
312
565
  console.log(` latest: ${tpLatest} (update available!)`);
@@ -319,9 +572,9 @@ export async function handleDoctor() {
319
572
  else {
320
573
  console.log(` latest: could not check (network error)`);
321
574
  }
322
- console.log('');
575
+ console.log("");
323
576
  // ── ast-index ──
324
- console.log('── ast-index ──');
577
+ console.log("── ast-index ──");
325
578
  const astStatus = await findBinary();
326
579
  if (astStatus.available) {
327
580
  console.log(` installed: ${astStatus.version} (${astStatus.source}: ${astStatus.path})`);
@@ -334,45 +587,99 @@ export async function handleDoctor() {
334
587
  console.log(` latest: ${astUpdate.latest} ✓ (up to date)`);
335
588
  }
336
589
  const config = await loadConfig(cwd);
337
- console.log(` auto-update: ${config.updates.autoUpdate ? 'enabled ✓' : 'disabled (set updates.autoUpdate=true in .token-pilot.json)'}`);
590
+ console.log(` auto-update: ${config.updates.autoUpdate ? "enabled ✓" : "disabled (set updates.autoUpdate=true in .token-pilot.json)"}`);
338
591
  }
339
592
  else {
340
593
  console.log(` installed: not found ✗`);
341
594
  console.log(` run: npx token-pilot install-ast-index`);
342
595
  }
343
- console.log('');
596
+ console.log("");
344
597
  // ── context-mode ──
345
- console.log('── context-mode ──');
346
- const { detectContextMode } = await import('./integration/context-mode-detector.js');
598
+ console.log("── context-mode ──");
599
+ const { detectContextMode } = await import("./integration/context-mode-detector.js");
347
600
  const cmStatus = await detectContextMode(cwd);
348
- console.log(` detected: ${cmStatus.detected ? `yes (${cmStatus.source})` : 'no'}`);
349
- const cmLatest = await checkNpmLatest('claude-context-mode');
601
+ console.log(` detected: ${cmStatus.detected ? `yes (${cmStatus.source})` : "no"}`);
602
+ const cmLatest = await checkNpmLatest("claude-context-mode");
350
603
  if (cmLatest) {
351
604
  console.log(` latest npm: ${cmLatest}`);
352
605
  }
353
606
  if (!cmStatus.detected) {
354
607
  console.log(` setup: npx token-pilot init`);
355
608
  }
356
- console.log('');
609
+ console.log("");
610
+ // ── blessed agents drift check ──
611
+ const drift = await detectDrift({ projectRoot: cwd, homeDir: homedir() });
612
+ if (drift.length > 0) {
613
+ console.log("── blessed-agents drift ──");
614
+ for (const finding of drift) {
615
+ console.log(` ${formatDriftFinding(finding)}`);
616
+ }
617
+ console.log("");
618
+ }
619
+ // ── Claude Code env-var savings tips ──
620
+ try {
621
+ const tips = await runClaudeCodeEnvCheck();
622
+ if (tips.length > 0) {
623
+ console.log("── Claude Code env knobs (savings tips) ──");
624
+ for (const t of tips) {
625
+ console.log(` ⚠ ${t}`);
626
+ }
627
+ console.log("");
628
+ }
629
+ }
630
+ catch {
631
+ /* doctor must never crash over an optional check */
632
+ }
633
+ // ── .claudeignore ──
634
+ try {
635
+ const status = await claudeIgnoreStatus(cwd);
636
+ console.log("── .claudeignore ──");
637
+ if (status.kind === "absent") {
638
+ console.log(` not present — run \`npx token-pilot init\` to add sensible defaults`);
639
+ }
640
+ else if (status.kind === "managed") {
641
+ console.log(` present ✓ (managed by token-pilot; safe to edit)`);
642
+ }
643
+ else {
644
+ console.log(` present ✓ (user-owned; token-pilot will not touch it)`);
645
+ }
646
+ console.log("");
647
+ }
648
+ catch {
649
+ /* ignore */
650
+ }
651
+ // ── CLAUDE.md hygiene ──
652
+ try {
653
+ const r = await assessClaudeMd(cwd);
654
+ if (r.kind === "bloated") {
655
+ console.log("── CLAUDE.md hygiene ──");
656
+ console.log(` ⚠ ${r.path} has ${r.nonEmptyLines} non-empty lines (threshold: ${r.threshold}).`);
657
+ console.log(` This file loads into every Claude Code message — splitting into docs/*.md and loading on-demand saves tokens per turn.`);
658
+ console.log("");
659
+ }
660
+ }
661
+ catch {
662
+ /* ignore */
663
+ }
357
664
  process.exit(0);
358
665
  }
359
666
  export async function handleInit(targetDir) {
360
- const { existsSync, readFileSync: readFs, writeFileSync } = await import('node:fs');
361
- const { join } = await import('node:path');
362
- const mcpPath = join(targetDir, '.mcp.json');
667
+ const { existsSync, readFileSync: readFs, writeFileSync, } = await import("node:fs");
668
+ const { join } = await import("node:path");
669
+ const mcpPath = join(targetDir, ".mcp.json");
363
670
  const tokenPilotConfig = {
364
- command: 'npx',
365
- args: ['-y', 'token-pilot'],
671
+ command: "npx",
672
+ args: ["-y", "token-pilot"],
366
673
  };
367
674
  const contextModeConfig = {
368
- command: 'npx',
369
- args: ['-y', 'claude-context-mode'],
675
+ command: "npx",
676
+ args: ["-y", "claude-context-mode"],
370
677
  };
371
678
  let config = { mcpServers: {} };
372
679
  let existed = false;
373
680
  if (existsSync(mcpPath)) {
374
681
  try {
375
- config = JSON.parse(readFs(mcpPath, 'utf-8'));
682
+ config = JSON.parse(readFs(mcpPath, "utf-8"));
376
683
  if (!config.mcpServers)
377
684
  config.mcpServers = {};
378
685
  existed = true;
@@ -383,28 +690,71 @@ export async function handleInit(targetDir) {
383
690
  }
384
691
  }
385
692
  const added = [];
386
- if (!config.mcpServers['token-pilot']) {
387
- config.mcpServers['token-pilot'] = tokenPilotConfig;
388
- added.push('token-pilot');
693
+ if (!config.mcpServers["token-pilot"]) {
694
+ config.mcpServers["token-pilot"] = tokenPilotConfig;
695
+ added.push("token-pilot");
389
696
  }
390
- if (!config.mcpServers['context-mode']) {
391
- config.mcpServers['context-mode'] = contextModeConfig;
392
- added.push('context-mode');
697
+ if (!config.mcpServers["context-mode"]) {
698
+ config.mcpServers["context-mode"] = contextModeConfig;
699
+ added.push("context-mode");
393
700
  }
394
701
  if (added.length === 0) {
395
702
  console.log(`✓ ${mcpPath} already has both token-pilot and context-mode configured`);
396
703
  process.exit(0);
397
704
  }
398
- writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n');
705
+ writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
399
706
  if (existed) {
400
- console.log(`✓ Updated ${mcpPath} — added: ${added.join(', ')}`);
707
+ console.log(`✓ Updated ${mcpPath} — added: ${added.join(", ")}`);
401
708
  }
402
709
  else {
403
710
  console.log(`✓ Created ${mcpPath} with token-pilot + context-mode`);
404
711
  }
405
712
  console.log(`\nConfigured MCP servers:`);
406
- console.log(` • token-pilot — AST-aware code reading (60-80% token savings)`);
713
+ console.log(` • token-pilot — enforcement layer for token-efficient reads (hook + MCP + tp-* subagents)`);
407
714
  console.log(` • context-mode — shell output & large data processing (BM25 sandbox)`);
715
+ // Claude Code users benefit from six token-pilot-native subagents (tp-run,
716
+ // tp-onboard, …). Offer installation now so they don't have to discover
717
+ // `install-agents` from a stderr reminder later.
718
+ const offeredAgents = added.includes("token-pilot") && process.stdin.isTTY === true;
719
+ if (offeredAgents) {
720
+ console.log("");
721
+ const yes = await promptYesNo("Install 6 tp-* subagents now (recommended for Claude Code)?", true);
722
+ if (yes) {
723
+ // Delegate to the full install-agents flow: it will prompt scope,
724
+ // handle idempotence, and persist the choice to .token-pilot.json.
725
+ const code = await handleInstallAgents([], { projectRoot: targetDir });
726
+ if (code !== 0) {
727
+ console.log("\n(install-agents returned non-zero; you can retry with: npx token-pilot install-agents)");
728
+ }
729
+ }
730
+ else {
731
+ console.log("\nSkipping agent install. Run later: npx token-pilot install-agents");
732
+ }
733
+ }
734
+ else if (added.includes("token-pilot")) {
735
+ // Non-TTY path — at minimum surface the next step in the log.
736
+ console.log(`\nNext step (Claude Code): npx token-pilot install-agents --scope=user|project`);
737
+ }
738
+ // Offer `.claudeignore` — small one-time win that compounds per message.
739
+ // Separate TTY check so a script that declined agents can still get ignore,
740
+ // and vice versa.
741
+ const ignoreStatus = await claudeIgnoreStatus(targetDir);
742
+ if (offeredAgents && ignoreStatus.kind !== "user-owned") {
743
+ console.log("");
744
+ const yesIgnore = await promptYesNo(ignoreStatus.kind === "absent"
745
+ ? "Create .claudeignore with sensible defaults (node_modules, dist, lockfiles, …)?"
746
+ : "Refresh .claudeignore defaults (managed by token-pilot)?", true);
747
+ if (yesIgnore) {
748
+ const wrote = await writeDefaultClaudeIgnore(targetDir);
749
+ if (wrote)
750
+ console.log("✓ .claudeignore written");
751
+ else
752
+ console.log("(Skipped — existing .claudeignore is user-owned; not touched.)");
753
+ }
754
+ }
755
+ else if (ignoreStatus.kind === "absent") {
756
+ console.log(`\nTip: \`npx token-pilot init\` offers to create .claudeignore — improves context by skipping node_modules, build artefacts, lockfiles.`);
757
+ }
408
758
  console.log(`\nRestart your AI assistant to activate.`);
409
759
  process.exit(0);
410
760
  }
@@ -421,7 +771,7 @@ export async function checkNpmLatest(packageName) {
421
771
  clearTimeout(timeout);
422
772
  if (!resp.ok)
423
773
  return null;
424
- const data = await resp.json();
774
+ const data = (await resp.json());
425
775
  return data.version ?? null;
426
776
  }
427
777
  catch {
@@ -432,59 +782,69 @@ export async function checkAllUpdates(config, binaryStatus) {
432
782
  if (!config.updates.checkOnStartup)
433
783
  return;
434
784
  const [tpLatest, astUpdate, cmLatest] = await Promise.allSettled([
435
- checkNpmLatest('token-pilot'),
436
- binaryStatus.available ? checkBinaryUpdate(binaryStatus.path) : Promise.resolve(null),
437
- checkNpmLatest('claude-context-mode'),
785
+ checkNpmLatest("token-pilot"),
786
+ binaryStatus.available
787
+ ? checkBinaryUpdate(binaryStatus.path)
788
+ : Promise.resolve(null),
789
+ checkNpmLatest("claude-context-mode"),
438
790
  ]);
439
791
  // token-pilot
440
792
  const tpVersion = getVersion();
441
- if (tpLatest.status === 'fulfilled' && tpLatest.value && isNewerVersion(tpVersion, tpLatest.value)) {
793
+ if (tpLatest.status === "fulfilled" &&
794
+ tpLatest.value &&
795
+ isNewerVersion(tpVersion, tpLatest.value)) {
442
796
  console.error(`[token-pilot] Update available: ${tpVersion} → ${tpLatest.value}. Run: npx token-pilot@latest`);
443
797
  }
444
798
  // ast-index
445
- if (astUpdate.status === 'fulfilled' && astUpdate.value?.updateAvailable) {
799
+ if (astUpdate.status === "fulfilled" && astUpdate.value?.updateAvailable) {
446
800
  const { current, latest } = astUpdate.value;
447
801
  if (config.updates.autoUpdate) {
448
802
  console.error(`[token-pilot] Auto-updating ast-index: ${current} → ${latest}...`);
449
- installBinary(msg => console.error(`[token-pilot] ${msg}`)).catch(() => { });
803
+ installBinary((msg) => console.error(`[token-pilot] ${msg}`)).catch(() => { });
450
804
  }
451
805
  else {
452
806
  console.error(`[token-pilot] ast-index update: ${current} → ${latest}. Run: token-pilot install-ast-index`);
453
807
  }
454
808
  }
455
809
  // context-mode (notification only — runs as separate MCP server)
456
- if (cmLatest.status === 'fulfilled' && cmLatest.value) {
810
+ if (cmLatest.status === "fulfilled" && cmLatest.value) {
457
811
  // We can't reliably detect the currently installed version of context-mode
458
812
  // (it runs as separate process via npx). Just log latest available for doctor.
459
813
  // On startup, we only notify if explicitly useful.
460
814
  }
461
815
  }
462
816
  export function printHelp() {
463
- console.log(`token-pilot v${getVersion()} — MCP server for token-efficient code reading
464
-
465
- Usage:
466
- token-pilot [project-root] Start MCP server (default: cwd)
467
- token-pilot init [dir] Create .mcp.json with token-pilot + context-mode
468
- token-pilot install-hook [root] Install PreToolUse hook (Claude Code only)
469
- token-pilot uninstall-hook [root] Remove PreToolUse hook
470
- token-pilot install-ast-index Download ast-index binary (auto on first run)
471
- token-pilot doctor Run diagnostics (check ast-index, config, updates)
472
- token-pilot --version Show version
473
- token-pilot --help Show this help
474
-
475
- Quick start:
476
- npx token-pilot init Setup .mcp.json (token-pilot + context-mode)
477
-
478
- MCP Tools (18):
479
- smart_read, read_symbol, read_range, read_diff, read_for_edit, smart_read_many,
480
- find_usages, find_unused, related_files, outline, project_overview, session_analytics,
481
- code_audit, module_info, smart_diff, explore_area, smart_log, test_summary
817
+ console.log(`token-pilot v${getVersion()} — MCP server for token-efficient code reading
818
+
819
+ Usage:
820
+ token-pilot [project-root] Start MCP server (default: cwd)
821
+ token-pilot init [dir] Create .mcp.json with token-pilot + context-mode
822
+ token-pilot install-hook [root] Install PreToolUse hook (Claude Code only)
823
+ token-pilot uninstall-hook [root] Remove PreToolUse hook
824
+ token-pilot install-ast-index Download ast-index binary (auto on first run)
825
+ token-pilot doctor Run diagnostics (check ast-index, config, updates)
826
+ token-pilot save-doc <name> Save stdin to .token-pilot/docs/<name>.md
827
+ token-pilot list-docs List saved docs
828
+ token-pilot --version Show version
829
+ token-pilot --help Show this help
830
+
831
+ Quick start:
832
+ npx token-pilot init Setup .mcp.json (token-pilot + context-mode)
833
+
834
+ MCP Tools (22):
835
+ smart_read, read_symbol, read_symbols, read_range, read_section, read_diff,
836
+ read_for_edit, smart_read_many, find_usages, find_unused, related_files,
837
+ outline, project_overview, session_analytics, code_audit, module_info,
838
+ smart_diff, explore_area, smart_log, test_summary, session_snapshot,
839
+ session_budget
482
840
  `);
483
841
  process.exit(0);
484
842
  }
485
- const isDirectRun = process.argv[1] !== undefined && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
843
+ const isDirectRun = process.argv[1] !== undefined &&
844
+ realpathSync(fileURLToPath(import.meta.url)) ===
845
+ realpathSync(process.argv[1]);
486
846
  if (isDirectRun) {
487
- main().catch(err => {
847
+ main().catch((err) => {
488
848
  console.error(`[token-pilot] Fatal: ${err instanceof Error ? err.message : err}`);
489
849
  process.exit(1);
490
850
  });