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
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Phase 5 subtasks 5.3 + 5.4 — install tp-* agents into the user's
3
+ * Claude Code agent registry.
4
+ *
5
+ * Copies every `tp-*.md` from `distAgentsDir` into either
6
+ * `<projectRoot>/.claude/agents/` (scope=project) or
7
+ * `<homeDir>/.claude/agents/` (scope=user).
8
+ *
9
+ * Idempotence states (see Phase 5 design):
10
+ *
11
+ * - **unchanged-installed** — stored hash matches template hash → skip
12
+ * (re-write would be a no-op).
13
+ * - **template-upgraded** — stored hash differs from template hash AND
14
+ * the file body still matches the stored hash (user did not edit) →
15
+ * overwrite.
16
+ * - **user-edited** — stored hash does not match the file body hash →
17
+ * skip unless `force: true`.
18
+ * - **no-hash** — file has no `token_pilot_body_hash` in frontmatter
19
+ * → never overwrite. This is always treated as the user's own file,
20
+ * even when `force: true`.
21
+ *
22
+ * Never throws on a per-file failure: the problem is recorded in
23
+ * `skipped` so the CLI can report it without aborting the rest.
24
+ */
25
+ import { readdir, readFile, writeFile, mkdir, access } from "node:fs/promises";
26
+ import { createHash } from "node:crypto";
27
+ import { createInterface } from "node:readline";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { parseFrontmatter } from "./agent-frontmatter.js";
32
+ function targetDirFor(opts) {
33
+ const root = opts.scope === "user" ? opts.homeDir : opts.projectRoot;
34
+ return join(root, ".claude", "agents");
35
+ }
36
+ function sha256(s) {
37
+ return createHash("sha256").update(s).digest("hex");
38
+ }
39
+ async function pathExists(p) {
40
+ try {
41
+ await access(p);
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ /** Extract the body portion (everything after the closing `---\n`). */
49
+ function extractBody(md) {
50
+ const m = md.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
51
+ return m ? m[1] : md;
52
+ }
53
+ /**
54
+ * Read a tp-*.md from disk and return the stored `token_pilot_body_hash`
55
+ * from frontmatter. Returns `null` when the field is absent — this
56
+ * marks the file as user-owned (no-hash state).
57
+ */
58
+ async function readStoredHash(p) {
59
+ try {
60
+ const md = await readFile(p, "utf-8");
61
+ const { meta } = parseFrontmatter(md);
62
+ const stored = meta.token_pilot_body_hash;
63
+ return typeof stored === "string" && stored.length > 0 ? stored : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ export async function installAgents(opts) {
70
+ const target = targetDirFor(opts);
71
+ const result = {
72
+ installed: [],
73
+ skipped: [],
74
+ targetDir: target,
75
+ };
76
+ let entries;
77
+ try {
78
+ entries = await readdir(opts.distAgentsDir);
79
+ }
80
+ catch (err) {
81
+ const msg = err.code ?? String(err);
82
+ throw new Error(`install-agents: distAgentsDir not readable (${opts.distAgentsDir}): ${msg}`);
83
+ }
84
+ const templates = entries.filter((f) => f.endsWith(".md") && f.startsWith("tp-"));
85
+ if (templates.length === 0) {
86
+ return result;
87
+ }
88
+ await mkdir(target, { recursive: true });
89
+ for (const entry of templates) {
90
+ const name = entry.replace(/\.md$/, "");
91
+ const distPath = join(opts.distAgentsDir, entry);
92
+ const targetPath = join(target, entry);
93
+ let templateMd;
94
+ try {
95
+ templateMd = await readFile(distPath, "utf-8");
96
+ }
97
+ catch {
98
+ result.skipped.push({ name, reason: "dist read failed" });
99
+ continue;
100
+ }
101
+ const templateHash = sha256(extractBody(templateMd));
102
+ if (!(await pathExists(targetPath))) {
103
+ // Fresh install.
104
+ try {
105
+ await writeFile(targetPath, templateMd);
106
+ result.installed.push(name);
107
+ }
108
+ catch {
109
+ result.skipped.push({ name, reason: "write failed" });
110
+ }
111
+ continue;
112
+ }
113
+ // Target exists — classify state.
114
+ const existing = await readFile(targetPath, "utf-8");
115
+ const storedHash = await readStoredHash(targetPath);
116
+ const currentBodyHash = sha256(extractBody(existing));
117
+ if (storedHash === null) {
118
+ // no-hash: user's own file. Never touch, even with --force.
119
+ result.skipped.push({
120
+ name,
121
+ reason: "not installed by token-pilot (no token_pilot_body_hash)",
122
+ });
123
+ continue;
124
+ }
125
+ // user-edited must be detected BEFORE the unchanged check, because a
126
+ // user may hand-edit an agent whose stored hash still equals the
127
+ // current template hash (common: local tweak, no template update).
128
+ if (currentBodyHash !== storedHash) {
129
+ if (opts.force) {
130
+ try {
131
+ await writeFile(targetPath, templateMd);
132
+ result.installed.push(name);
133
+ }
134
+ catch {
135
+ result.skipped.push({ name, reason: "write failed" });
136
+ }
137
+ }
138
+ else {
139
+ result.skipped.push({
140
+ name,
141
+ reason: "edited by user (use --force to override)",
142
+ });
143
+ }
144
+ continue;
145
+ }
146
+ if (storedHash === templateHash) {
147
+ // unchanged-installed: silent skip (re-write would be a no-op).
148
+ result.skipped.push({ name, reason: "unchanged" });
149
+ continue;
150
+ }
151
+ // template-upgraded: user did not edit (currentBodyHash === storedHash)
152
+ // but the template has moved on — safe to overwrite.
153
+ try {
154
+ await writeFile(targetPath, templateMd);
155
+ result.installed.push(name);
156
+ }
157
+ catch {
158
+ result.skipped.push({ name, reason: "write failed" });
159
+ }
160
+ }
161
+ return result;
162
+ }
163
+ // ─── CLI wrapper ─────────────────────────────────────────────────────────────
164
+ /**
165
+ * Resolve the dist/agents directory relative to the running `dist/index.js`
166
+ * entry. Works for both `npm run start` (dist/) and `npm pack`-installed
167
+ * users (node_modules/token-pilot/dist/agents/). Falls back to `templates/
168
+ * agents` only when we are clearly running from source (tests, dev mode).
169
+ */
170
+ export function resolveDistAgentsDir(scriptUrl) {
171
+ // Compiled layout: dist/cli/install-agents.js → dist/agents/.
172
+ // One level up from our own file, then into agents/.
173
+ const here = dirname(fileURLToPath(scriptUrl));
174
+ return join(here, "..", "agents");
175
+ }
176
+ /** Read one line from an interactive TTY prompt. */
177
+ async function promptLine(question) {
178
+ process.stderr.write(question);
179
+ return new Promise((resolve) => {
180
+ const rl = createInterface({
181
+ input: process.stdin,
182
+ output: process.stderr,
183
+ });
184
+ rl.question("", (answer) => {
185
+ rl.close();
186
+ resolve(answer.trim());
187
+ });
188
+ });
189
+ }
190
+ /**
191
+ * Yes/no prompt used by other CLI entry points that want to offer
192
+ * an opt-in step (e.g. `token-pilot init` → "install agents now?").
193
+ * Returns false on non-TTY or ambiguous input; callers can pass a
194
+ * `defaultYes` to accept bare Enter as yes.
195
+ */
196
+ export async function promptYesNo(question, defaultYes = true) {
197
+ if (!process.stdin.isTTY)
198
+ return false;
199
+ const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
200
+ const ans = (await promptLine(question + suffix)).toLowerCase();
201
+ if (ans === "")
202
+ return defaultYes;
203
+ return ans === "y" || ans === "yes";
204
+ }
205
+ async function promptScope() {
206
+ process.stderr.write("\nWhere should token-pilot agents be installed?\n" +
207
+ " [1] user → ~/.claude/agents/tp-*.md (available in every project)\n" +
208
+ " [2] project → .claude/agents/tp-*.md (only this project)\n");
209
+ while (true) {
210
+ const ans = (await promptLine("Choice [1/2]: ")).toLowerCase();
211
+ if (ans === "1" || ans === "user")
212
+ return "user";
213
+ if (ans === "2" || ans === "project")
214
+ return "project";
215
+ process.stderr.write("Please enter 1 or 2.\n");
216
+ }
217
+ }
218
+ function parseFlag(argv, key) {
219
+ for (const a of argv) {
220
+ if (a === `--${key}` || a === `-${key}`)
221
+ return "true";
222
+ if (a.startsWith(`--${key}=`))
223
+ return a.slice(key.length + 3);
224
+ }
225
+ return undefined;
226
+ }
227
+ // ─── scope persistence in .token-pilot.json ─────────────────────────────────
228
+ function configPath(projectRoot) {
229
+ return join(projectRoot, ".token-pilot.json");
230
+ }
231
+ /**
232
+ * Read `agents.scope` from `<projectRoot>/.token-pilot.json`. Returns
233
+ * null if the file is missing, unreadable, not valid JSON, or if the
234
+ * field is absent. Never throws — a bad config should not block install.
235
+ */
236
+ export async function readPersistedScope(projectRoot) {
237
+ try {
238
+ const raw = await readFile(configPath(projectRoot), "utf-8");
239
+ const json = JSON.parse(raw);
240
+ const s = json.agents?.scope;
241
+ if (s === "user" || s === "project")
242
+ return s;
243
+ return null;
244
+ }
245
+ catch {
246
+ return null;
247
+ }
248
+ }
249
+ /**
250
+ * Persist `agents.scope` into `<projectRoot>/.token-pilot.json`, merging
251
+ * with any existing config. Failures are swallowed — persistence is a
252
+ * convenience, not a correctness requirement.
253
+ */
254
+ export async function persistScope(projectRoot, scope) {
255
+ const p = configPath(projectRoot);
256
+ let existing = {};
257
+ try {
258
+ const raw = await readFile(p, "utf-8");
259
+ const parsed = JSON.parse(raw);
260
+ if (parsed && typeof parsed === "object") {
261
+ existing = parsed;
262
+ }
263
+ }
264
+ catch {
265
+ /* fresh file */
266
+ }
267
+ const currentAgents = existing.agents ?? {};
268
+ const next = {
269
+ ...existing,
270
+ agents: { ...currentAgents, scope },
271
+ };
272
+ try {
273
+ await writeFile(p, JSON.stringify(next, null, 2) + "\n");
274
+ }
275
+ catch {
276
+ /* ignore — persistence is best-effort */
277
+ }
278
+ }
279
+ /**
280
+ * CLI entry: `token-pilot install-agents [--scope=user|project] [--force]`.
281
+ *
282
+ * Exit codes:
283
+ * 0 — something installed OR everything was deliberately skipped
284
+ * 1 — missing/unreadable dist/agents, or non-TTY without --scope
285
+ */
286
+ export async function handleInstallAgents(argv, opts) {
287
+ const scopeArg = parseFlag(argv, "scope");
288
+ const force = parseFlag(argv, "force") !== undefined;
289
+ const projectRoot = opts?.projectRoot ?? process.cwd();
290
+ let scope;
291
+ if (scopeArg === "user" || scopeArg === "project") {
292
+ scope = scopeArg;
293
+ }
294
+ else if (scopeArg !== undefined) {
295
+ process.stderr.write(`install-agents: --scope must be 'user' or 'project', got '${scopeArg}'\n`);
296
+ return 1;
297
+ }
298
+ else {
299
+ // No flag — try persisted scope from .token-pilot.json, else prompt.
300
+ const persisted = await readPersistedScope(projectRoot);
301
+ if (persisted) {
302
+ scope = persisted;
303
+ process.stderr.write(`[token-pilot] Using persisted scope: ${scope} (from .token-pilot.json)\n`);
304
+ }
305
+ else {
306
+ const tty = opts?.isTTY ?? process.stdin.isTTY === true;
307
+ if (!tty) {
308
+ process.stderr.write("install-agents: --scope=user|project is required in non-interactive mode.\n");
309
+ return 1;
310
+ }
311
+ scope = await promptScope();
312
+ }
313
+ }
314
+ const distAgentsDir = opts?.distAgentsDir ?? resolveDistAgentsDir(import.meta.url);
315
+ try {
316
+ const result = await installAgents({
317
+ scope,
318
+ projectRoot,
319
+ homeDir: opts?.homeDir ?? homedir(),
320
+ distAgentsDir,
321
+ force,
322
+ });
323
+ const plural = (n, s) => (n === 1 ? s : s + "s");
324
+ if (result.installed.length > 0) {
325
+ // Best-effort persist the chosen scope so re-runs skip the prompt.
326
+ await persistScope(projectRoot, scope);
327
+ process.stderr.write(`\n[token-pilot] Installed ${result.installed.length} ${plural(result.installed.length, "agent")} ` +
328
+ `to ${result.targetDir}.\n` +
329
+ `Start a new Claude Code session to see them.\n`);
330
+ }
331
+ if (result.skipped.length > 0) {
332
+ process.stderr.write(`[token-pilot] Skipped ${result.skipped.length}:\n`);
333
+ for (const s of result.skipped) {
334
+ process.stderr.write(` - ${s.name}: ${s.reason}\n`);
335
+ }
336
+ }
337
+ if (result.installed.length === 0 && result.skipped.length === 0) {
338
+ process.stderr.write(`[token-pilot] No tp-*.md found in ${distAgentsDir}. ` +
339
+ `Did you run \`npm run build\`?\n`);
340
+ return 1;
341
+ }
342
+ return 0;
343
+ }
344
+ catch (err) {
345
+ process.stderr.write(`install-agents: ${err.message}\n`);
346
+ return 1;
347
+ }
348
+ }
349
+ /**
350
+ * Scan both user-scope (`<homeDir>/.claude/agents`) and project-scope
351
+ * (`<projectRoot>/.claude/agents`) for any `tp-*.md`. The reminder fires
352
+ * only when nothing is found in either location.
353
+ */
354
+ async function anyTpAgentInstalled(projectRoot, homeDir) {
355
+ for (const root of [projectRoot, homeDir]) {
356
+ try {
357
+ const entries = await readdir(join(root, ".claude", "agents"));
358
+ if (entries.some((f) => f.startsWith("tp-") && f.endsWith(".md"))) {
359
+ return true;
360
+ }
361
+ }
362
+ catch {
363
+ // Missing dir → this scope has nothing; keep checking.
364
+ }
365
+ }
366
+ return false;
367
+ }
368
+ /**
369
+ * Pure, testable check: should the MCP startup emit the agent-install
370
+ * reminder right now?
371
+ */
372
+ export async function shouldEmitStartupReminder(opts) {
373
+ const env = opts.env ?? process.env;
374
+ if (env.TOKEN_PILOT_NO_AGENT_REMINDER === "1")
375
+ return false;
376
+ if (env.TOKEN_PILOT_SUBAGENT === "1")
377
+ return false;
378
+ if (opts.configSuppressed)
379
+ return false;
380
+ return !(await anyTpAgentInstalled(opts.projectRoot, opts.homeDir));
381
+ }
382
+ export const STARTUP_REMINDER_MESSAGE = "[token-pilot] tp-* agents not installed. Run `npx token-pilot install-agents` " +
383
+ "to enable guaranteed-savings subagents (scope: user or project — your choice).\n";
384
+ /**
385
+ * Emit the reminder to stderr at most once per process. Safe to call
386
+ * multiple times; subsequent calls are no-ops.
387
+ */
388
+ let startupReminderEmitted = false;
389
+ export async function maybeEmitStartupReminder(opts) {
390
+ if (startupReminderEmitted)
391
+ return false;
392
+ if (!(await shouldEmitStartupReminder(opts)))
393
+ return false;
394
+ process.stderr.write(STARTUP_REMINDER_MESSAGE);
395
+ startupReminderEmitted = true;
396
+ return true;
397
+ }
398
+ /** Test-only: reset the single-fire guard. */
399
+ export function _resetStartupReminderForTests() {
400
+ startupReminderEmitted = false;
401
+ }
402
+ //# sourceMappingURL=install-agents.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * TP-89n — persist arbitrary research text so it survives compaction.
3
+ *
4
+ * `token-pilot save-doc <name>` reads text from stdin (or --content flag)
5
+ * and writes it to `.token-pilot/docs/<name>.md`. `token-pilot list-docs`
6
+ * enumerates what's been saved. Later, agents can re-read the file with
7
+ * `read_range` / `smart_read` instead of re-fetching the external source.
8
+ *
9
+ * Safety: name must be a slug (no path separators, no traversal, no
10
+ * whitespace/control chars). Overwrite is explicit.
11
+ */
12
+ export declare const DOCS_SUBDIR = ".token-pilot/docs";
13
+ export declare function normalizeDocName(raw: string): string;
14
+ export interface SaveDocInput {
15
+ projectRoot: string;
16
+ name: string;
17
+ content: string;
18
+ overwrite?: boolean;
19
+ }
20
+ export interface SaveDocResult {
21
+ saved: boolean;
22
+ path?: string;
23
+ reason?: string;
24
+ }
25
+ export declare function saveDoc(input: SaveDocInput): Promise<SaveDocResult>;
26
+ export interface DocEntry {
27
+ name: string;
28
+ path: string;
29
+ bytes: number;
30
+ mtimeMs: number;
31
+ }
32
+ export declare function listDocs(projectRoot: string): Promise<DocEntry[]>;
33
+ /**
34
+ * CLI entry — returns exit code.
35
+ * Usage:
36
+ * token-pilot save-doc <name> [--overwrite] [--content "text"]
37
+ * token-pilot list-docs
38
+ * When --content is absent, reads from stdin.
39
+ */
40
+ export declare function handleSaveDocCli(args: string[]): Promise<number>;
41
+ export declare function handleListDocsCli(): Promise<number>;
42
+ //# sourceMappingURL=save-doc.d.ts.map
@@ -0,0 +1,145 @@
1
+ /**
2
+ * TP-89n — persist arbitrary research text so it survives compaction.
3
+ *
4
+ * `token-pilot save-doc <name>` reads text from stdin (or --content flag)
5
+ * and writes it to `.token-pilot/docs/<name>.md`. `token-pilot list-docs`
6
+ * enumerates what's been saved. Later, agents can re-read the file with
7
+ * `read_range` / `smart_read` instead of re-fetching the external source.
8
+ *
9
+ * Safety: name must be a slug (no path separators, no traversal, no
10
+ * whitespace/control chars). Overwrite is explicit.
11
+ */
12
+ import { promises as fs } from "node:fs";
13
+ import { join } from "node:path";
14
+ export const DOCS_SUBDIR = ".token-pilot/docs";
15
+ const NAME_RE = /^[A-Za-z0-9._-]+$/;
16
+ export function normalizeDocName(raw) {
17
+ if (!raw)
18
+ throw new Error("invalid doc name: empty");
19
+ const trimmed = raw.endsWith(".md") ? raw.slice(0, -3) : raw;
20
+ if (!NAME_RE.test(trimmed)) {
21
+ throw new Error(`invalid doc name: ${JSON.stringify(raw)} — use [A-Za-z0-9._-] only`);
22
+ }
23
+ return trimmed;
24
+ }
25
+ export async function saveDoc(input) {
26
+ const name = normalizeDocName(input.name);
27
+ if (!input.content) {
28
+ return { saved: false, reason: "content is empty" };
29
+ }
30
+ const dir = join(input.projectRoot, DOCS_SUBDIR);
31
+ await fs.mkdir(dir, { recursive: true });
32
+ const path = join(dir, `${name}.md`);
33
+ if (!input.overwrite) {
34
+ try {
35
+ await fs.stat(path);
36
+ return {
37
+ saved: false,
38
+ path,
39
+ reason: `doc already exists at ${path}; pass --overwrite to replace`,
40
+ };
41
+ }
42
+ catch {
43
+ /* not present — proceed */
44
+ }
45
+ }
46
+ await fs.writeFile(path, input.content);
47
+ return { saved: true, path };
48
+ }
49
+ export async function listDocs(projectRoot) {
50
+ const dir = join(projectRoot, DOCS_SUBDIR);
51
+ let entries;
52
+ try {
53
+ entries = await fs.readdir(dir);
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ const out = [];
59
+ for (const name of entries) {
60
+ if (!name.endsWith(".md"))
61
+ continue;
62
+ const full = join(dir, name);
63
+ try {
64
+ const s = await fs.stat(full);
65
+ if (!s.isFile())
66
+ continue;
67
+ out.push({
68
+ name: name.slice(0, -3),
69
+ path: full,
70
+ bytes: s.size,
71
+ mtimeMs: s.mtimeMs,
72
+ });
73
+ }
74
+ catch {
75
+ /* skip unreadable */
76
+ }
77
+ }
78
+ out.sort((a, b) => a.name.localeCompare(b.name));
79
+ return out;
80
+ }
81
+ /**
82
+ * CLI entry — returns exit code.
83
+ * Usage:
84
+ * token-pilot save-doc <name> [--overwrite] [--content "text"]
85
+ * token-pilot list-docs
86
+ * When --content is absent, reads from stdin.
87
+ */
88
+ export async function handleSaveDocCli(args) {
89
+ const name = args.find((a) => !a.startsWith("--"));
90
+ if (!name) {
91
+ process.stderr.write('Usage: token-pilot save-doc <name> [--overwrite] [--content "text"]\n');
92
+ return 1;
93
+ }
94
+ const overwrite = args.includes("--overwrite");
95
+ const contentIdx = args.indexOf("--content");
96
+ let content;
97
+ if (contentIdx >= 0 && args[contentIdx + 1] !== undefined) {
98
+ content = args[contentIdx + 1];
99
+ }
100
+ else {
101
+ content = await readAllStdin();
102
+ }
103
+ try {
104
+ const res = await saveDoc({
105
+ projectRoot: process.cwd(),
106
+ name,
107
+ content,
108
+ overwrite,
109
+ });
110
+ if (res.saved) {
111
+ process.stdout.write(`Saved: ${res.path}\n`);
112
+ return 0;
113
+ }
114
+ process.stderr.write(`Not saved: ${res.reason ?? "unknown reason"}\n`);
115
+ return 1;
116
+ }
117
+ catch (err) {
118
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
119
+ return 1;
120
+ }
121
+ }
122
+ export async function handleListDocsCli() {
123
+ const docs = await listDocs(process.cwd());
124
+ if (docs.length === 0) {
125
+ process.stdout.write("No saved docs.\n");
126
+ return 0;
127
+ }
128
+ for (const d of docs) {
129
+ const kb = (d.bytes / 1024).toFixed(1);
130
+ process.stdout.write(`${d.name}\t${kb} KB\t${d.path}\n`);
131
+ }
132
+ return 0;
133
+ }
134
+ async function readAllStdin() {
135
+ return new Promise((resolve, reject) => {
136
+ let data = "";
137
+ process.stdin.setEncoding("utf-8");
138
+ process.stdin.on("data", (chunk) => {
139
+ data += chunk;
140
+ });
141
+ process.stdin.on("end", () => resolve(data));
142
+ process.stdin.on("error", reject);
143
+ });
144
+ }
145
+ //# sourceMappingURL=save-doc.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Agent scanner + classifier (subtasks 3.2 + 3.3).
3
+ *
4
+ * Discovers agent .md files from three locations:
5
+ * 1. project .claude/agents/**\/*.md (scope: 'project')
6
+ * 2. user ~/.claude/agents/**\/*.md (scope: 'user')
7
+ * 3. plugin-contributed agents (scope: 'plugin')
8
+ *
9
+ * For each file: parses frontmatter, computes body hash, returns a ScannedAgent.
10
+ * Never throws — bad/unreadable files are skipped with a one-line stderr note.
11
+ * Symlinks pointing outside the scope's nominal root are skipped.
12
+ */
13
+ import { type ParsedTools } from "./agent-frontmatter.js";
14
+ export type AgentScope = "project" | "user" | "plugin";
15
+ export type AgentCategory = "A" | "B" | "C";
16
+ export interface ScannedAgent {
17
+ name: string;
18
+ path: string;
19
+ scope: AgentScope;
20
+ tools: ParsedTools;
21
+ description: string;
22
+ bodyHash: string;
23
+ blessed: boolean;
24
+ }
25
+ export interface ScanOptions {
26
+ projectRoot: string;
27
+ homeDir: string;
28
+ /** Pre-resolved glob patterns for plugin agent files (e.g. from ~/.claude/plugins/cache). */
29
+ pluginCacheGlob: string[];
30
+ }
31
+ /**
32
+ * Scan all agent directories and return parsed ScannedAgent entries.
33
+ * Never throws.
34
+ */
35
+ export declare function scanAgents(opts: ScanOptions): Promise<ScannedAgent[]>;
36
+ /**
37
+ * Classify an agent by its tools field.
38
+ *
39
+ * A — wildcard (tools: * | All tools) → already has MCP access
40
+ * A — explicit list that already contains mcp__token-pilot__* → already has access
41
+ * B — exclusion form where mcp__token-pilot__ is NOT excluded → has access
42
+ * C — explicit list without mcp__token-pilot__* → candidate for blessing
43
+ * C — exclusion form that explicitly excludes mcp__token-pilot__* → needs blessing
44
+ */
45
+ export declare function classifyAgent(agent: ScannedAgent): AgentCategory;
46
+ //# sourceMappingURL=scan-agents.d.ts.map