token-pilot 0.19.1 → 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 (94) hide show
  1. package/.claude-plugin/hooks/hooks.json +21 -0
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +736 -580
  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/ast-index/binary-manager.d.ts +3 -3
  17. package/dist/ast-index/binary-manager.js +74 -11
  18. package/dist/ast-index/client.d.ts +5 -1
  19. package/dist/ast-index/client.js +9 -2
  20. package/dist/cli/agent-frontmatter.d.ts +48 -0
  21. package/dist/cli/agent-frontmatter.js +189 -0
  22. package/dist/cli/bless-agents.d.ts +65 -0
  23. package/dist/cli/bless-agents.js +307 -0
  24. package/dist/cli/claudeignore.d.ts +33 -0
  25. package/dist/cli/claudeignore.js +88 -0
  26. package/dist/cli/claudemd-hygiene.d.ts +26 -0
  27. package/dist/cli/claudemd-hygiene.js +43 -0
  28. package/dist/cli/doctor-drift.d.ts +31 -0
  29. package/dist/cli/doctor-drift.js +130 -0
  30. package/dist/cli/doctor-env-check.d.ts +25 -0
  31. package/dist/cli/doctor-env-check.js +91 -0
  32. package/dist/cli/install-agents.d.ts +108 -0
  33. package/dist/cli/install-agents.js +402 -0
  34. package/dist/cli/save-doc.d.ts +42 -0
  35. package/dist/cli/save-doc.js +145 -0
  36. package/dist/cli/scan-agents.d.ts +46 -0
  37. package/dist/cli/scan-agents.js +227 -0
  38. package/dist/cli/stats.d.ts +36 -0
  39. package/dist/cli/stats.js +131 -0
  40. package/dist/cli/unbless-agents.d.ts +33 -0
  41. package/dist/cli/unbless-agents.js +85 -0
  42. package/dist/cli/uninstall-agents.d.ts +36 -0
  43. package/dist/cli/uninstall-agents.js +117 -0
  44. package/dist/config/defaults.d.ts +1 -1
  45. package/dist/config/defaults.js +14 -8
  46. package/dist/config/loader.d.ts +1 -1
  47. package/dist/config/loader.js +105 -11
  48. package/dist/core/context-registry.d.ts +16 -1
  49. package/dist/core/context-registry.js +60 -28
  50. package/dist/core/event-log.d.ts +79 -0
  51. package/dist/core/event-log.js +190 -0
  52. package/dist/core/session-registry.d.ts +43 -0
  53. package/dist/core/session-registry.js +113 -0
  54. package/dist/core/session-savings.d.ts +19 -0
  55. package/dist/core/session-savings.js +60 -0
  56. package/dist/handlers/session-budget.d.ts +32 -0
  57. package/dist/handlers/session-budget.js +61 -0
  58. package/dist/handlers/session-snapshot-persist.d.ts +22 -0
  59. package/dist/handlers/session-snapshot-persist.js +76 -0
  60. package/dist/hooks/adaptive-threshold.d.ts +27 -0
  61. package/dist/hooks/adaptive-threshold.js +46 -0
  62. package/dist/hooks/format-deny-message.d.ts +21 -0
  63. package/dist/hooks/format-deny-message.js +147 -0
  64. package/dist/hooks/installer.d.ts +7 -1
  65. package/dist/hooks/installer.js +175 -55
  66. package/dist/hooks/path-safety.d.ts +16 -0
  67. package/dist/hooks/path-safety.js +34 -0
  68. package/dist/hooks/post-bash.d.ts +46 -0
  69. package/dist/hooks/post-bash.js +77 -0
  70. package/dist/hooks/session-start.d.ts +45 -0
  71. package/dist/hooks/session-start.js +179 -0
  72. package/dist/hooks/summary-ast-index.d.ts +28 -0
  73. package/dist/hooks/summary-ast-index.js +122 -0
  74. package/dist/hooks/summary-head-tail.d.ts +15 -0
  75. package/dist/hooks/summary-head-tail.js +78 -0
  76. package/dist/hooks/summary-pipeline.d.ts +35 -0
  77. package/dist/hooks/summary-pipeline.js +63 -0
  78. package/dist/hooks/summary-regex.d.ts +14 -0
  79. package/dist/hooks/summary-regex.js +130 -0
  80. package/dist/hooks/summary-types.d.ts +29 -0
  81. package/dist/hooks/summary-types.js +9 -0
  82. package/dist/index.d.ts +15 -3
  83. package/dist/index.js +508 -131
  84. package/dist/integration/context-mode-detector.d.ts +7 -1
  85. package/dist/integration/context-mode-detector.js +51 -15
  86. package/dist/server/tool-definitions.d.ts +149 -0
  87. package/dist/server/tool-definitions.js +424 -202
  88. package/dist/server.d.ts +1 -1
  89. package/dist/server.js +456 -179
  90. package/dist/templates/agent-builder.d.ts +49 -0
  91. package/dist/templates/agent-builder.js +104 -0
  92. package/dist/types.d.ts +38 -4
  93. package/package.json +89 -87
  94. 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,99 +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
- installHook(projectRoot).then(result => {
277
+ // Uses absolute paths to node + script so hooks work in /bin/sh (nvm, npx, etc.)
278
+ let hookOptions;
279
+ try {
280
+ const rawPath = fileURLToPath(new URL("./index.js", import.meta.url));
281
+ hookOptions = {
282
+ scriptPath: realpathSync(rawPath),
283
+ nodeExecPath: process.execPath,
284
+ };
285
+ }
286
+ catch {
287
+ // Can't resolve script path (e.g. running from src/ in tests) — fall back to bare command
288
+ }
289
+ installHook(projectRoot, hookOptions)
290
+ .then((result) => {
126
291
  if (result.installed) {
127
292
  console.error(`[token-pilot] hook auto-installed: ${result.message}`);
128
293
  }
129
- }).catch(() => { });
294
+ })
295
+ .catch(() => {
296
+ /* ignore — not Claude Code or no .claude dir */
297
+ });
130
298
  const server = await createServer(projectRoot, {
131
299
  skipAstIndex: isDangerousRoot(projectRoot),
132
300
  });
133
301
  const transport = new StdioServerTransport();
134
302
  await server.connect(transport);
135
- process.on('SIGINT', async () => {
303
+ process.on("SIGINT", async () => {
136
304
  await server.close();
137
305
  process.exit(0);
138
306
  });
139
- process.on('SIGTERM', async () => {
307
+ process.on("SIGTERM", async () => {
140
308
  await server.close();
141
309
  process.exit(0);
142
310
  });
143
311
  }
144
- export function handleHookRead(filePathArg, denyThreshold = 300) {
145
- // 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).
146
339
  let filePath = filePathArg;
147
340
  let hasOffset = false;
148
341
  let hasLimit = false;
342
+ let sessionId = "";
343
+ let agentType = null;
344
+ let agentId = null;
149
345
  if (!filePath) {
150
346
  try {
151
- const stdin = readFileSync(0, 'utf-8');
347
+ const stdin = readFileSync(0, "utf-8");
152
348
  const input = JSON.parse(stdin);
153
349
  filePath = input?.tool_input?.file_path;
154
350
  hasOffset = input?.tool_input?.offset != null;
155
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;
156
356
  }
157
357
  catch {
158
- process.exit(0);
358
+ return null;
159
359
  }
160
360
  }
161
- if (!filePath) {
162
- process.exit(0);
163
- }
164
- const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
165
- // Non-code files — allow Read without interference
166
- if (!CODE_EXTENSIONS.has(ext)) {
167
- process.exit(0);
168
- }
169
- // Bounded Read (has offset or limit) allow, AI is reading a specific section
170
- if (hasOffset || hasLimit) {
171
- 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;
172
380
  }
173
- // 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 = "";
174
392
  let lineCount = 0;
175
- let fileContent = '';
176
393
  try {
177
- fileContent = readFileSync(filePath, 'utf-8');
178
- lineCount = fileContent.split('\n').length;
179
- if (lineCount <= denyThreshold) {
180
- process.exit(0);
181
- }
394
+ fileContent = readFileSync(filePath, "utf-8");
395
+ lineCount = fileContent.split("\n").length;
396
+ if (lineCount <= effectiveThreshold)
397
+ return null;
182
398
  }
183
399
  catch {
184
- process.exit(0);
400
+ return null;
185
401
  }
186
- // 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.
187
408
  try {
188
- const charEst = Math.ceil(fileContent.length / 4);
189
- const wsRatio = (fileContent.match(/\s/g)?.length ?? 0) / fileContent.length;
190
- const estTokens = Math.ceil(charEst * (1 - wsRatio * 0.3));
191
- const entry = JSON.stringify({ filePath, lineCount, estimatedTokens: estTokens, timestamp: Date.now() });
192
- 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");
193
417
  mkdirSync(dir, { recursive: true });
194
- appendFileSync(join(dir, 'hook-denied.jsonl'), entry + '\n');
418
+ appendFileSync(join(dir, "hook-denied.jsonl"), entry + "\n");
195
419
  }
196
420
  catch {
197
- // 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
+ });
449
+ }
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;
198
455
  }
199
- // Large code file, unbounded Read → DENY
200
- // permissionDecisionReason is shown to Claude (not user) per official docs
201
- const deny = JSON.stringify({
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({
202
463
  hookSpecificOutput: {
203
464
  hookEventName: "PreToolUse",
204
465
  permissionDecision: "deny",
205
- 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,
206
467
  },
207
468
  });
208
- process.stdout.write(deny);
209
- process.exit(0);
210
469
  }
211
470
  export function handleHookEdit() {
212
471
  // Parse stdin for Edit tool_input
213
472
  let filePath;
214
473
  try {
215
- const stdin = readFileSync(0, 'utf-8');
474
+ const stdin = readFileSync(0, "utf-8");
216
475
  const input = JSON.parse(stdin);
217
476
  filePath = input?.tool_input?.file_path;
218
477
  }
@@ -222,7 +481,7 @@ export function handleHookEdit() {
222
481
  if (!filePath) {
223
482
  process.exit(0);
224
483
  }
225
- const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
484
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
226
485
  // Only add context for code files
227
486
  if (!CODE_EXTENSIONS.has(ext)) {
228
487
  process.exit(0);
@@ -239,7 +498,18 @@ export function handleHookEdit() {
239
498
  process.exit(0);
240
499
  }
241
500
  export async function handleInstallHook(projectRoot) {
242
- const result = await installHook(projectRoot);
501
+ let hookOptions;
502
+ try {
503
+ const rawPath = fileURLToPath(new URL("./index.js", import.meta.url));
504
+ hookOptions = {
505
+ scriptPath: realpathSync(rawPath),
506
+ nodeExecPath: process.execPath,
507
+ };
508
+ }
509
+ catch {
510
+ // Fall back to bare command
511
+ }
512
+ const result = await installHook(projectRoot, hookOptions);
243
513
  console.log(result.message);
244
514
  process.exit(result.fatal ? 1 : 0);
245
515
  }
@@ -273,23 +543,23 @@ export async function handleInstallAstIndex() {
273
543
  }
274
544
  export async function handleDoctor() {
275
545
  const version = getVersion();
276
- const { existsSync } = await import('node:fs');
277
- const { join } = await import('node:path');
546
+ const { existsSync } = await import("node:fs");
547
+ const { join } = await import("node:path");
278
548
  const cwd = process.cwd();
279
549
  console.log(`token-pilot doctor v${version}\n`);
280
550
  // ── Environment ──
281
551
  const nodeVersion = process.version;
282
552
  const nodeMajor = parseInt(nodeVersion.slice(1), 10);
283
- console.log(`Node.js: ${nodeVersion} ${nodeMajor >= 18 ? '' : '✗ (requires >=18)'}`);
284
- const configPath = join(cwd, '.token-pilot.json');
285
- console.log(`config: ${existsSync(configPath) ? configPath + '' : 'default (no .token-pilot.json)'}`);
286
- const gitDir = join(cwd, '.git');
287
- console.log(`git repo: ${existsSync(gitDir) ? 'yes ✓' : 'no (read_diff/git features unavailable)'}`);
288
- 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("");
289
559
  // ── token-pilot ──
290
- console.log('── token-pilot ──');
560
+ console.log("── token-pilot ──");
291
561
  console.log(` installed: ${version}`);
292
- const tpLatest = await checkNpmLatest('token-pilot');
562
+ const tpLatest = await checkNpmLatest("token-pilot");
293
563
  if (tpLatest) {
294
564
  if (isNewerVersion(version, tpLatest)) {
295
565
  console.log(` latest: ${tpLatest} (update available!)`);
@@ -302,9 +572,9 @@ export async function handleDoctor() {
302
572
  else {
303
573
  console.log(` latest: could not check (network error)`);
304
574
  }
305
- console.log('');
575
+ console.log("");
306
576
  // ── ast-index ──
307
- console.log('── ast-index ──');
577
+ console.log("── ast-index ──");
308
578
  const astStatus = await findBinary();
309
579
  if (astStatus.available) {
310
580
  console.log(` installed: ${astStatus.version} (${astStatus.source}: ${astStatus.path})`);
@@ -317,45 +587,99 @@ export async function handleDoctor() {
317
587
  console.log(` latest: ${astUpdate.latest} ✓ (up to date)`);
318
588
  }
319
589
  const config = await loadConfig(cwd);
320
- 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)"}`);
321
591
  }
322
592
  else {
323
593
  console.log(` installed: not found ✗`);
324
594
  console.log(` run: npx token-pilot install-ast-index`);
325
595
  }
326
- console.log('');
596
+ console.log("");
327
597
  // ── context-mode ──
328
- console.log('── context-mode ──');
329
- const { detectContextMode } = await import('./integration/context-mode-detector.js');
598
+ console.log("── context-mode ──");
599
+ const { detectContextMode } = await import("./integration/context-mode-detector.js");
330
600
  const cmStatus = await detectContextMode(cwd);
331
- console.log(` detected: ${cmStatus.detected ? `yes (${cmStatus.source})` : 'no'}`);
332
- 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");
333
603
  if (cmLatest) {
334
604
  console.log(` latest npm: ${cmLatest}`);
335
605
  }
336
606
  if (!cmStatus.detected) {
337
607
  console.log(` setup: npx token-pilot init`);
338
608
  }
339
- 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
+ }
340
664
  process.exit(0);
341
665
  }
342
666
  export async function handleInit(targetDir) {
343
- const { existsSync, readFileSync: readFs, writeFileSync } = await import('node:fs');
344
- const { join } = await import('node:path');
345
- 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");
346
670
  const tokenPilotConfig = {
347
- command: 'npx',
348
- args: ['-y', 'token-pilot'],
671
+ command: "npx",
672
+ args: ["-y", "token-pilot"],
349
673
  };
350
674
  const contextModeConfig = {
351
- command: 'npx',
352
- args: ['-y', 'claude-context-mode'],
675
+ command: "npx",
676
+ args: ["-y", "claude-context-mode"],
353
677
  };
354
678
  let config = { mcpServers: {} };
355
679
  let existed = false;
356
680
  if (existsSync(mcpPath)) {
357
681
  try {
358
- config = JSON.parse(readFs(mcpPath, 'utf-8'));
682
+ config = JSON.parse(readFs(mcpPath, "utf-8"));
359
683
  if (!config.mcpServers)
360
684
  config.mcpServers = {};
361
685
  existed = true;
@@ -366,28 +690,71 @@ export async function handleInit(targetDir) {
366
690
  }
367
691
  }
368
692
  const added = [];
369
- if (!config.mcpServers['token-pilot']) {
370
- config.mcpServers['token-pilot'] = tokenPilotConfig;
371
- added.push('token-pilot');
693
+ if (!config.mcpServers["token-pilot"]) {
694
+ config.mcpServers["token-pilot"] = tokenPilotConfig;
695
+ added.push("token-pilot");
372
696
  }
373
- if (!config.mcpServers['context-mode']) {
374
- config.mcpServers['context-mode'] = contextModeConfig;
375
- added.push('context-mode');
697
+ if (!config.mcpServers["context-mode"]) {
698
+ config.mcpServers["context-mode"] = contextModeConfig;
699
+ added.push("context-mode");
376
700
  }
377
701
  if (added.length === 0) {
378
702
  console.log(`✓ ${mcpPath} already has both token-pilot and context-mode configured`);
379
703
  process.exit(0);
380
704
  }
381
- writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n');
705
+ writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
382
706
  if (existed) {
383
- console.log(`✓ Updated ${mcpPath} — added: ${added.join(', ')}`);
707
+ console.log(`✓ Updated ${mcpPath} — added: ${added.join(", ")}`);
384
708
  }
385
709
  else {
386
710
  console.log(`✓ Created ${mcpPath} with token-pilot + context-mode`);
387
711
  }
388
712
  console.log(`\nConfigured MCP servers:`);
389
- 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)`);
390
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
+ }
391
758
  console.log(`\nRestart your AI assistant to activate.`);
392
759
  process.exit(0);
393
760
  }
@@ -404,7 +771,7 @@ export async function checkNpmLatest(packageName) {
404
771
  clearTimeout(timeout);
405
772
  if (!resp.ok)
406
773
  return null;
407
- const data = await resp.json();
774
+ const data = (await resp.json());
408
775
  return data.version ?? null;
409
776
  }
410
777
  catch {
@@ -415,28 +782,32 @@ export async function checkAllUpdates(config, binaryStatus) {
415
782
  if (!config.updates.checkOnStartup)
416
783
  return;
417
784
  const [tpLatest, astUpdate, cmLatest] = await Promise.allSettled([
418
- checkNpmLatest('token-pilot'),
419
- binaryStatus.available ? checkBinaryUpdate(binaryStatus.path) : Promise.resolve(null),
420
- checkNpmLatest('claude-context-mode'),
785
+ checkNpmLatest("token-pilot"),
786
+ binaryStatus.available
787
+ ? checkBinaryUpdate(binaryStatus.path)
788
+ : Promise.resolve(null),
789
+ checkNpmLatest("claude-context-mode"),
421
790
  ]);
422
791
  // token-pilot
423
792
  const tpVersion = getVersion();
424
- if (tpLatest.status === 'fulfilled' && tpLatest.value && isNewerVersion(tpVersion, tpLatest.value)) {
793
+ if (tpLatest.status === "fulfilled" &&
794
+ tpLatest.value &&
795
+ isNewerVersion(tpVersion, tpLatest.value)) {
425
796
  console.error(`[token-pilot] Update available: ${tpVersion} → ${tpLatest.value}. Run: npx token-pilot@latest`);
426
797
  }
427
798
  // ast-index
428
- if (astUpdate.status === 'fulfilled' && astUpdate.value?.updateAvailable) {
799
+ if (astUpdate.status === "fulfilled" && astUpdate.value?.updateAvailable) {
429
800
  const { current, latest } = astUpdate.value;
430
801
  if (config.updates.autoUpdate) {
431
802
  console.error(`[token-pilot] Auto-updating ast-index: ${current} → ${latest}...`);
432
- installBinary(msg => console.error(`[token-pilot] ${msg}`)).catch(() => { });
803
+ installBinary((msg) => console.error(`[token-pilot] ${msg}`)).catch(() => { });
433
804
  }
434
805
  else {
435
806
  console.error(`[token-pilot] ast-index update: ${current} → ${latest}. Run: token-pilot install-ast-index`);
436
807
  }
437
808
  }
438
809
  // context-mode (notification only — runs as separate MCP server)
439
- if (cmLatest.status === 'fulfilled' && cmLatest.value) {
810
+ if (cmLatest.status === "fulfilled" && cmLatest.value) {
440
811
  // We can't reliably detect the currently installed version of context-mode
441
812
  // (it runs as separate process via npx). Just log latest available for doctor.
442
813
  // On startup, we only notify if explicitly useful.
@@ -452,22 +823,28 @@ Usage:
452
823
  token-pilot uninstall-hook [root] Remove PreToolUse hook
453
824
  token-pilot install-ast-index Download ast-index binary (auto on first run)
454
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
455
828
  token-pilot --version Show version
456
829
  token-pilot --help Show this help
457
830
 
458
831
  Quick start:
459
832
  npx token-pilot init Setup .mcp.json (token-pilot + context-mode)
460
833
 
461
- MCP Tools (18):
462
- smart_read, read_symbol, read_range, read_diff, read_for_edit, smart_read_many,
463
- find_usages, find_unused, related_files, outline, project_overview, session_analytics,
464
- code_audit, module_info, smart_diff, explore_area, smart_log, test_summary
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
465
840
  `);
466
841
  process.exit(0);
467
842
  }
468
- 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]);
469
846
  if (isDirectRun) {
470
- main().catch(err => {
847
+ main().catch((err) => {
471
848
  console.error(`[token-pilot] Fatal: ${err instanceof Error ? err.message : err}`);
472
849
  process.exit(1);
473
850
  });