token-pilot 0.32.0 → 0.33.1

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 (48) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/tp-api-surface-tracker.md +1 -1
  4. package/agents/tp-audit-scanner.md +1 -1
  5. package/agents/tp-commit-writer.md +1 -1
  6. package/agents/tp-context-engineer.md +1 -1
  7. package/agents/tp-dead-code-finder.md +1 -1
  8. package/agents/tp-debugger.md +1 -1
  9. package/agents/tp-dep-health.md +1 -1
  10. package/agents/tp-doc-writer.md +1 -1
  11. package/agents/tp-history-explorer.md +1 -1
  12. package/agents/tp-impact-analyzer.md +1 -1
  13. package/agents/tp-incident-timeline.md +1 -1
  14. package/agents/tp-incremental-builder.md +1 -1
  15. package/agents/tp-migration-scout.md +1 -1
  16. package/agents/tp-onboard.md +1 -1
  17. package/agents/tp-performance-profiler.md +1 -1
  18. package/agents/tp-pr-reviewer.md +1 -1
  19. package/agents/tp-refactor-planner.md +1 -1
  20. package/agents/tp-review-impact.md +1 -1
  21. package/agents/tp-run.md +1 -1
  22. package/agents/tp-session-restorer.md +1 -1
  23. package/agents/tp-ship-coordinator.md +1 -1
  24. package/agents/tp-spec-writer.md +1 -1
  25. package/agents/tp-test-coverage-gapper.md +1 -1
  26. package/agents/tp-test-triage.md +1 -1
  27. package/agents/tp-test-writer.md +1 -1
  28. package/dist/ast-index/client.js +17 -1
  29. package/dist/cli/install-agents.d.ts +18 -0
  30. package/dist/cli/install-agents.js +88 -1
  31. package/dist/cli/stats.js +9 -2
  32. package/dist/cli/typo-guard.d.ts +1 -1
  33. package/dist/cli/typo-guard.js +9 -0
  34. package/dist/core/error-log.d.ts +86 -0
  35. package/dist/core/error-log.js +228 -0
  36. package/dist/core/event-log.d.ts +49 -1
  37. package/dist/core/event-log.js +114 -0
  38. package/dist/core/validation.d.ts +12 -0
  39. package/dist/core/validation.js +38 -8
  40. package/dist/handlers/smart-log.js +7 -2
  41. package/dist/hooks/installer.d.ts +40 -0
  42. package/dist/hooks/installer.js +145 -2
  43. package/dist/hooks/pre-task.js +44 -10
  44. package/dist/hooks/safe-runner.d.ts +48 -0
  45. package/dist/hooks/safe-runner.js +73 -0
  46. package/dist/index.d.ts +11 -0
  47. package/dist/index.js +284 -63
  48. package/package.json +1 -1
@@ -0,0 +1,228 @@
1
+ /**
2
+ * v0.34.0 — error / diagnostic channel for token-pilot hooks + CLI.
3
+ *
4
+ * Why a separate file from `hook-events.jsonl`:
5
+ * - hook-events lives in `<projectRoot>/.token-pilot/`. When the hook
6
+ * itself fails BEFORE projectRoot is resolved (B8 WSL detection,
7
+ * missing dir, ENOENT), there is nowhere to write the regular log.
8
+ * - Errors must outlive a single project — when a user reports
9
+ * "nothing logs anymore" we want one absolute path to look at.
10
+ *
11
+ * Layout:
12
+ * ~/.token-pilot/hook-errors.jsonl
13
+ *
14
+ * Format: one JSON record per line. Schema in `HookErrorRecord` below.
15
+ *
16
+ * Discipline:
17
+ * - Never throws. The error logger is itself the last line of defence —
18
+ * a throw here would defeat the wrapper that calls it.
19
+ * - Cap-and-rotate: when the file passes MAX_BYTES the writer renames
20
+ * it to `hook-errors.<ts>.jsonl` and starts fresh. Old archives
21
+ * past RETENTION_MS are pruned best-effort on each append.
22
+ * - `TOKEN_PILOT_NO_ERROR_LOG=1` opts out entirely.
23
+ *
24
+ * Privacy:
25
+ * - The `input` field is whatever the hook chose to record. Callers
26
+ * MUST sanitize before passing it in — no full paths, no file
27
+ * content, no prompts. Helpers `safeBasename()` / `safePathInfo()`
28
+ * are provided for the common cases.
29
+ */
30
+ import * as fs from "node:fs/promises";
31
+ import { existsSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { basename, dirname, join, resolve } from "node:path";
34
+ // ─── constants ───────────────────────────────────────────────────────
35
+ const MAX_BYTES = 5 * 1024 * 1024; // 5 MB before rotate
36
+ const RETENTION_MS = 30 * 24 * 3600 * 1000; // 30d archive retention
37
+ const ARCHIVE_RE = /^hook-errors\.(\d+)\.jsonl$/;
38
+ const CURRENT = "hook-errors.jsonl";
39
+ // ─── path resolution ─────────────────────────────────────────────────
40
+ export function errorLogDir() {
41
+ return join(homedir(), ".token-pilot");
42
+ }
43
+ export function errorLogPath() {
44
+ return join(errorLogDir(), CURRENT);
45
+ }
46
+ // ─── env opt-out ─────────────────────────────────────────────────────
47
+ function isOptedOut() {
48
+ return process.env.TOKEN_PILOT_NO_ERROR_LOG === "1";
49
+ }
50
+ // ─── sanitizers ──────────────────────────────────────────────────────
51
+ /**
52
+ * Reduce a path to its basename. Use everywhere a path could leak:
53
+ * the user's project tree, file content, anything not strictly an
54
+ * identifier. Returns `"<empty>"` for missing input rather than null
55
+ * so the field stays shape-stable.
56
+ */
57
+ export function safeBasename(p) {
58
+ if (typeof p !== "string" || p.length === 0)
59
+ return "<empty>";
60
+ return basename(p);
61
+ }
62
+ /**
63
+ * Capture only the metadata about a path — basename + length + ext —
64
+ * dropping the absolute path entirely. Useful when the analysis
65
+ * benefits from "kind of file" without revealing where it lived.
66
+ */
67
+ export function safePathInfo(p) {
68
+ if (typeof p !== "string" || p.length === 0) {
69
+ return { name: "<empty>", ext: "" };
70
+ }
71
+ const name = basename(p);
72
+ const dot = name.lastIndexOf(".");
73
+ return {
74
+ name,
75
+ ext: dot >= 0 ? name.slice(dot).toLowerCase() : "",
76
+ };
77
+ }
78
+ // ─── error classification ────────────────────────────────────────────
79
+ /**
80
+ * Map a thrown value to a stable, searchable `code`. The classifier
81
+ * is intentionally simple — node ErrnoException codes pass through,
82
+ * everything else falls into a coarse bucket. New cases land here
83
+ * only when a pattern shows up repeatedly in the wild.
84
+ */
85
+ export function classifyError(err) {
86
+ if (err && typeof err === "object") {
87
+ const e = err;
88
+ if (typeof e.code === "string" && e.code.length > 0)
89
+ return e.code;
90
+ const name = e.name;
91
+ if (name === "SyntaxError")
92
+ return "parse_error";
93
+ if (name === "TypeError")
94
+ return "type_error";
95
+ }
96
+ if (err instanceof Error) {
97
+ const m = err.message.toLowerCase();
98
+ if (m.includes("timeout"))
99
+ return "timeout";
100
+ if (m.includes("not initialized"))
101
+ return "not_initialized";
102
+ if (m.includes("permission denied"))
103
+ return "EACCES";
104
+ }
105
+ return "unknown";
106
+ }
107
+ // ─── rotate + retention ──────────────────────────────────────────────
108
+ async function rotateIfNeeded() {
109
+ const p = errorLogPath();
110
+ try {
111
+ const stat = await fs.stat(p);
112
+ if (stat.size < MAX_BYTES)
113
+ return;
114
+ const archive = join(errorLogDir(), `hook-errors.${Date.now()}.jsonl`);
115
+ await fs.rename(p, archive);
116
+ }
117
+ catch {
118
+ /* missing or stat failure — append will create */
119
+ }
120
+ }
121
+ async function pruneArchives() {
122
+ const dir = errorLogDir();
123
+ let entries;
124
+ try {
125
+ entries = await fs.readdir(dir);
126
+ }
127
+ catch {
128
+ return;
129
+ }
130
+ const cutoff = Date.now() - RETENTION_MS;
131
+ for (const name of entries) {
132
+ const m = name.match(ARCHIVE_RE);
133
+ if (!m)
134
+ continue;
135
+ const ts = Number(m[1]);
136
+ if (!Number.isFinite(ts))
137
+ continue;
138
+ if (ts < cutoff) {
139
+ try {
140
+ await fs.unlink(join(dir, name));
141
+ }
142
+ catch {
143
+ /* best-effort */
144
+ }
145
+ }
146
+ }
147
+ }
148
+ // ─── append ──────────────────────────────────────────────────────────
149
+ export async function appendError(rec) {
150
+ if (isOptedOut())
151
+ return;
152
+ try {
153
+ await fs.mkdir(errorLogDir(), { recursive: true });
154
+ await rotateIfNeeded();
155
+ await fs.appendFile(errorLogPath(), JSON.stringify(rec) + "\n");
156
+ // best-effort retention sweep — not awaited tightly because a slow
157
+ // FS shouldn't slow the hook hot-path; failures are silent.
158
+ pruneArchives().catch(() => { });
159
+ }
160
+ catch {
161
+ /* logger of last resort — never throw */
162
+ }
163
+ }
164
+ export async function loadErrors(opts = {}) {
165
+ const p = opts.path ?? errorLogPath();
166
+ let raw;
167
+ try {
168
+ raw = await fs.readFile(p, "utf-8");
169
+ }
170
+ catch {
171
+ return [];
172
+ }
173
+ const out = [];
174
+ for (const line of raw.split("\n")) {
175
+ if (!line.trim())
176
+ continue;
177
+ try {
178
+ const rec = JSON.parse(line);
179
+ if (opts.code && rec.code !== opts.code)
180
+ continue;
181
+ if (opts.hook && rec.hook !== opts.hook)
182
+ continue;
183
+ if (opts.level && rec.level !== opts.level)
184
+ continue;
185
+ out.push(rec);
186
+ }
187
+ catch {
188
+ /* skip malformed */
189
+ }
190
+ }
191
+ // Newest first — most useful default ordering for a tail view.
192
+ out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
193
+ if (opts.tail && opts.tail > 0) {
194
+ return out.slice(0, opts.tail);
195
+ }
196
+ return out;
197
+ }
198
+ // ─── format ──────────────────────────────────────────────────────────
199
+ export function formatErrorList(records) {
200
+ if (records.length === 0) {
201
+ return "No errors logged.";
202
+ }
203
+ const counts = new Map();
204
+ for (const r of records) {
205
+ counts.set(r.code, (counts.get(r.code) ?? 0) + 1);
206
+ }
207
+ const top = Array.from(counts.entries())
208
+ .sort((a, b) => b[1] - a[1])
209
+ .slice(0, 10);
210
+ const lines = [];
211
+ lines.push(`token-pilot errors — ${records.length} total`);
212
+ lines.push("");
213
+ lines.push("Top codes:");
214
+ for (const [code, n] of top) {
215
+ lines.push(` ${String(n).padStart(4)}× ${code}`);
216
+ }
217
+ lines.push("");
218
+ lines.push("Most recent:");
219
+ const recent = records.slice(0, 20);
220
+ for (const r of recent) {
221
+ const when = new Date(r.ts).toISOString().slice(11, 19);
222
+ lines.push(` [${when}] ${r.level.toUpperCase().padEnd(5)} ${r.hook} ${r.code} — ${r.msg}`);
223
+ }
224
+ return lines.join("\n");
225
+ }
226
+ // ─── exports for indirection from index.ts ───────────────────────────
227
+ export { existsSync, resolve, dirname };
228
+ //# sourceMappingURL=error-log.js.map
@@ -28,7 +28,7 @@ export interface HookEvent {
28
28
  /** null for top-level session; agent_type string inside a subagent. */
29
29
  agent_type: string | null;
30
30
  agent_id: string | null;
31
- event: "denied" | "allowed" | "bypass" | "pass-through" | "task" | string;
31
+ event: "denied" | "allowed" | "bypass" | "pass-through" | "task" | "diagnostic" | string;
32
32
  file: string;
33
33
  lines: number;
34
34
  estTokens: number;
@@ -36,6 +36,18 @@ export interface HookEvent {
36
36
  summaryTokens: number;
37
37
  /** estTokens - summaryTokens; 0 for allow/bypass. */
38
38
  savedTokens: number;
39
+ /** "info" | "warn" | "error" — severity of the diagnostic. */
40
+ level?: "info" | "warn" | "error";
41
+ /** Stable searchable identifier — e.g. `force_subagents_no_agents`. */
42
+ code?: string;
43
+ /** Optional small map of context — must be sanitised by caller. */
44
+ detail?: Record<string, unknown>;
45
+ /**
46
+ * Wall-clock duration of the hook handler that emitted this event,
47
+ * milliseconds. Always optional — only the safe-runner wrapper sets
48
+ * it, and only on the FINAL diagnostic record per hook invocation.
49
+ */
50
+ duration_ms?: number;
39
51
  /** The subagent_type Claude Code dispatched (`tp-*` or `general-purpose`…). */
40
52
  subagent_type?: string;
41
53
  /**
@@ -78,12 +90,48 @@ export declare function retentionDeletions(files: Array<{
78
90
  * here must not break hook dispatch.
79
91
  */
80
92
  export declare function appendEvent(projectRoot: string, event: HookEvent): Promise<void>;
93
+ /**
94
+ * v0.34.0 — convenience wrapper for emitting a `diagnostic` event.
95
+ *
96
+ * Diagnostics describe edge-case branches inside a normal handler
97
+ * run (matcher returned no agents, WSL path rejected, MCP arg
98
+ * coerced, etc.). They live in the project-local hook-events.jsonl
99
+ * alongside the regular events so `stats --diagnostics` can count
100
+ * them by code.
101
+ *
102
+ * If the projectRoot is not yet resolvable (the failure happened
103
+ * before detection), prefer `appendError` from `core/error-log.ts`
104
+ * — it falls back to a user-level path.
105
+ *
106
+ * Pure-ish: never throws. Same best-effort semantics as appendEvent.
107
+ */
108
+ export declare function appendDiagnostic(projectRoot: string, args: {
109
+ code: string;
110
+ level?: "info" | "warn" | "error";
111
+ detail?: Record<string, unknown>;
112
+ sessionId?: string;
113
+ agentType?: string | null;
114
+ agentId?: string | null;
115
+ durationMs?: number;
116
+ }): Promise<void>;
81
117
  /**
82
118
  * Read all events from the current log file. Malformed JSONL lines are
83
119
  * skipped silently (a corrupted line should not poison the whole
84
120
  * dataset). Returns [] if the file is missing.
85
121
  */
86
122
  export declare function loadEvents(projectRoot: string): Promise<HookEvent[]>;
123
+ /**
124
+ * v0.33.0 (B5) — load events from EVERY `.token-pilot/hook-events.jsonl`
125
+ * found at or below `repoRoot`. The hook writer resolves its own
126
+ * project root from `process.cwd()` at the moment Claude Code spawns
127
+ * us, which can land in a subdirectory of the actual repo (apps/admin,
128
+ * apps/api, packages/prisma, …). Without this, `token-pilot stats`
129
+ * sees only the top-level log and reports a fraction of the savings.
130
+ *
131
+ * Walks up to `maxDepth` levels and merges chronologically. Pure on
132
+ * filesystem read errors — missing dirs are silently skipped.
133
+ */
134
+ export declare function loadEventsTree(repoRoot: string, maxDepth?: number): Promise<HookEvent[]>;
87
135
  /**
88
136
  * Apply age + size retention. Safe to call on startup; no-op when the
89
137
  * directory does not exist.
@@ -117,6 +117,40 @@ export async function appendEvent(projectRoot, event) {
117
117
  /* silent — telemetry is best-effort */
118
118
  }
119
119
  }
120
+ /**
121
+ * v0.34.0 — convenience wrapper for emitting a `diagnostic` event.
122
+ *
123
+ * Diagnostics describe edge-case branches inside a normal handler
124
+ * run (matcher returned no agents, WSL path rejected, MCP arg
125
+ * coerced, etc.). They live in the project-local hook-events.jsonl
126
+ * alongside the regular events so `stats --diagnostics` can count
127
+ * them by code.
128
+ *
129
+ * If the projectRoot is not yet resolvable (the failure happened
130
+ * before detection), prefer `appendError` from `core/error-log.ts`
131
+ * — it falls back to a user-level path.
132
+ *
133
+ * Pure-ish: never throws. Same best-effort semantics as appendEvent.
134
+ */
135
+ export async function appendDiagnostic(projectRoot, args) {
136
+ const rec = {
137
+ ts: Date.now(),
138
+ session_id: args.sessionId ?? "diagnostic",
139
+ agent_type: args.agentType ?? null,
140
+ agent_id: args.agentId ?? null,
141
+ event: "diagnostic",
142
+ file: "",
143
+ lines: 0,
144
+ estTokens: 0,
145
+ summaryTokens: 0,
146
+ savedTokens: 0,
147
+ level: args.level ?? "info",
148
+ code: args.code,
149
+ detail: args.detail,
150
+ duration_ms: args.durationMs,
151
+ };
152
+ await appendEvent(projectRoot, rec);
153
+ }
120
154
  /**
121
155
  * Read all events from the current log file. Malformed JSONL lines are
122
156
  * skipped silently (a corrupted line should not poison the whole
@@ -143,6 +177,86 @@ export async function loadEvents(projectRoot) {
143
177
  }
144
178
  return out;
145
179
  }
180
+ /**
181
+ * v0.33.0 (B5) — load events from EVERY `.token-pilot/hook-events.jsonl`
182
+ * found at or below `repoRoot`. The hook writer resolves its own
183
+ * project root from `process.cwd()` at the moment Claude Code spawns
184
+ * us, which can land in a subdirectory of the actual repo (apps/admin,
185
+ * apps/api, packages/prisma, …). Without this, `token-pilot stats`
186
+ * sees only the top-level log and reports a fraction of the savings.
187
+ *
188
+ * Walks up to `maxDepth` levels and merges chronologically. Pure on
189
+ * filesystem read errors — missing dirs are silently skipped.
190
+ */
191
+ export async function loadEventsTree(repoRoot, maxDepth = 5) {
192
+ const PRUNE = new Set([
193
+ "node_modules",
194
+ ".git",
195
+ "dist",
196
+ "build",
197
+ ".next",
198
+ ".turbo",
199
+ ".cache",
200
+ "coverage",
201
+ ".vercel",
202
+ ".vite",
203
+ ]);
204
+ const seen = new Set();
205
+ const all = [];
206
+ async function visit(dir, depth) {
207
+ if (depth > maxDepth)
208
+ return;
209
+ let entries = [];
210
+ try {
211
+ entries = await fs.readdir(dir);
212
+ }
213
+ catch {
214
+ return;
215
+ }
216
+ if (entries.includes(".token-pilot")) {
217
+ const logPath = join(dir, ".token-pilot", CURRENT_FILE);
218
+ if (!seen.has(logPath)) {
219
+ seen.add(logPath);
220
+ try {
221
+ const raw = await fs.readFile(logPath, "utf-8");
222
+ for (const line of raw.split("\n")) {
223
+ if (!line.trim())
224
+ continue;
225
+ try {
226
+ all.push(JSON.parse(line));
227
+ }
228
+ catch {
229
+ /* skip malformed */
230
+ }
231
+ }
232
+ }
233
+ catch {
234
+ /* missing log */
235
+ }
236
+ }
237
+ }
238
+ for (const name of entries) {
239
+ if (PRUNE.has(name))
240
+ continue;
241
+ if (name.startsWith(".") && name !== ".claude")
242
+ continue;
243
+ const full = join(dir, name);
244
+ try {
245
+ const stat = await fs.stat(full);
246
+ if (stat.isDirectory()) {
247
+ await visit(full, depth + 1);
248
+ }
249
+ }
250
+ catch {
251
+ /* skip */
252
+ }
253
+ }
254
+ }
255
+ await visit(repoRoot, 0);
256
+ // Sort chronologically so per-session aggregations stay correct.
257
+ all.sort((a, b) => (a.ts || 0) - (b.ts || 0));
258
+ return all;
259
+ }
146
260
  /**
147
261
  * Enumerate all archive files (`hook-events.<ts>.jsonl`) with metadata
148
262
  * needed by `retentionDeletions`.
@@ -1,3 +1,15 @@
1
+ /**
2
+ * v0.33.0 (B9) — coerce an `unknown` argument value to an integer.
3
+ *
4
+ * MCP transports frequently round-trip numeric arguments through
5
+ * JSON or environment variables and re-emit them as strings (e.g.
6
+ * `"42"`). Accept that case and reject everything else, including
7
+ * non-finite numbers, decimals, and strings that don't parse cleanly.
8
+ *
9
+ * Returns the integer value or `null` when the input cannot be
10
+ * interpreted as one.
11
+ */
12
+ export declare function coerceIntFromAny(value: unknown): number | null;
1
13
  /**
2
14
  * Resolve a user-provided path and validate it stays within projectRoot.
3
15
  * Prevents path traversal attacks (e.g. ../../etc/passwd).
@@ -1,4 +1,34 @@
1
1
  import { resolve, relative } from "node:path";
2
+ /**
3
+ * v0.33.0 (B9) — coerce an `unknown` argument value to an integer.
4
+ *
5
+ * MCP transports frequently round-trip numeric arguments through
6
+ * JSON or environment variables and re-emit them as strings (e.g.
7
+ * `"42"`). Accept that case and reject everything else, including
8
+ * non-finite numbers, decimals, and strings that don't parse cleanly.
9
+ *
10
+ * Returns the integer value or `null` when the input cannot be
11
+ * interpreted as one.
12
+ */
13
+ export function coerceIntFromAny(value) {
14
+ if (typeof value === "number") {
15
+ if (!Number.isFinite(value) || !Number.isInteger(value))
16
+ return null;
17
+ return value;
18
+ }
19
+ if (typeof value === "string") {
20
+ const trimmed = value.trim();
21
+ if (trimmed.length === 0)
22
+ return null;
23
+ if (!/^-?\d+$/.test(trimmed))
24
+ return null;
25
+ const n = Number(trimmed);
26
+ if (!Number.isFinite(n) || !Number.isInteger(n))
27
+ return null;
28
+ return n;
29
+ }
30
+ return null;
31
+ }
2
32
  /**
3
33
  * Resolve a user-provided path and validate it stays within projectRoot.
4
34
  * Prevents path traversal attacks (e.g. ../../etc/passwd).
@@ -110,20 +140,20 @@ export function validateReadRangeArgs(args) {
110
140
  if (typeof a.path !== "string" || a.path.length === 0) {
111
141
  throw new Error('Required parameter "path" must be a non-empty string.');
112
142
  }
113
- if (typeof a.start_line !== "number" ||
114
- !Number.isInteger(a.start_line) ||
115
- a.start_line < 1) {
143
+ // v0.33.0 (B9) some MCP clients serialise numbers as strings;
144
+ // accept "10" the same as 10. Reject non-numeric strings.
145
+ const start = coerceIntFromAny(a.start_line);
146
+ if (start === null || start < 1) {
116
147
  throw new Error('Required parameter "start_line" must be a positive integer.');
117
148
  }
118
- if (typeof a.end_line !== "number" ||
119
- !Number.isInteger(a.end_line) ||
120
- a.end_line < 1) {
149
+ const end = coerceIntFromAny(a.end_line);
150
+ if (end === null || end < 1) {
121
151
  throw new Error('Required parameter "end_line" must be a positive integer.');
122
152
  }
123
- if (a.end_line < a.start_line) {
153
+ if (end < start) {
124
154
  throw new Error('"end_line" must be >= "start_line".');
125
155
  }
126
- return { path: a.path, start_line: a.start_line, end_line: a.end_line };
156
+ return { path: a.path, start_line: start, end_line: end };
127
157
  }
128
158
  /**
129
159
  * Validate read_diff arguments.
@@ -14,14 +14,19 @@ const MAX_COUNT = 50;
14
14
  export async function handleSmartLog(args, projectRoot) {
15
15
  const count = Math.min(args.count ?? 10, MAX_COUNT);
16
16
  const ref = args.ref ?? 'HEAD';
17
- // Build git log command with --numstat for file stats
17
+ // Build git log command with --numstat for file stats.
18
+ // v0.33.0 (B6) — `ref` MUST be a revision argument, not a pathspec.
19
+ // The previous version pushed `'--', ref` which made git interpret
20
+ // `HEAD` as a path (`git log -- HEAD`) and silently returned empty.
21
+ // Adding a path then produced `git log -- HEAD -- foo.ts` — invalid.
22
+ // Correct order: `git log <flags> <ref> [-- <path>]`.
18
23
  const gitArgs = [
19
24
  'log',
20
25
  `--format=${RECORD_SEPARATOR}%h${FIELD_SEPARATOR}%ad${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%s`,
21
26
  '--date=short',
22
27
  '--numstat',
23
28
  `-n`, `${count}`,
24
- '--', ref,
29
+ ref,
25
30
  ];
26
31
  if (args.path) {
27
32
  gitArgs.push('--', args.path);
@@ -14,6 +14,20 @@ export interface HookInstallOptions {
14
14
  /** Absolute path to the node binary. Defaults to process.execPath. */
15
15
  nodeExecPath?: string;
16
16
  }
17
+ /**
18
+ * Detect a stale token-pilot hook command — one that points at a
19
+ * pinned npx-cache snapshot (`npx/_npx/<hash>/...`) or any other
20
+ * version-pinned path that won't follow plugin upgrades.
21
+ *
22
+ * v0.33.0 fix: users who ran `npx token-pilot init` early on got
23
+ * settings.json entries with literal `~/.npm/_npx/<hash>/...` paths.
24
+ * When the npx cache rotates or token-pilot publishes a new minor,
25
+ * those entries silently call the old binary, missing every hook
26
+ * shipped after install (e.g. v0.31.0 Task hooks). Removing the
27
+ * stale entry lets the next install or the bundled plugin's
28
+ * `hooks/hooks.json` (CLAUDE_PLUGIN_ROOT) take over.
29
+ */
30
+ export declare function isStaleTokenPilotHookCommand(cmd: unknown): boolean;
17
31
  /**
18
32
  * Install Token Pilot hook into Claude Code settings.
19
33
  * Creates or updates .claude/settings.json with PreToolUse hook.
@@ -23,4 +37,30 @@ export declare function installHook(projectRoot: string, options?: HookInstallOp
23
37
  * Remove Token Pilot hook from Claude Code settings.
24
38
  */
25
39
  export declare function uninstallHook(projectRoot: string): Promise<HookUninstallResult>;
40
+ export interface CleanStaleResult {
41
+ scanned: string[];
42
+ cleaned: string[];
43
+ staleEntriesRemoved: number;
44
+ message: string;
45
+ }
46
+ /**
47
+ * Scan a settings.json (user-level or project-level) and remove every
48
+ * token-pilot hook entry whose command points at a pinned npx-cache
49
+ * snapshot or a literal plugin-cache version path. The plugin's bundled
50
+ * `hooks/hooks.json` (resolved through `${CLAUDE_PLUGIN_ROOT}` at
51
+ * runtime) supersedes them.
52
+ *
53
+ * Pure-ish: writes only when something changed. Never throws — bad JSON
54
+ * or missing file are reported in the result so callers (CLI, init)
55
+ * can surface them without aborting.
56
+ */
57
+ export declare function cleanStaleHookEntries(settingsPath: string): Promise<CleanStaleResult>;
58
+ /**
59
+ * Inspect `~/.claude/settings.json` to determine whether the user has
60
+ * enabled the bundled `token-pilot` plugin in Claude Code. When true,
61
+ * the plugin's own `hooks/hooks.json` is the source of truth and any
62
+ * additional hook entries written by the npm CLI are duplicates that
63
+ * also lock the user to whichever binary path the CLI captured.
64
+ */
65
+ export declare function isTokenPilotPluginEnabled(homeDir: string): Promise<boolean>;
26
66
  //# sourceMappingURL=installer.d.ts.map