token-pilot 0.10.0 → 0.12.0

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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "token-pilot",
3
3
  "displayName": "Token Pilot",
4
- "description": "Reduces token consumption by 80-95% via AST-aware lazy file reading. 16 MCP tools for structural code reading, symbol navigation, and cross-file search.",
5
- "version": "0.10.0",
4
+ "description": "Reduces token consumption by 80-95% via AST-aware lazy file reading. 18 MCP tools for structural code reading, symbol navigation, and cross-file search.",
5
+ "version": "0.12.0",
6
6
  "author": "Digital-Threads",
7
7
  "repository": "https://github.com/Digital-Threads/token-pilot",
8
8
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-pilot",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Reduces token consumption by 80-95% via AST-aware lazy file reading. Returns structural overviews instead of full files.",
5
5
  "author": "token-pilot",
6
6
  "license": "MIT",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ All notable changes to Token Pilot will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.12.0] - 2026-03-14
9
+
10
+ ### Added
11
+ - **Version check for all components** — on startup, checks token-pilot (npm), ast-index (GitHub releases), and context-mode (npm) in parallel. Non-blocking, fire-and-forget. Shows update notifications in stderr.
12
+ - **`autoUpdate` config flag** — `updates.autoUpdate: true` in `.token-pilot.json` auto-downloads new ast-index binary on startup. Default: `false` (notify only). token-pilot and context-mode only notify (separate processes).
13
+ - **`checkBinaryUpdate()`** — compares installed ast-index version vs latest GitHub release.
14
+ - **`isNewerVersion()` utility** — semver comparison: strip `v` prefix, compare segments. Handles different lengths (`1.0` vs `1.0.1`).
15
+ - **Common Lisp extensions** — `.lisp`, `.lsp`, `.cl`, `.asd` added to `CODE_EXTENSIONS` for ast-index v3.28+ compatibility.
16
+ - **9 new tests** — `isNewerVersion()` covering major/minor/patch, same version, older, `v` prefix, different segment lengths, large numbers, real-world versions. Total: 217 tests.
17
+
18
+ ### Changed
19
+ - **`doctor` command** — now shows 3 sections: token-pilot (installed/latest), ast-index (installed/latest/auto-update status), context-mode (detected/latest npm). Previously only showed ast-index binary status.
20
+ - **`install-ast-index` command** — now also updates existing binary if newer version available on GitHub.
21
+ - **`printHelp()`** — fixed tool count: 18 (was incorrectly showing 12 since v0.8.0).
22
+ - **Startup update check** — replaced single `checkLatestVersion()` with `checkAllUpdates()` covering all 3 components via `Promise.allSettled`.
23
+
24
+ ### Fixed
25
+ - **`test_summary` PHPUnit parser** — now counts both `Failures:` and `Errors:` (was only counting failures).
26
+ - **`test_summary` cargo parser** — correctly identifies failure name-list section (no `----` markers) vs detail section.
27
+ - **`test_summary` token estimation** — uses shared `estimateTokens()` instead of local duplicate.
28
+ - **`smart_log` category detection** — `documentation` now matches docs pattern, `tests` (plural) matches test pattern, `optimize`/`optimization` match perf pattern.
29
+ - **`explore_area` path boundary** — `startsWith(path + '/')` prevents `src/auth` matching `src/authorize/`.
30
+ - **Validation consistency** — `validateSmartLogArgs` and `validateTestSummaryArgs` now use `optionalString`/`optionalNumber` helpers, reject empty strings, check integers.
31
+
32
+ ## [0.11.0] - 2026-03-14
33
+
34
+ ### Added
35
+ - **`smart_log` tool** — structured git log with commit category detection (feat/fix/refactor/docs/test/chore/style/perf). Shows author breakdown, file stats (+/-), per-commit file list. Filters by path and ref. Raw git log → compact summary.
36
+ - **`test_summary` tool** — runs test command and returns structured summary: total/passed/failed/skipped + failure details. Parsers for vitest, jest, pytest, phpunit, go test, cargo test, rspec, mocha + generic fallback. 200 lines of raw output → 10-15 lines.
37
+ - **38 new tests** — smart_log parser (5), categorizer (4), test_summary parsers (17), runner detection (8), validation (4). Total: 208 tests (was 170).
38
+
39
+ ### Changed
40
+ - **18 tools** (was 16) — added `smart_log`, `test_summary`
41
+ - **MCP instructions** — added smart_log and test_summary to workflow guidance
42
+
8
43
  ## [0.10.0] - 2026-03-14
9
44
 
10
45
  ### Added
package/README.md CHANGED
@@ -175,6 +175,8 @@ For more control, you can add rules to your project:
175
175
  | `module_info` | manual analysis | Module dependency analysis: dependencies, dependents, public API, unused deps. Use for architecture understanding and dependency cleanup. |
176
176
  | `smart_diff` | raw `git diff` | Structural diff with AST symbol mapping — shows which functions/classes changed instead of raw patch. Scopes: unstaged, staged, commit, branch. |
177
177
  | `explore_area` | outline + related_files + git log | One-call directory exploration: structure, imports, tests, recent changes. Replaces 3-5 separate calls. |
178
+ | `smart_log` | raw `git log` | Structured commit history with category detection (feat/fix/refactor/docs), file stats, author breakdown. Filters by path and ref. |
179
+ | `test_summary` | raw test output | Run tests and get structured summary: total/passed/failed + failure details. Supports vitest, jest, pytest, phpunit, go, cargo, rspec, mocha. |
178
180
 
179
181
  ### Analytics
180
182
 
@@ -355,6 +357,8 @@ src/
355
357
  module-info.ts — module_info handler (deps, dependents, API, unused)
356
358
  smart-diff.ts — smart_diff handler (structural git diff + symbol mapping)
357
359
  explore-area.ts — explore_area handler (outline + imports + tests + changes)
360
+ smart-log.ts — smart_log handler (structured git log + category detection)
361
+ test-summary.ts — test_summary handler (run tests + parse output)
358
362
  non-code.ts — JSON/YAML/MD/TOML structural summaries
359
363
  export-ast-index.ts — AST export for context-mode BM25
360
364
  git/
@@ -12,4 +12,17 @@ export declare function findBinary(configPath?: string | null): Promise<BinarySt
12
12
  * Download and install ast-index binary from GitHub releases.
13
13
  */
14
14
  export declare function installBinary(onProgress?: (msg: string) => void): Promise<BinaryStatus>;
15
+ /**
16
+ * Check if a newer version of ast-index is available on GitHub.
17
+ * Non-blocking, returns null values on any error.
18
+ */
19
+ export declare function checkBinaryUpdate(currentPath: string | null): Promise<{
20
+ current: string | null;
21
+ latest: string | null;
22
+ updateAvailable: boolean;
23
+ }>;
24
+ /**
25
+ * Compare two semver strings. Returns true if `latest` is newer than `current`.
26
+ */
27
+ export declare function isNewerVersion(current: string, latest: string): boolean;
15
28
  //# sourceMappingURL=binary-manager.d.ts.map
@@ -86,6 +86,49 @@ export async function installBinary(onProgress) {
86
86
  throw err;
87
87
  }
88
88
  }
89
+ /**
90
+ * Check if a newer version of ast-index is available on GitHub.
91
+ * Non-blocking, returns null values on any error.
92
+ */
93
+ export async function checkBinaryUpdate(currentPath) {
94
+ if (!currentPath) {
95
+ return { current: null, latest: null, updateAvailable: false };
96
+ }
97
+ try {
98
+ const [current, release] = await Promise.all([
99
+ getBinaryVersion(currentPath),
100
+ fetchLatestRelease(),
101
+ ]);
102
+ const latest = release.tag.replace(/^v/, '');
103
+ if (!current) {
104
+ return { current: null, latest, updateAvailable: false };
105
+ }
106
+ return {
107
+ current,
108
+ latest,
109
+ updateAvailable: isNewerVersion(current, latest),
110
+ };
111
+ }
112
+ catch {
113
+ return { current: null, latest: null, updateAvailable: false };
114
+ }
115
+ }
116
+ /**
117
+ * Compare two semver strings. Returns true if `latest` is newer than `current`.
118
+ */
119
+ export function isNewerVersion(current, latest) {
120
+ const c = current.replace(/^v/, '').split('.').map(Number);
121
+ const l = latest.replace(/^v/, '').split('.').map(Number);
122
+ for (let i = 0; i < Math.max(c.length, l.length); i++) {
123
+ const cv = c[i] ?? 0;
124
+ const lv = l[i] ?? 0;
125
+ if (lv > cv)
126
+ return true;
127
+ if (lv < cv)
128
+ return false;
129
+ }
130
+ return false;
131
+ }
89
132
  // --- Internal helpers ---
90
133
  function getPlatform() {
91
134
  switch (platform()) {
@@ -38,6 +38,10 @@ export const DEFAULT_CONFIG = {
38
38
  adviseDelegation: true,
39
39
  largeNonCodeThreshold: 200,
40
40
  },
41
+ updates: {
42
+ checkOnStartup: true,
43
+ autoUpdate: false,
44
+ },
41
45
  ignore: [
42
46
  'node_modules/**',
43
47
  'dist/**',
@@ -127,6 +127,18 @@ export interface ExploreAreaArgs {
127
127
  include?: Array<'outline' | 'imports' | 'tests' | 'changes'>;
128
128
  }
129
129
  export declare function validateExploreAreaArgs(args: unknown): ExploreAreaArgs;
130
+ export interface SmartLogArgs {
131
+ path?: string;
132
+ count?: number;
133
+ ref?: string;
134
+ }
135
+ export declare function validateSmartLogArgs(args: unknown): SmartLogArgs;
136
+ export interface TestSummaryArgs {
137
+ command: string;
138
+ runner?: string;
139
+ timeout?: number;
140
+ }
141
+ export declare function validateTestSummaryArgs(args: unknown): TestSummaryArgs;
130
142
  /** Detect roots that would cause ast-index to scan the entire filesystem */
131
143
  export declare function isDangerousRoot(root: string): boolean;
132
144
  //# sourceMappingURL=validation.d.ts.map
@@ -337,6 +337,50 @@ export function validateExploreAreaArgs(args) {
337
337
  }
338
338
  return { path: a.path };
339
339
  }
340
+ export function validateSmartLogArgs(args) {
341
+ if (!args || typeof args !== 'object')
342
+ return {};
343
+ const a = args;
344
+ const path = optionalString(a.path, 'path');
345
+ if (path !== undefined && path.length === 0) {
346
+ throw new Error('"path" must be a non-empty string.');
347
+ }
348
+ const count = optionalNumber(a.count, 'count');
349
+ if (count !== undefined) {
350
+ if (!Number.isInteger(count) || count < 1 || count > 50) {
351
+ throw new Error('"count" must be an integer between 1 and 50.');
352
+ }
353
+ }
354
+ const ref = optionalString(a.ref, 'ref');
355
+ if (ref !== undefined && ref.length === 0) {
356
+ throw new Error('"ref" must be a non-empty string.');
357
+ }
358
+ return { path, count, ref };
359
+ }
360
+ const VALID_RUNNERS = ['vitest', 'jest', 'pytest', 'phpunit', 'go', 'cargo', 'rspec', 'mocha'];
361
+ export function validateTestSummaryArgs(args) {
362
+ if (!args || typeof args !== 'object') {
363
+ throw new Error('Arguments must be an object with a "command" parameter.');
364
+ }
365
+ const a = args;
366
+ if (typeof a.command !== 'string' || a.command.length === 0) {
367
+ throw new Error('Required parameter "command" must be a non-empty string.');
368
+ }
369
+ let runner;
370
+ if (a.runner !== undefined && a.runner !== null) {
371
+ if (typeof a.runner !== 'string' || !VALID_RUNNERS.includes(a.runner)) {
372
+ throw new Error(`"runner" must be one of: ${VALID_RUNNERS.join(', ')}`);
373
+ }
374
+ runner = a.runner;
375
+ }
376
+ const timeout = optionalNumber(a.timeout, 'timeout');
377
+ if (timeout !== undefined) {
378
+ if (!Number.isInteger(timeout) || timeout < 1000 || timeout > 300000) {
379
+ throw new Error('"timeout" must be an integer between 1000 and 300000 (ms).');
380
+ }
381
+ }
382
+ return { command: a.command, runner, timeout };
383
+ }
340
384
  /** Detect roots that would cause ast-index to scan the entire filesystem */
341
385
  export function isDangerousRoot(root) {
342
386
  const normalized = root.replace(/\/+$/, '') || '/';
@@ -102,7 +102,7 @@ async function buildImportsSection(codeFiles, absPath, projectRoot, astIndex) {
102
102
  if (source.startsWith('.') || source.startsWith('/')) {
103
103
  // Internal import — track if it's outside this area
104
104
  const resolved = resolve(absPath, source);
105
- if (!resolved.startsWith(absPath)) {
105
+ if (!resolved.startsWith(absPath + '/') && resolved !== absPath) {
106
106
  const relImport = relative(projectRoot, resolved).replace(/\.[^.]+$/, '');
107
107
  internalDeps.add(relImport);
108
108
  }
@@ -129,7 +129,7 @@ async function buildImportsSection(codeFiles, absPath, projectRoot, astIndex) {
129
129
  continue;
130
130
  const relFile = relative(projectRoot, impFile);
131
131
  // Only include files outside this area
132
- if (!relFile.startsWith(relDir)) {
132
+ if (!relFile.startsWith(relDir + '/') && relFile !== relDir) {
133
133
  importedBy.add(relFile.replace(/\.[^.]+$/, ''));
134
134
  }
135
135
  }
@@ -6,6 +6,7 @@ export const CODE_EXTENSIONS = new Set([
6
6
  'swift', 'cs', 'cpp', 'cc', 'cxx', 'hpp', 'c', 'h', 'php', 'rb', 'scala',
7
7
  'dart', 'lua', 'sh', 'bash', 'sql', 'r', 'vue', 'svelte', 'pl', 'pm',
8
8
  'ex', 'exs', 'groovy', 'm', 'proto', 'bsl',
9
+ 'lisp', 'lsp', 'cl', 'asd',
9
10
  ]);
10
11
  // Standard HTTP verbs — protocol-level, not framework-specific
11
12
  const HTTP_VERBS = ['Get', 'Post', 'Put', 'Delete', 'Patch', 'Head', 'Options', 'All'];
@@ -11,7 +11,7 @@ const MAX_FILES = 50;
11
11
  const MAX_OUTPUT_LINES = 500;
12
12
  export async function handleSmartDiff(args, projectRoot, astIndex) {
13
13
  // 1. Build git command
14
- const gitArgs = buildGitArgs(args, projectRoot);
14
+ const gitArgs = buildGitArgs(args);
15
15
  // 2. Execute git diff
16
16
  let rawDiff;
17
17
  try {
@@ -71,21 +71,21 @@ export async function handleSmartDiff(args, projectRoot, astIndex) {
71
71
  // ──────────────────────────────────────────────
72
72
  // Git command builder
73
73
  // ──────────────────────────────────────────────
74
- function buildGitArgs(args, projectRoot) {
74
+ function buildGitArgs(args) {
75
75
  const base = [];
76
76
  switch (args.scope) {
77
77
  case 'staged':
78
- base.push('diff', '--cached');
78
+ base.push('diff', '--cached', '--no-color');
79
79
  break;
80
80
  case 'commit':
81
- base.push('show', '--format=', args.ref);
81
+ base.push('show', '--format=', '--no-color', args.ref);
82
82
  break;
83
83
  case 'branch':
84
- base.push('diff', `${args.ref}...HEAD`);
84
+ base.push('diff', '--no-color', `${args.ref}...HEAD`);
85
85
  break;
86
86
  case 'unstaged':
87
87
  default:
88
- base.push('diff');
88
+ base.push('diff', '--no-color');
89
89
  break;
90
90
  }
91
91
  if (args.path) {
@@ -182,16 +182,27 @@ function flattenSymbols(symbols, prefix = '') {
182
182
  export function mapHunksToSymbols(hunks, structure) {
183
183
  const allSymbols = flattenSymbols(structure.symbols);
184
184
  const changedSymbols = new Map();
185
+ // Classify hunks: all-added, all-removed, or mixed
186
+ const hasAdded = hunks.some(h => h.lines.some(l => l.startsWith('+')));
187
+ const hasRemoved = hunks.some(h => h.lines.some(l => l.startsWith('-')));
185
188
  for (const hunk of hunks) {
186
189
  const hunkStart = hunk.newStart;
187
- const hunkEnd = hunk.newStart + hunk.newCount - 1;
190
+ const hunkEnd = hunk.newCount > 0
191
+ ? hunk.newStart + hunk.newCount - 1
192
+ : hunk.newStart; // pure deletion: use newStart as point
188
193
  for (const sym of allSymbols) {
189
194
  if (hunkStart <= sym.end && hunkEnd >= sym.start) {
190
195
  if (!changedSymbols.has(sym.name)) {
196
+ // Determine changeType from hunk content
197
+ let changeType = 'MODIFIED';
198
+ if (hasAdded && !hasRemoved)
199
+ changeType = 'ADDED';
200
+ else if (hasRemoved && !hasAdded)
201
+ changeType = 'REMOVED';
191
202
  changedSymbols.set(sym.name, {
192
203
  name: sym.name,
193
204
  kind: sym.kind,
194
- changeType: 'MODIFIED',
205
+ changeType,
195
206
  lineRange: `[L${sym.start}-${sym.end}]`,
196
207
  });
197
208
  }
@@ -228,7 +239,8 @@ function formatSmartDiff(allFiles, processedFiles, symbolChanges, args, rawToken
228
239
  const symbols = symbolChanges.get(fd.path);
229
240
  if (symbols && symbols.length > 0) {
230
241
  for (const sc of symbols) {
231
- lines.push(` ${sc.changeType}: ${sc.name}() ${sc.lineRange}`);
242
+ const parens = ['function', 'method'].includes(sc.kind) ? '()' : '';
243
+ lines.push(` ${sc.changeType}: ${sc.name}${parens} ${sc.lineRange}`);
232
244
  }
233
245
  }
234
246
  // Small diff: include actual hunks
@@ -0,0 +1,21 @@
1
+ import type { SmartLogArgs } from '../core/validation.js';
2
+ export interface LogEntry {
3
+ hash: string;
4
+ date: string;
5
+ author: string;
6
+ message: string;
7
+ category: 'feat' | 'fix' | 'refactor' | 'docs' | 'test' | 'chore' | 'style' | 'perf' | 'other';
8
+ files: string[];
9
+ insertions: number;
10
+ deletions: number;
11
+ }
12
+ export declare function handleSmartLog(args: SmartLogArgs, projectRoot: string): Promise<{
13
+ content: Array<{
14
+ type: 'text';
15
+ text: string;
16
+ }>;
17
+ rawTokens: number;
18
+ }>;
19
+ export declare function parseGitLog(raw: string): LogEntry[];
20
+ export declare function categorizeCommit(message: string): LogEntry['category'];
21
+ //# sourceMappingURL=smart-log.d.ts.map
@@ -0,0 +1,200 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { estimateTokens } from '../core/token-estimator.js';
4
+ const execFileAsync = promisify(execFile);
5
+ // ──────────────────────────────────────────────
6
+ // Constants
7
+ // ──────────────────────────────────────────────
8
+ const RECORD_SEPARATOR = '<<<SEP>>>';
9
+ const FIELD_SEPARATOR = '<<<F>>>';
10
+ const MAX_COUNT = 50;
11
+ // ──────────────────────────────────────────────
12
+ // Handler
13
+ // ──────────────────────────────────────────────
14
+ export async function handleSmartLog(args, projectRoot) {
15
+ const count = Math.min(args.count ?? 10, MAX_COUNT);
16
+ const ref = args.ref ?? 'HEAD';
17
+ // Build git log command with --numstat for file stats
18
+ const gitArgs = [
19
+ 'log',
20
+ `--format=${RECORD_SEPARATOR}%h${FIELD_SEPARATOR}%ad${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%s`,
21
+ '--date=short',
22
+ '--numstat',
23
+ `-n`, `${count}`,
24
+ '--', ref,
25
+ ];
26
+ if (args.path) {
27
+ gitArgs.push('--', args.path);
28
+ }
29
+ let rawOutput;
30
+ try {
31
+ const { stdout } = await execFileAsync('git', gitArgs, {
32
+ cwd: projectRoot,
33
+ timeout: 10000,
34
+ maxBuffer: 5 * 1024 * 1024,
35
+ });
36
+ rawOutput = stdout;
37
+ }
38
+ catch (err) {
39
+ const msg = err instanceof Error ? err.message : String(err);
40
+ return {
41
+ content: [{ type: 'text', text: `git log failed: ${msg}` }],
42
+ rawTokens: 0,
43
+ };
44
+ }
45
+ if (!rawOutput.trim()) {
46
+ return {
47
+ content: [{ type: 'text', text: 'No commits found.' }],
48
+ rawTokens: 0,
49
+ };
50
+ }
51
+ const rawTokens = estimateTokens(rawOutput);
52
+ const entries = parseGitLog(rawOutput);
53
+ const formatted = formatSmartLog(entries, args.path);
54
+ return {
55
+ content: [{ type: 'text', text: formatted }],
56
+ rawTokens,
57
+ };
58
+ }
59
+ // ──────────────────────────────────────────────
60
+ // Parser
61
+ // ──────────────────────────────────────────────
62
+ export function parseGitLog(raw) {
63
+ const records = raw.split(RECORD_SEPARATOR).filter(r => r.trim());
64
+ const entries = [];
65
+ for (const record of records) {
66
+ const lines = record.trim().split('\n');
67
+ if (lines.length === 0)
68
+ continue;
69
+ const headerLine = lines[0];
70
+ const fields = headerLine.split(FIELD_SEPARATOR);
71
+ if (fields.length < 4)
72
+ continue;
73
+ const [hash, date, author, message] = fields;
74
+ // Parse numstat lines (insertions\tdeletions\tfile)
75
+ const files = [];
76
+ let insertions = 0;
77
+ let deletions = 0;
78
+ for (let i = 1; i < lines.length; i++) {
79
+ const line = lines[i].trim();
80
+ if (!line)
81
+ continue;
82
+ const parts = line.split('\t');
83
+ if (parts.length >= 3) {
84
+ const ins = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
85
+ const del = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
86
+ insertions += ins;
87
+ deletions += del;
88
+ files.push(parts.slice(2).join('\t'));
89
+ }
90
+ }
91
+ entries.push({
92
+ hash,
93
+ date,
94
+ author,
95
+ message,
96
+ category: categorizeCommit(message),
97
+ files,
98
+ insertions,
99
+ deletions,
100
+ });
101
+ }
102
+ return entries;
103
+ }
104
+ // ──────────────────────────────────────────────
105
+ // Categorizer
106
+ // ──────────────────────────────────────────────
107
+ export function categorizeCommit(message) {
108
+ const lower = message.toLowerCase();
109
+ // Conventional commits prefix
110
+ if (/^feat[:(!\s]/.test(lower))
111
+ return 'feat';
112
+ if (/^fix[:(!\s]/.test(lower))
113
+ return 'fix';
114
+ if (/^refactor[:(!\s]/.test(lower))
115
+ return 'refactor';
116
+ if (/^docs?[:(!\s]/.test(lower))
117
+ return 'docs';
118
+ if (/^tests?[:(!\s]/.test(lower))
119
+ return 'test';
120
+ if (/^chore[:(!\s]/.test(lower))
121
+ return 'chore';
122
+ if (/^style[:(!\s]/.test(lower))
123
+ return 'style';
124
+ if (/^perf[:(!\s]/.test(lower))
125
+ return 'perf';
126
+ // Version bumps
127
+ if (/^v?\d+\.\d+/.test(lower))
128
+ return 'feat';
129
+ // Keyword heuristics with word boundaries (order matters — more specific first)
130
+ if (/\b(fix|bug|hotfix)\b/.test(lower) || /\bpatch\b/.test(lower))
131
+ return 'fix';
132
+ if (/\b(tests?|specs?|coverage)\b/.test(lower))
133
+ return 'test';
134
+ if (/\b(refactor|restructure|rename|extract)\b/.test(lower) || /\bmove\b/.test(lower))
135
+ return 'refactor';
136
+ if (/\b(docs?|documentation|readme|changelog)\b/.test(lower))
137
+ return 'docs';
138
+ if (/\b(add|new|implement|feature)\b/.test(lower))
139
+ return 'feat';
140
+ if (/\b(style|format|lint)\b/.test(lower))
141
+ return 'style';
142
+ if (/\b(perf|optimiz\w*|speed|faster?)\b/.test(lower))
143
+ return 'perf';
144
+ if (/\b(chore|bump|deps)\b/.test(lower) || /\bci\b/.test(lower) || /\bbuild\b/.test(lower))
145
+ return 'chore';
146
+ return 'other';
147
+ }
148
+ // ──────────────────────────────────────────────
149
+ // Formatter
150
+ // ──────────────────────────────────────────────
151
+ function formatSmartLog(entries, pathFilter) {
152
+ if (entries.length === 0)
153
+ return 'No commits found.';
154
+ const lines = [];
155
+ // Header
156
+ const filterInfo = pathFilter ? ` (filtered: ${pathFilter})` : '';
157
+ lines.push(`GIT LOG: ${entries.length} commits${filterInfo}`);
158
+ lines.push('');
159
+ // Summary stats
160
+ const authors = new Map();
161
+ const categories = new Map();
162
+ let totalIns = 0;
163
+ let totalDel = 0;
164
+ for (const e of entries) {
165
+ authors.set(e.author, (authors.get(e.author) ?? 0) + 1);
166
+ categories.set(e.category, (categories.get(e.category) ?? 0) + 1);
167
+ totalIns += e.insertions;
168
+ totalDel += e.deletions;
169
+ }
170
+ // Category summary
171
+ const catParts = [];
172
+ for (const [cat, count] of Array.from(categories.entries()).sort((a, b) => b[1] - a[1])) {
173
+ catParts.push(`${cat}:${count}`);
174
+ }
175
+ lines.push(`BREAKDOWN: ${catParts.join(', ')} | +${totalIns}/-${totalDel} lines`);
176
+ // Authors
177
+ const authorParts = Array.from(authors.entries())
178
+ .sort((a, b) => b[1] - a[1])
179
+ .slice(0, 5)
180
+ .map(([name, cnt]) => authors.size > 1 ? `${name} (${cnt})` : name);
181
+ lines.push(`AUTHORS: ${authorParts.join(', ')}`);
182
+ lines.push('');
183
+ // Entries
184
+ for (const e of entries) {
185
+ const filesSummary = e.files.length <= 3
186
+ ? e.files.join(', ')
187
+ : `${e.files.slice(0, 3).join(', ')} +${e.files.length - 3} more`;
188
+ const stats = e.insertions + e.deletions > 0
189
+ ? ` (+${e.insertions}/-${e.deletions})`
190
+ : '';
191
+ lines.push(`${e.hash} ${e.date} [${e.category}] ${e.message}${stats}`);
192
+ if (e.files.length > 0) {
193
+ lines.push(` → ${filesSummary}`);
194
+ }
195
+ }
196
+ lines.push('');
197
+ lines.push('HINT: Use smart_diff(scope="commit", ref="<hash>") to see structural changes for a specific commit.');
198
+ return lines.join('\n');
199
+ }
200
+ //# sourceMappingURL=smart-log.js.map
@@ -0,0 +1,25 @@
1
+ import type { TestSummaryArgs } from '../core/validation.js';
2
+ export interface TestResult {
3
+ total: number;
4
+ passed: number;
5
+ failed: number;
6
+ skipped: number;
7
+ duration?: string;
8
+ failures: FailedTest[];
9
+ suites?: number;
10
+ }
11
+ export interface FailedTest {
12
+ name: string;
13
+ file?: string;
14
+ error: string;
15
+ }
16
+ export declare function handleTestSummary(args: TestSummaryArgs, projectRoot: string): Promise<{
17
+ content: Array<{
18
+ type: 'text';
19
+ text: string;
20
+ }>;
21
+ rawTokens: number;
22
+ }>;
23
+ export declare function detectRunner(command: string, output: string): string;
24
+ export declare function parseTestOutput(output: string, runner: string): TestResult;
25
+ //# sourceMappingURL=test-summary.d.ts.map
@@ -0,0 +1,321 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { estimateTokens } from '../core/token-estimator.js';
4
+ const execFileAsync = promisify(execFile);
5
+ // ──────────────────────────────────────────────
6
+ // Handler
7
+ // ──────────────────────────────────────────────
8
+ export async function handleTestSummary(args, projectRoot) {
9
+ const command = args.command;
10
+ const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [command];
11
+ const bin = parts[0];
12
+ const binArgs = parts.slice(1).map(a => a.replace(/^"|"$/g, ''));
13
+ let rawOutput;
14
+ let exitCode = 0;
15
+ try {
16
+ const { stdout, stderr } = await execFileAsync(bin, binArgs, {
17
+ cwd: projectRoot,
18
+ timeout: args.timeout ?? 60000,
19
+ maxBuffer: 10 * 1024 * 1024,
20
+ env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', CI: '1' },
21
+ });
22
+ rawOutput = stdout + '\n' + stderr;
23
+ }
24
+ catch (err) {
25
+ // Test runners exit with non-zero when tests fail — that's expected
26
+ const execErr = err;
27
+ rawOutput = (execErr.stdout ?? '') + '\n' + (execErr.stderr ?? '');
28
+ exitCode = execErr.code ?? 1;
29
+ // If no output at all, it's a real error
30
+ if (!rawOutput.trim()) {
31
+ return {
32
+ content: [{ type: 'text', text: `Command failed: ${command}\n${err instanceof Error ? err.message : String(err)}` }],
33
+ rawTokens: 0,
34
+ };
35
+ }
36
+ }
37
+ const rawTokens = estimateTokens(rawOutput);
38
+ const runner = args.runner ?? detectRunner(command, rawOutput);
39
+ const result = parseTestOutput(rawOutput, runner);
40
+ const formatted = formatTestSummary(result, command, runner, rawTokens);
41
+ return {
42
+ content: [{ type: 'text', text: formatted }],
43
+ rawTokens,
44
+ };
45
+ }
46
+ // ──────────────────────────────────────────────
47
+ // Runner detection
48
+ // ──────────────────────────────────────────────
49
+ export function detectRunner(command, output) {
50
+ const cmd = command.toLowerCase();
51
+ if (cmd.includes('vitest'))
52
+ return 'vitest';
53
+ if (cmd.includes('jest'))
54
+ return 'jest';
55
+ if (cmd.includes('pytest') || cmd.includes('python -m pytest'))
56
+ return 'pytest';
57
+ if (cmd.includes('phpunit'))
58
+ return 'phpunit';
59
+ if (cmd.includes('cargo test'))
60
+ return 'cargo';
61
+ if (cmd.includes('go test'))
62
+ return 'go';
63
+ if (cmd.includes('rspec'))
64
+ return 'rspec';
65
+ if (cmd.includes('mocha'))
66
+ return 'mocha';
67
+ // Detect from output
68
+ const lower = output.toLowerCase();
69
+ if (lower.includes('vitest') || lower.includes('vite'))
70
+ return 'vitest';
71
+ if (lower.includes('jest'))
72
+ return 'jest';
73
+ if (lower.includes('pytest') || (lower.includes('=== ') && lower.includes(' passed')))
74
+ return 'pytest';
75
+ if (lower.includes('phpunit'))
76
+ return 'phpunit';
77
+ if (lower.includes('--- fail:') || lower.includes('--- pass:') || lower.includes('ok \t'))
78
+ return 'go';
79
+ return 'generic';
80
+ }
81
+ // ──────────────────────────────────────────────
82
+ // Parsers
83
+ // ──────────────────────────────────────────────
84
+ export function parseTestOutput(output, runner) {
85
+ switch (runner) {
86
+ case 'vitest':
87
+ case 'jest':
88
+ return parseVitestJest(output);
89
+ case 'pytest':
90
+ return parsePytest(output);
91
+ case 'phpunit':
92
+ return parsePhpunit(output);
93
+ case 'go':
94
+ return parseGoTest(output);
95
+ case 'cargo':
96
+ return parseCargoTest(output);
97
+ default:
98
+ return parseGeneric(output);
99
+ }
100
+ }
101
+ function parseVitestJest(output) {
102
+ const result = { total: 0, passed: 0, failed: 0, skipped: 0, failures: [] };
103
+ // Test Files 12 passed (12) OR Tests 170 passed (170) OR Tests 3 failed (3)
104
+ const testsLine = output.match(/Tests?\s+(?:(\d+)\s+failed\s*\|?\s*)?(?:(\d+)\s+passed\s*)?(?:\|?\s*(\d+)\s+skipped)?\s*\((\d+)\)/);
105
+ if (testsLine) {
106
+ result.failed = parseInt(testsLine[1] ?? '0', 10);
107
+ result.passed = parseInt(testsLine[2] ?? '0', 10);
108
+ result.skipped = parseInt(testsLine[3] ?? '0', 10);
109
+ result.total = parseInt(testsLine[4], 10);
110
+ }
111
+ // Test Files count
112
+ const suitesLine = output.match(/Test Files\s+(?:\d+\s+failed\s*\|?\s*)?(\d+)\s+passed\s*\((\d+)\)/);
113
+ if (suitesLine) {
114
+ result.suites = parseInt(suitesLine[2], 10);
115
+ }
116
+ // Duration
117
+ const duration = output.match(/Duration\s+([\d.]+\w?\s*(?:\([^)]+\))?)/);
118
+ if (duration) {
119
+ result.duration = duration[1].trim();
120
+ }
121
+ // Parse failures
122
+ // FAIL tests/foo.test.ts > describe > test name
123
+ const failBlocks = output.split(/(?:FAIL|✕|×)\s+/).slice(1);
124
+ for (const block of failBlocks.slice(0, 10)) {
125
+ const lines = block.split('\n');
126
+ const firstLine = lines[0]?.trim() ?? '';
127
+ const errorLines = [];
128
+ for (let i = 1; i < Math.min(lines.length, 8); i++) {
129
+ const line = lines[i]?.trim();
130
+ if (!line)
131
+ continue;
132
+ if (line.startsWith('at ') || line.startsWith('❯'))
133
+ continue;
134
+ if (line.startsWith('⎯') || line.startsWith('─'))
135
+ break;
136
+ errorLines.push(line);
137
+ }
138
+ if (firstLine) {
139
+ result.failures.push({
140
+ name: firstLine.substring(0, 200),
141
+ error: errorLines.join('\n').substring(0, 300),
142
+ });
143
+ }
144
+ }
145
+ return result;
146
+ }
147
+ function parsePytest(output) {
148
+ const result = { total: 0, passed: 0, failed: 0, skipped: 0, failures: [] };
149
+ // === 5 passed, 1 failed, 2 skipped in 1.23s ===
150
+ const summary = output.match(/=+\s*(.*?)\s*=+\s*$/m);
151
+ if (summary) {
152
+ const parts = summary[1];
153
+ const passed = parts.match(/(\d+)\s+passed/);
154
+ const failed = parts.match(/(\d+)\s+failed/);
155
+ const skipped = parts.match(/(\d+)\s+skipped/);
156
+ const duration = parts.match(/in\s+([\d.]+s)/);
157
+ result.passed = parseInt(passed?.[1] ?? '0', 10);
158
+ result.failed = parseInt(failed?.[1] ?? '0', 10);
159
+ result.skipped = parseInt(skipped?.[1] ?? '0', 10);
160
+ result.total = result.passed + result.failed + result.skipped;
161
+ if (duration)
162
+ result.duration = duration[1];
163
+ }
164
+ // FAILED tests/test_foo.py::test_bar - AssertionError
165
+ const failedPattern = /^FAILED\s+(\S+)\s*-?\s*(.*)/gm;
166
+ let match;
167
+ while ((match = failedPattern.exec(output)) !== null) {
168
+ const [, name, error] = match;
169
+ result.failures.push({
170
+ name: name.substring(0, 200),
171
+ error: (error || '').substring(0, 300),
172
+ });
173
+ }
174
+ return result;
175
+ }
176
+ function parsePhpunit(output) {
177
+ const result = { total: 0, passed: 0, failed: 0, skipped: 0, failures: [] };
178
+ // OK (5 tests, 10 assertions) or FAILURES! Tests: 5, Assertions: 10, Failures: 2, Errors: 1
179
+ const ok = output.match(/OK\s*\((\d+)\s+test/);
180
+ if (ok) {
181
+ result.total = parseInt(ok[1], 10);
182
+ result.passed = result.total;
183
+ }
184
+ const failures = output.match(/Tests:\s*(\d+).*?Failures:\s*(\d+)/);
185
+ if (failures) {
186
+ result.total = parseInt(failures[1], 10);
187
+ result.failed = parseInt(failures[2], 10);
188
+ // PHPUnit also reports Errors separately from Failures
189
+ const errors = output.match(/Errors:\s*(\d+)/);
190
+ if (errors) {
191
+ result.failed += parseInt(errors[1], 10);
192
+ }
193
+ result.passed = result.total - result.failed - result.skipped;
194
+ }
195
+ const duration = output.match(/Time:\s*([\d.:]+\s*\w*)/);
196
+ if (duration)
197
+ result.duration = duration[1].trim();
198
+ // 1) TestClass::testMethod
199
+ const failPattern = /^\d+\)\s+(\S+::\S+)/gm;
200
+ let match;
201
+ while ((match = failPattern.exec(output)) !== null) {
202
+ result.failures.push({
203
+ name: match[1].substring(0, 200),
204
+ error: '',
205
+ });
206
+ }
207
+ return result;
208
+ }
209
+ function parseGoTest(output) {
210
+ const result = { total: 0, passed: 0, failed: 0, skipped: 0, failures: [] };
211
+ const passLines = output.match(/^---\s+PASS:/gm);
212
+ const failLines = output.match(/^---\s+FAIL:/gm);
213
+ const skipLines = output.match(/^---\s+SKIP:/gm);
214
+ result.passed = passLines?.length ?? 0;
215
+ result.failed = failLines?.length ?? 0;
216
+ result.skipped = skipLines?.length ?? 0;
217
+ result.total = result.passed + result.failed + result.skipped;
218
+ // --- FAIL: TestFoo (0.00s)
219
+ const failPattern = /^---\s+FAIL:\s+(\S+)\s+\(([^)]+)\)/gm;
220
+ let match;
221
+ while ((match = failPattern.exec(output)) !== null) {
222
+ result.failures.push({
223
+ name: match[1],
224
+ error: `duration: ${match[2]}`,
225
+ });
226
+ }
227
+ // If zero counted, try "ok" / "FAIL" summary lines
228
+ if (result.total === 0) {
229
+ const okCount = (output.match(/^ok\s+/gm) ?? []).length;
230
+ const failCount = (output.match(/^FAIL\s+/gm) ?? []).length;
231
+ result.passed = okCount;
232
+ result.failed = failCount;
233
+ result.total = okCount + failCount;
234
+ }
235
+ return result;
236
+ }
237
+ function parseCargoTest(output) {
238
+ const result = { total: 0, passed: 0, failed: 0, skipped: 0, failures: [] };
239
+ // test result: ok. 5 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
240
+ const summary = output.match(/test result:\s*\w+\.\s*(\d+)\s+passed;\s*(\d+)\s+failed;\s*(\d+)\s+ignored/);
241
+ if (summary) {
242
+ result.passed = parseInt(summary[1], 10);
243
+ result.failed = parseInt(summary[2], 10);
244
+ result.skipped = parseInt(summary[3], 10);
245
+ result.total = result.passed + result.failed + result.skipped;
246
+ }
247
+ // Cargo outputs two "failures:" sections:
248
+ // 1. Detail section: "failures:\n\n---- test_name stdout ----\n..."
249
+ // 2. Name-list section: "failures:\n test_name_1\n test_name_2\n"
250
+ // We want the name-list section (the last one before "test result:")
251
+ const failSections = output.split(/^failures:\s*$/m).slice(1);
252
+ for (const section of failSections) {
253
+ // The name-list section has indented test names without "---- ... ----"
254
+ const lines = section.split('\n').filter(l => l.trim());
255
+ const isNameList = lines.length > 0 && lines.every(l => /^\s+\S+/.test(l) && !l.includes('----'));
256
+ if (isNameList) {
257
+ for (const line of lines.slice(0, 10)) {
258
+ result.failures.push({ name: line.trim(), error: '' });
259
+ }
260
+ break;
261
+ }
262
+ }
263
+ return result;
264
+ }
265
+ function parseGeneric(output) {
266
+ const result = { total: 0, passed: 0, failed: 0, skipped: 0, failures: [] };
267
+ // Try common patterns
268
+ const passedMatch = output.match(/(\d+)\s+(?:passed|passing|ok|success)/i);
269
+ const failedMatch = output.match(/(\d+)\s+(?:failed|failing|error|fail)/i);
270
+ const skippedMatch = output.match(/(\d+)\s+(?:skipped|pending|ignored)/i);
271
+ const totalMatch = output.match(/(\d+)\s+(?:total|tests?|specs?)\b/i);
272
+ result.passed = parseInt(passedMatch?.[1] ?? '0', 10);
273
+ result.failed = parseInt(failedMatch?.[1] ?? '0', 10);
274
+ result.skipped = parseInt(skippedMatch?.[1] ?? '0', 10);
275
+ result.total = totalMatch
276
+ ? parseInt(totalMatch[1], 10)
277
+ : result.passed + result.failed + result.skipped;
278
+ return result;
279
+ }
280
+ // ──────────────────────────────────────────────
281
+ // Formatter
282
+ // ──────────────────────────────────────────────
283
+ function formatTestSummary(result, command, runner, rawTokens) {
284
+ const lines = [];
285
+ const status = result.failed > 0 ? '❌ FAIL' : '✅ PASS';
286
+ lines.push(`TEST RESULT: ${status} (${runner})`);
287
+ lines.push('');
288
+ // Stats line
289
+ const parts = [];
290
+ parts.push(`${result.total} total`);
291
+ parts.push(`${result.passed} passed`);
292
+ if (result.failed > 0)
293
+ parts.push(`${result.failed} failed`);
294
+ if (result.skipped > 0)
295
+ parts.push(`${result.skipped} skipped`);
296
+ if (result.duration)
297
+ parts.push(`${result.duration}`);
298
+ if (result.suites)
299
+ parts.push(`${result.suites} suites`);
300
+ lines.push(parts.join(' | '));
301
+ // Failed tests detail
302
+ if (result.failures.length > 0) {
303
+ lines.push('');
304
+ lines.push('FAILURES:');
305
+ for (const f of result.failures.slice(0, 10)) {
306
+ lines.push(` ✗ ${f.name}`);
307
+ if (f.error) {
308
+ for (const errLine of f.error.split('\n').slice(0, 3)) {
309
+ lines.push(` ${errLine}`);
310
+ }
311
+ }
312
+ }
313
+ if (result.failures.length > 10) {
314
+ lines.push(` ... and ${result.failures.length - 10} more failures`);
315
+ }
316
+ }
317
+ lines.push('');
318
+ lines.push(`RAW OUTPUT: ~${rawTokens} tokens → test_summary: ~${estimateTokens(lines.join('\n'))} tokens`);
319
+ return lines.join('\n');
320
+ }
321
+ //# sourceMappingURL=test-summary.js.map
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { createServer } from './server.js';
7
7
  import { installHook, uninstallHook } from './hooks/installer.js';
8
- import { findBinary, installBinary } from './ast-index/binary-manager.js';
8
+ import { findBinary, installBinary, checkBinaryUpdate, isNewerVersion } from './ast-index/binary-manager.js';
9
+ import { loadConfig } from './config/loader.js';
9
10
  import { isDangerousRoot } from './core/validation.js';
10
11
  const execFileAsync = promisify(execFile);
11
12
  const HOOK_DENY_THRESHOLD = 500;
@@ -14,6 +15,7 @@ const CODE_EXTENSIONS = new Set([
14
15
  'swift', 'cs', 'cpp', 'cc', 'cxx', 'hpp', 'c', 'h', 'php', 'rb', 'scala',
15
16
  'dart', 'lua', 'sh', 'bash', 'sql', 'r', 'vue', 'svelte', 'pl', 'pm',
16
17
  'ex', 'exs', 'groovy', 'm', 'proto', 'bsl',
18
+ 'lisp', 'lsp', 'cl', 'asd',
17
19
  ]);
18
20
  function getVersion() {
19
21
  try {
@@ -114,12 +116,10 @@ async function startServer() {
114
116
  ` Fix: pass project path explicitly — token-pilot /path/to/project\n` +
115
117
  ` Or configure mcpServers with "args": ["/path/to/project"]`);
116
118
  }
117
- // Non-blocking update check (logs to stderr, never blocks startup)
118
- checkLatestVersion().then(latest => {
119
- if (latest && latest !== getVersion()) {
120
- console.error(`[token-pilot] Update available: ${getVersion()} ${latest}. Run: npx token-pilot@latest`);
121
- }
122
- }).catch(() => { });
119
+ // Non-blocking update check for all components (logs to stderr, never blocks startup)
120
+ const config = await loadConfig(projectRoot);
121
+ const binaryStatus = await findBinary(config.astIndex.binaryPath);
122
+ checkAllUpdates(config, binaryStatus).catch(() => { });
123
123
  // Auto-install PreToolUse hook (non-blocking, Claude Code only)
124
124
  installHook(projectRoot).then(result => {
125
125
  if (result.installed) {
@@ -236,8 +236,15 @@ async function handleUninstallHook(projectRoot) {
236
236
  async function handleInstallAstIndex() {
237
237
  const status = await findBinary();
238
238
  if (status.available) {
239
- console.log(`ast-index ${status.version} already available at ${status.path} (${status.source})`);
240
- process.exit(0);
239
+ // Check if update is available
240
+ const update = await checkBinaryUpdate(status.path);
241
+ if (update.updateAvailable) {
242
+ console.log(`ast-index ${update.current} installed, updating to ${update.latest}...`);
243
+ }
244
+ else {
245
+ console.log(`ast-index ${status.version} already up to date at ${status.path} (${status.source})`);
246
+ process.exit(0);
247
+ }
241
248
  }
242
249
  try {
243
250
  const result = await installBinary((msg) => console.log(msg));
@@ -251,43 +258,69 @@ async function handleInstallAstIndex() {
251
258
  }
252
259
  async function handleDoctor() {
253
260
  const version = getVersion();
254
- console.log(`token-pilot v${version}\n`);
255
- // Check Node.js version
261
+ const { existsSync } = await import('node:fs');
262
+ const { join } = await import('node:path');
263
+ const cwd = process.cwd();
264
+ console.log(`token-pilot doctor v${version}\n`);
265
+ // ── Environment ──
256
266
  const nodeVersion = process.version;
257
267
  const nodeMajor = parseInt(nodeVersion.slice(1), 10);
258
- console.log(`Node.js: ${nodeVersion} ${nodeMajor >= 18 ? '✓' : '✗ (requires >=18)'}`);
259
- // Check ast-index
260
- const astStatus = await findBinary();
261
- if (astStatus.available) {
262
- console.log(`ast-index: ${astStatus.version}(${astStatus.source}: ${astStatus.path})`);
268
+ console.log(`Node.js: ${nodeVersion} ${nodeMajor >= 18 ? '✓' : '✗ (requires >=18)'}`);
269
+ const configPath = join(cwd, '.token-pilot.json');
270
+ console.log(`config: ${existsSync(configPath) ? configPath + ' ✓' : 'default (no .token-pilot.json)'}`);
271
+ const gitDir = join(cwd, '.git');
272
+ console.log(`git repo: ${existsSync(gitDir) ? 'yes ' : 'no (read_diff/git features unavailable)'}`);
273
+ console.log('');
274
+ // ── token-pilot ──
275
+ console.log('── token-pilot ──');
276
+ console.log(` installed: ${version}`);
277
+ const tpLatest = await checkNpmLatest('token-pilot');
278
+ if (tpLatest) {
279
+ if (isNewerVersion(version, tpLatest)) {
280
+ console.log(` latest: ${tpLatest} (update available!)`);
281
+ console.log(` run: npx clear-npx-cache && npx -y token-pilot@latest`);
282
+ }
283
+ else {
284
+ console.log(` latest: ${tpLatest} ✓ (up to date)`);
285
+ }
263
286
  }
264
287
  else {
265
- console.log(`ast-index: not found ✗`);
266
- console.log(` Run: npx token-pilot install-ast-index`);
267
- }
268
- // Check for updates
269
- const latest = await checkLatestVersion();
270
- if (latest) {
271
- if (latest !== version) {
272
- console.log(`npm version: ${latest} (current: ${version} — update available!)`);
273
- console.log(` Run: npx clear-npx-cache && npx -y token-pilot@latest`);
288
+ console.log(` latest: could not check (network error)`);
289
+ }
290
+ console.log('');
291
+ // ── ast-index ──
292
+ console.log('── ast-index ──');
293
+ const astStatus = await findBinary();
294
+ if (astStatus.available) {
295
+ console.log(` installed: ${astStatus.version} (${astStatus.source}: ${astStatus.path})`);
296
+ const astUpdate = await checkBinaryUpdate(astStatus.path);
297
+ if (astUpdate.updateAvailable) {
298
+ console.log(` latest: ${astUpdate.latest} (update available!)`);
299
+ console.log(` run: npx token-pilot install-ast-index`);
274
300
  }
275
- else {
276
- console.log(`npm version: ${latest} ✓ (up to date)`);
301
+ else if (astUpdate.latest) {
302
+ console.log(` latest: ${astUpdate.latest} ✓ (up to date)`);
277
303
  }
304
+ const config = await loadConfig(cwd);
305
+ console.log(` auto-update: ${config.updates.autoUpdate ? 'enabled ✓' : 'disabled (set updates.autoUpdate=true in .token-pilot.json)'}`);
278
306
  }
279
307
  else {
280
- console.log(`npm version: could not check (network error)`);
308
+ console.log(` installed: not found ✗`);
309
+ console.log(` run: npx token-pilot install-ast-index`);
310
+ }
311
+ console.log('');
312
+ // ── context-mode ──
313
+ console.log('── context-mode ──');
314
+ const { detectContextMode } = await import('./integration/context-mode-detector.js');
315
+ const cmStatus = await detectContextMode(cwd);
316
+ console.log(` detected: ${cmStatus.detected ? `yes (${cmStatus.source})` : 'no'}`);
317
+ const cmLatest = await checkNpmLatest('claude-context-mode');
318
+ if (cmLatest) {
319
+ console.log(` latest npm: ${cmLatest}`);
320
+ }
321
+ if (!cmStatus.detected) {
322
+ console.log(` setup: npx token-pilot init`);
281
323
  }
282
- // Check config
283
- const { existsSync } = await import('node:fs');
284
- const { join } = await import('node:path');
285
- const cwd = process.cwd();
286
- const configPath = join(cwd, '.token-pilot.json');
287
- console.log(`config: ${existsSync(configPath) ? configPath + ' ✓' : 'default (no .token-pilot.json)'}`);
288
- // Check git
289
- const gitDir = join(cwd, '.git');
290
- console.log(`git repo: ${existsSync(gitDir) ? 'yes ✓' : 'no (read_diff/git features unavailable)'}`);
291
324
  console.log('');
292
325
  process.exit(0);
293
326
  }
@@ -343,11 +376,14 @@ async function handleInit(targetDir) {
343
376
  console.log(`\nRestart your AI assistant to activate.`);
344
377
  process.exit(0);
345
378
  }
346
- async function checkLatestVersion() {
379
+ // ──────────────────────────────────────────────
380
+ // Update checking
381
+ // ──────────────────────────────────────────────
382
+ async function checkNpmLatest(packageName) {
347
383
  try {
348
384
  const controller = new AbortController();
349
385
  const timeout = setTimeout(() => controller.abort(), 3000);
350
- const resp = await fetch('https://registry.npmjs.org/token-pilot/latest', {
386
+ const resp = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
351
387
  signal: controller.signal,
352
388
  });
353
389
  clearTimeout(timeout);
@@ -360,6 +396,37 @@ async function checkLatestVersion() {
360
396
  return null;
361
397
  }
362
398
  }
399
+ async function checkAllUpdates(config, binaryStatus) {
400
+ if (!config.updates.checkOnStartup)
401
+ return;
402
+ const [tpLatest, astUpdate, cmLatest] = await Promise.allSettled([
403
+ checkNpmLatest('token-pilot'),
404
+ binaryStatus.available ? checkBinaryUpdate(binaryStatus.path) : Promise.resolve(null),
405
+ checkNpmLatest('claude-context-mode'),
406
+ ]);
407
+ // token-pilot
408
+ const tpVersion = getVersion();
409
+ if (tpLatest.status === 'fulfilled' && tpLatest.value && isNewerVersion(tpVersion, tpLatest.value)) {
410
+ console.error(`[token-pilot] Update available: ${tpVersion} → ${tpLatest.value}. Run: npx token-pilot@latest`);
411
+ }
412
+ // ast-index
413
+ if (astUpdate.status === 'fulfilled' && astUpdate.value?.updateAvailable) {
414
+ const { current, latest } = astUpdate.value;
415
+ if (config.updates.autoUpdate) {
416
+ console.error(`[token-pilot] Auto-updating ast-index: ${current} → ${latest}...`);
417
+ installBinary(msg => console.error(`[token-pilot] ${msg}`)).catch(() => { });
418
+ }
419
+ else {
420
+ console.error(`[token-pilot] ast-index update: ${current} → ${latest}. Run: token-pilot install-ast-index`);
421
+ }
422
+ }
423
+ // context-mode (notification only — runs as separate MCP server)
424
+ if (cmLatest.status === 'fulfilled' && cmLatest.value) {
425
+ // We can't reliably detect the currently installed version of context-mode
426
+ // (it runs as separate process via npx). Just log latest available for doctor.
427
+ // On startup, we only notify if explicitly useful.
428
+ }
429
+ }
363
430
  function printHelp() {
364
431
  console.log(`token-pilot v${getVersion()} — MCP server for token-efficient code reading
365
432
 
@@ -376,10 +443,10 @@ Usage:
376
443
  Quick start:
377
444
  npx token-pilot init Setup .mcp.json (token-pilot + context-mode)
378
445
 
379
- MCP Tools (12):
380
- smart_read, read_symbol, read_range, read_diff, smart_read_many, read_for_edit,
381
- find_usages, find_unused, related_files, outline,
382
- project_overview, session_analytics
446
+ MCP Tools (18):
447
+ smart_read, read_symbol, read_range, read_diff, read_for_edit, smart_read_many,
448
+ find_usages, find_unused, related_files, outline, project_overview, session_analytics,
449
+ code_audit, module_info, smart_diff, explore_area, smart_log, test_summary
383
450
  `);
384
451
  process.exit(0);
385
452
  }
package/dist/server.js CHANGED
@@ -30,9 +30,11 @@ import { handleCodeAudit } from './handlers/code-audit.js';
30
30
  import { handleModuleInfo } from './handlers/module-info.js';
31
31
  import { handleSmartDiff } from './handlers/smart-diff.js';
32
32
  import { handleExploreArea } from './handlers/explore-area.js';
33
+ import { handleSmartLog } from './handlers/smart-log.js';
34
+ import { handleTestSummary } from './handlers/test-summary.js';
33
35
  import { detectContextMode } from './integration/context-mode-detector.js';
34
36
  import { estimateTokens } from './core/token-estimator.js';
35
- import { resolveSafePath, validateSmartReadArgs, validateReadSymbolArgs, validateReadRangeArgs, validateReadDiffArgs, validateFindUsagesArgs, validateSmartReadManyArgs, validateReadForEditArgs, validateRelatedFilesArgs, validateOutlineArgs, validateFindUnusedArgs, validateCodeAuditArgs, validateProjectOverviewArgs, validateModuleInfoArgs, validateSmartDiffArgs, validateExploreAreaArgs, } from './core/validation.js';
37
+ import { resolveSafePath, validateSmartReadArgs, validateReadSymbolArgs, validateReadRangeArgs, validateReadDiffArgs, validateFindUsagesArgs, validateSmartReadManyArgs, validateReadForEditArgs, validateRelatedFilesArgs, validateOutlineArgs, validateFindUnusedArgs, validateCodeAuditArgs, validateProjectOverviewArgs, validateModuleInfoArgs, validateSmartDiffArgs, validateExploreAreaArgs, validateSmartLogArgs, validateTestSummaryArgs, } from './core/validation.js';
36
38
  export async function createServer(projectRoot, options) {
37
39
  const config = await loadConfig(projectRoot);
38
40
  const astIndex = new AstIndexClient(projectRoot, config.astIndex.timeout, {
@@ -187,6 +189,8 @@ export async function createServer(projectRoot, options) {
187
189
  '• Code quality audit → code_audit (TODOs, deprecated, structural code patterns)',
188
190
  '• Reviewing git changes → smart_diff (structural diff with symbol mapping, not raw patch)',
189
191
  '• Starting work on an area → explore_area (outline + imports + tests + git log in one call)',
192
+ '• Understanding commit history → smart_log (structured git log with categories, not raw output)',
193
+ '• Running tests → test_summary (structured pass/fail summary, not 200 lines of raw output)',
190
194
  '',
191
195
  'WHEN TO USE DEFAULT TOOLS (Token Pilot adds no value):',
192
196
  '• Small files (≤200 lines) → smart_read returns full content anyway, same as Read',
@@ -434,6 +438,31 @@ export async function createServer(projectRoot, options) {
434
438
  required: ['path'],
435
439
  },
436
440
  },
441
+ {
442
+ name: 'smart_log',
443
+ description: 'Use INSTEAD OF raw git log. Structured commit history with category detection (feat/fix/refactor/docs), file stats, author breakdown. Filters by path and ref.',
444
+ inputSchema: {
445
+ type: 'object',
446
+ properties: {
447
+ path: { type: 'string', description: 'Filter to specific file or directory' },
448
+ count: { type: 'number', description: 'Number of commits (default: 10, max: 50)' },
449
+ ref: { type: 'string', description: 'Git ref — branch, tag, or commit (default: HEAD)' },
450
+ },
451
+ },
452
+ },
453
+ {
454
+ name: 'test_summary',
455
+ description: 'Run tests and return structured summary: total/passed/failed/skipped + failure details. 200 lines of raw output → 10-15 lines. Supports vitest, jest, pytest, phpunit, go test, cargo test.',
456
+ inputSchema: {
457
+ type: 'object',
458
+ properties: {
459
+ command: { type: 'string', description: 'Test command to run (e.g., "npm test", "pytest", "go test ./...")' },
460
+ runner: { type: 'string', enum: ['vitest', 'jest', 'pytest', 'phpunit', 'go', 'cargo', 'rspec', 'mocha'], description: 'Force specific parser (auto-detected if omitted)' },
461
+ timeout: { type: 'number', description: 'Timeout in ms (default: 60000, max: 300000)' },
462
+ },
463
+ required: ['command'],
464
+ },
465
+ },
437
466
  ],
438
467
  }));
439
468
  // Helper: get real full-file token count for honest analytics
@@ -604,6 +633,22 @@ export async function createServer(projectRoot, options) {
604
633
  analytics.record({ tool: 'explore_area', path: eaArgs.path, tokensReturned: eaTokens, tokensWouldBe: eaWouldBe, timestamp: Date.now() });
605
634
  return eaResult;
606
635
  }
636
+ case 'smart_log': {
637
+ const slArgs = validateSmartLogArgs(args);
638
+ const slResult = await handleSmartLog(slArgs, projectRoot);
639
+ const slText = slResult.content[0]?.text ?? '';
640
+ const slTokens = estimateTokens(slText);
641
+ analytics.record({ tool: 'smart_log', path: slArgs.path ?? 'all', tokensReturned: slTokens, tokensWouldBe: slResult.rawTokens || slTokens, timestamp: Date.now() });
642
+ return { content: slResult.content };
643
+ }
644
+ case 'test_summary': {
645
+ const tsArgs = validateTestSummaryArgs(args);
646
+ const tsResult = await handleTestSummary(tsArgs, projectRoot);
647
+ const tsText = tsResult.content[0]?.text ?? '';
648
+ const tsTokens = estimateTokens(tsText);
649
+ analytics.record({ tool: 'test_summary', path: tsArgs.command, tokensReturned: tsTokens, tokensWouldBe: tsResult.rawTokens || tsTokens, timestamp: Date.now() });
650
+ return { content: tsResult.content };
651
+ }
607
652
  default:
608
653
  return {
609
654
  content: [{ type: 'text', text: `Unknown tool: ${name}` }],
package/dist/types.d.ts CHANGED
@@ -115,6 +115,10 @@ export interface TokenPilotConfig {
115
115
  adviseDelegation: boolean;
116
116
  largeNonCodeThreshold: number;
117
117
  };
118
+ updates: {
119
+ checkOnStartup: boolean;
120
+ autoUpdate: boolean;
121
+ };
118
122
  ignore: string[];
119
123
  }
120
124
  //# sourceMappingURL=types.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-pilot",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Save 60-80% tokens when AI reads code — MCP server for token-efficient code navigation, AST-aware structural reading instead of dumping full files into context window",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",