pi-lens 3.6.6 → 3.7.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.
@@ -9,6 +9,7 @@ import * as fs from "node:fs/promises";
9
9
  import * as path from "node:path";
10
10
  import * as ts from "typescript";
11
11
  import { EXCLUDED_DIRS } from "../../file-utils.js";
12
+ import { NativeRustCoreClient } from "../../native-rust-client.js";
12
13
  import {
13
14
  buildProjectIndex,
14
15
  findSimilarFunctions,
@@ -23,12 +24,18 @@ import type {
23
24
  RunnerResult,
24
25
  } from "../types.js";
25
26
 
27
+ // Singleton Rust client — initialised once, reused across runner invocations.
28
+ const rustClient = new NativeRustCoreClient();
29
+
30
+ /** Feature flag: set to false to force the pure-TypeScript path. */
31
+ const USE_RUST = true;
32
+
26
33
  // ============================================================================
27
34
  // Configuration
28
35
  // ============================================================================
29
36
 
30
37
  const CONFIG = {
31
- SIMILARITY_THRESHOLD: 0.75, // 75% minimum similarity
38
+ SIMILARITY_THRESHOLD: 0.9, // 90% minimum similarity — below this false positives dominate
32
39
  MIN_TRANSITIONS: 20, // Skip functions with <20 AST transitions
33
40
  MAX_SUGGESTIONS: 3, // Max 3 suggestions per file
34
41
  USAGE_THRESHOLD: 2, // Only suggest utilities with 2+ uses (placeholder)
@@ -64,6 +71,24 @@ const similarityRunner: RunnerDefinition = {
64
71
  return { status: "skipped", diagnostics: [], semantic: "none" };
65
72
  }
66
73
 
74
+ // ── Rust fast-path ─────────────────────────────────────────────────────
75
+ // Try Rust for file scanning + similarity detection. If the Rust binary
76
+ // is available, use it. On any failure, fall through to the pure-TS path.
77
+ if (USE_RUST && rustClient.isAvailable()) {
78
+ try {
79
+ const rustResult = await runWithRust(
80
+ filePath,
81
+ projectRoot,
82
+ CONFIG.SIMILARITY_THRESHOLD,
83
+ CONFIG.MAX_SUGGESTIONS,
84
+ );
85
+ if (rustResult !== null) return rustResult;
86
+ } catch {
87
+ // Fall through to TypeScript implementation.
88
+ }
89
+ }
90
+ // ── TypeScript fallback ─────────────────────────────────────────────────
91
+
67
92
  const index = await loadOrBuildIndex(projectRoot);
68
93
  if (!index || index.entries.size === 0) {
69
94
  return { status: "skipped", diagnostics: [], semantic: "none" };
@@ -261,6 +286,80 @@ function buildSuggestionMessage(
261
286
  return `Function '${func.name}' has ${similarityPct}% similarity to existing utility '${name}()' in ${location}. Consider reusing the existing utility.`;
262
287
  }
263
288
 
289
+ // ============================================================================
290
+ // Rust fast-path
291
+ // ============================================================================
292
+
293
+ /**
294
+ * Run similarity detection via the Rust binary.
295
+ *
296
+ * Flow:
297
+ * 1. Scan project files with Rust (respects .gitignore, much faster than glob).
298
+ * 2. Build the Rust index (persisted to .pi-lens/rust-index.json).
299
+ * 3. Query similarity for the current file.
300
+ * 4. Convert matches to Diagnostics.
301
+ *
302
+ * Returns `null` if the Rust path cannot produce results (no matches is still
303
+ * a valid result — returned as an empty-diagnostic RunnerResult).
304
+ */
305
+ async function runWithRust(
306
+ filePath: string,
307
+ projectRoot: string,
308
+ threshold: number,
309
+ maxSuggestions: number,
310
+ ): Promise<RunnerResult | null> {
311
+ // 1. Scan project files.
312
+ const scanned = await rustClient.scanProject(projectRoot, [".ts", ".tsx"]);
313
+ if (scanned.length === 0) return null;
314
+
315
+ const relativeFiles = scanned.map((e) =>
316
+ path.relative(projectRoot, e.path).replace(/\\/g, "/"),
317
+ );
318
+
319
+ // 2. Build index (saves to .pi-lens/rust-index.json).
320
+ await rustClient.buildIndex(projectRoot, relativeFiles);
321
+
322
+ // 3. Find similarities for the current file.
323
+ const matches = await rustClient.findSimilarities(
324
+ projectRoot,
325
+ filePath,
326
+ threshold,
327
+ );
328
+
329
+ if (matches.length === 0) {
330
+ return { status: "succeeded", diagnostics: [], semantic: "none" };
331
+ }
332
+
333
+ // 4. Convert to Diagnostics.
334
+ const diagnostics: Diagnostic[] = matches
335
+ .slice(0, maxSuggestions)
336
+ .map((m) => {
337
+ const similarityPct = Math.round(m.similarity * 100);
338
+ // source_id format: "path/to/file.ts::funcName@line"
339
+ const [srcFile, srcFunc] = m.source_id.split("::");
340
+ const [targetFile, targetFunc] = m.target_id.split("::");
341
+ const funcName = srcFunc?.split("@")[0] ?? "?";
342
+ const targetName = targetFunc?.split("@")[0] ?? "?";
343
+ void srcFile; // file is implicit (it's the current file)
344
+ return {
345
+ id: `similarity-rust-${m.source_id}-${m.target_id}`,
346
+ tool: "similarity",
347
+ filePath,
348
+ line: 1, // Rust gives us the function source_id; line resolution is TODO
349
+ column: 1,
350
+ message: `Function '${funcName}' has ${similarityPct}% similarity to '${targetName}()' in ${targetFile}. Consider reusing the existing utility.`,
351
+ severity: "warning" as const,
352
+ semantic: "warning" as const,
353
+ };
354
+ });
355
+
356
+ return {
357
+ status: "succeeded",
358
+ diagnostics,
359
+ semantic: diagnostics.length > 0 ? "warning" : "none",
360
+ };
361
+ }
362
+
264
363
  // ============================================================================
265
364
  // Index Management
266
365
  // ============================================================================
@@ -51,7 +51,11 @@ const typeSafetyRunner: RunnerDefinition = {
51
51
  return {
52
52
  status: hasErrors ? "failed" : "succeeded",
53
53
  diagnostics,
54
- semantic: hasErrors ? "blocking" : "warning",
54
+ semantic: hasErrors
55
+ ? "blocking"
56
+ : diagnostics.length > 0
57
+ ? "warning"
58
+ : "none",
55
59
  };
56
60
  },
57
61
  };
@@ -17,10 +17,16 @@ import { safeSpawn } from "./safe-spawn.js";
17
17
 
18
18
  export interface FormatterInfo {
19
19
  name: string;
20
- command: string[]; // Command with $FILE placeholder
20
+ command: string[]; // Command with $FILE placeholder — used as fallback
21
21
  extensions: string[];
22
22
  /** Detect if this formatter should be used for a project */
23
23
  detect(cwd: string): Promise<boolean>;
24
+ /**
25
+ * Optionally resolve the full command at runtime (venv, vendor/bin, bundle exec).
26
+ * Return null to fall back to the static `command` field.
27
+ * filePath is already resolved to an absolute path.
28
+ */
29
+ resolveCommand?(filePath: string, cwd: string): Promise<string[] | null>;
24
30
  }
25
31
 
26
32
  export interface FormatterResult {
@@ -82,11 +88,107 @@ async function which(command: string): Promise<string | null> {
82
88
  return result.stdout?.trim().split("\n")[0] ?? null;
83
89
  }
84
90
 
91
+ // --- Venv / Local Binary Helpers ---
92
+
93
+ /**
94
+ * Walk up from cwd looking for a binary in .venv or venv.
95
+ * Returns the absolute path if found, null otherwise.
96
+ */
97
+ async function findInVenv(binary: string, cwd: string): Promise<string | null> {
98
+ const isWin = process.platform === "win32";
99
+ const candidates = isWin
100
+ ? [
101
+ `.venv/Scripts/${binary}.exe`,
102
+ `venv/Scripts/${binary}.exe`,
103
+ `.venv/Scripts/${binary}`,
104
+ `venv/Scripts/${binary}`,
105
+ ]
106
+ : [`.venv/bin/${binary}`, `venv/bin/${binary}`];
107
+
108
+ let dir = cwd;
109
+ const root = path.parse(dir).root;
110
+ while (dir !== root) {
111
+ for (const candidate of candidates) {
112
+ const full = path.join(dir, candidate);
113
+ if (await fileExists(full)) return full;
114
+ }
115
+ const parent = path.dirname(dir);
116
+ if (parent === dir) break;
117
+ dir = parent;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Check vendor/bin for PHP Composer-managed tools.
124
+ * Walks up from cwd to find vendor/bin/<binary>.
125
+ */
126
+ async function findInVendorBin(
127
+ binary: string,
128
+ cwd: string,
129
+ ): Promise<string | null> {
130
+ const isWin = process.platform === "win32";
131
+ const name = isWin ? `${binary}.bat` : binary;
132
+ let dir = cwd;
133
+ const root = path.parse(dir).root;
134
+ while (dir !== root) {
135
+ const full = path.join(dir, "vendor", "bin", name);
136
+ if (await fileExists(full)) return full;
137
+ const parent = path.dirname(dir);
138
+ if (parent === dir) break;
139
+ dir = parent;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * Check node_modules/.bin for locally installed Node tools.
146
+ * Walks up from cwd to find node_modules/.bin/<binary>.
147
+ */
148
+ async function findInNodeModules(
149
+ binary: string,
150
+ cwd: string,
151
+ ): Promise<string | null> {
152
+ const isWin = process.platform === "win32";
153
+ let dir = cwd;
154
+ const root = path.parse(dir).root;
155
+ while (dir !== root) {
156
+ const candidates = isWin
157
+ ? [
158
+ path.join(dir, "node_modules", ".bin", `${binary}.cmd`),
159
+ path.join(dir, "node_modules", ".bin", binary),
160
+ ]
161
+ : [path.join(dir, "node_modules", ".bin", binary)];
162
+ for (const full of candidates) {
163
+ if (await fileExists(full)) return full;
164
+ }
165
+ const parent = path.dirname(dir);
166
+ if (parent === dir) break;
167
+ dir = parent;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * Returns true if `bundle exec <gem>` should be used:
174
+ * bundle binary is available AND Gemfile.lock exists in the tree.
175
+ */
176
+ async function canUseBundleExec(cwd: string): Promise<boolean> {
177
+ if ((await which("bundle")) === null) return false;
178
+ const lockfiles = await findUp(["Gemfile.lock"], cwd);
179
+ return lockfiles.length > 0;
180
+ }
181
+
85
182
  // --- Formatter Definitions ---
86
183
 
87
184
  export const biomeFormatter: FormatterInfo = {
88
185
  name: "biome",
89
186
  command: ["npx", "@biomejs/biome", "format", "--write", "$FILE"],
187
+ async resolveCommand(filePath, cwd) {
188
+ const local = await findInNodeModules("biome", cwd);
189
+ if (local) return [local, "format", "--write", filePath];
190
+ return null;
191
+ },
90
192
  extensions: [
91
193
  ".js",
92
194
  ".jsx",
@@ -111,10 +213,10 @@ export const biomeFormatter: FormatterInfo = {
111
213
  const found = await findUp(configs, cwd);
112
214
  if (found.length > 0) return true;
113
215
 
114
- // Check if biome is in package.json devDependencies
115
- const pkgPath = path.join(cwd, "package.json");
116
- if (await fileExists(pkgPath)) {
117
- const pkg = (await readJson(pkgPath)) as {
216
+ // Check if biome is in the nearest package.json devDependencies
217
+ const pkgPaths = await findUp(["package.json"], cwd);
218
+ if (pkgPaths.length > 0) {
219
+ const pkg = (await readJson(pkgPaths[0])) as {
118
220
  devDependencies?: Record<string, string>;
119
221
  };
120
222
  if (pkg.devDependencies?.["@biomejs/biome"]) return true;
@@ -127,6 +229,11 @@ export const biomeFormatter: FormatterInfo = {
127
229
  export const prettierFormatter: FormatterInfo = {
128
230
  name: "prettier",
129
231
  command: ["npx", "prettier", "--write", "$FILE"],
232
+ async resolveCommand(filePath, cwd) {
233
+ const local = await findInNodeModules("prettier", cwd);
234
+ if (local) return [local, "--write", filePath];
235
+ return null;
236
+ },
130
237
  extensions: [
131
238
  ".js",
132
239
  ".jsx",
@@ -168,10 +275,10 @@ export const prettierFormatter: FormatterInfo = {
168
275
  const found = await findUp(configs, cwd);
169
276
  if (found.length > 0) return true;
170
277
 
171
- // Check package.json
172
- const pkgPath = path.join(cwd, "package.json");
173
- if (await fileExists(pkgPath)) {
174
- const pkg = (await readJson(pkgPath)) as {
278
+ // Check the nearest package.json for prettier
279
+ const pkgPaths = await findUp(["package.json"], cwd);
280
+ if (pkgPaths.length > 0) {
281
+ const pkg = (await readJson(pkgPaths[0])) as {
175
282
  devDependencies?: Record<string, string>;
176
283
  dependencies?: Record<string, string>;
177
284
  prettier?: unknown;
@@ -191,6 +298,11 @@ export const ruffFormatter: FormatterInfo = {
191
298
  name: "ruff",
192
299
  command: ["ruff", "format", "$FILE"],
193
300
  extensions: [".py", ".pyi"],
301
+ async resolveCommand(filePath, cwd) {
302
+ const venv = await findInVenv("ruff", cwd);
303
+ if (venv) return [venv, "format", filePath];
304
+ return null;
305
+ },
194
306
  async detect(cwd: string) {
195
307
  // Check for ruff config
196
308
  const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"];
@@ -226,6 +338,11 @@ export const blackFormatter: FormatterInfo = {
226
338
  name: "black",
227
339
  command: ["black", "$FILE"],
228
340
  extensions: [".py", ".pyi"],
341
+ async resolveCommand(filePath, cwd) {
342
+ const venv = await findInVenv("black", cwd);
343
+ if (venv) return [venv, filePath];
344
+ return null;
345
+ },
229
346
  async detect(cwd: string) {
230
347
  // Check for black config in pyproject.toml
231
348
  const configs = ["pyproject.toml"];
@@ -351,6 +468,11 @@ export const rubocopFormatter: FormatterInfo = {
351
468
  name: "rubocop",
352
469
  command: ["rubocop", "-a", "--no-color", "$FILE"],
353
470
  extensions: [".rb", ".rake", ".gemspec", ".ru"],
471
+ async resolveCommand(filePath, cwd) {
472
+ if (await canUseBundleExec(cwd))
473
+ return ["bundle", "exec", "rubocop", "-a", "--no-color", filePath];
474
+ return null;
475
+ },
354
476
  async detect(cwd: string) {
355
477
  // Only run if project has explicit RuboCop config
356
478
  const configs = [".rubocop.yml", ".rubocop.yaml"];
@@ -370,6 +492,11 @@ export const standardrbFormatter: FormatterInfo = {
370
492
  name: "standardrb",
371
493
  command: ["standardrb", "--fix", "$FILE"],
372
494
  extensions: [".rb", ".rake"],
495
+ async resolveCommand(filePath, cwd) {
496
+ if (await canUseBundleExec(cwd))
497
+ return ["bundle", "exec", "standardrb", "--fix", filePath];
498
+ return null;
499
+ },
373
500
  async detect(cwd: string) {
374
501
  // standardrb is only used if explicitly in Gemfile (no config file — it is the config)
375
502
  const gemfile = path.join(cwd, "Gemfile");
@@ -403,6 +530,80 @@ export const terraformFormatter: FormatterInfo = {
403
530
  },
404
531
  };
405
532
 
533
+ export const phpCsFixerFormatter: FormatterInfo = {
534
+ name: "php-cs-fixer",
535
+ command: ["php-cs-fixer", "fix", "$FILE"],
536
+ extensions: [".php"],
537
+ async resolveCommand(filePath, cwd) {
538
+ const vendor = await findInVendorBin("php-cs-fixer", cwd);
539
+ if (vendor) return [vendor, "fix", filePath];
540
+ return null;
541
+ },
542
+ async detect(cwd: string) {
543
+ const vendorBin = await findInVendorBin("php-cs-fixer", cwd);
544
+ const globalBin = await which("php-cs-fixer");
545
+ if (!vendorBin && !globalBin) return false;
546
+ // Only run if project has explicit config
547
+ const configs = [".php-cs-fixer.php", ".php-cs-fixer.dist.php"];
548
+ const found = await findUp(configs, cwd);
549
+ return found.length > 0;
550
+ },
551
+ };
552
+
553
+ export const csharpierFormatter: FormatterInfo = {
554
+ name: "csharpier",
555
+ command: ["dotnet", "csharpier", "$FILE"],
556
+ extensions: [".cs"],
557
+ async detect(_cwd: string) {
558
+ // Check dotnet is available AND csharpier tool is installed
559
+ if ((await which("dotnet")) === null) return false;
560
+ const result = safeSpawn("dotnet", ["csharpier", "--version"], {
561
+ timeout: 5000,
562
+ });
563
+ return !result.error && result.status === 0;
564
+ },
565
+ };
566
+
567
+ export const fantomasFormatter: FormatterInfo = {
568
+ name: "fantomas",
569
+ command: ["fantomas", "$FILE"],
570
+ extensions: [".fs", ".fsi", ".fsx"],
571
+ async detect(_cwd: string) {
572
+ return (await which("fantomas")) !== null;
573
+ },
574
+ };
575
+
576
+ export const swiftformatFormatter: FormatterInfo = {
577
+ name: "swiftformat",
578
+ command: ["swiftformat", "$FILE"],
579
+ extensions: [".swift"],
580
+ async detect(_cwd: string) {
581
+ return (await which("swiftformat")) !== null;
582
+ },
583
+ };
584
+
585
+ export const styluaFormatter: FormatterInfo = {
586
+ name: "stylua",
587
+ command: ["stylua", "$FILE"],
588
+ extensions: [".lua"],
589
+ async detect(cwd: string) {
590
+ if ((await which("stylua")) === null) return false;
591
+ // Prefer explicit config but also run if binary is present in a Lua project
592
+ const configs = ["stylua.toml", ".stylua.toml"];
593
+ const found = await findUp(configs, cwd);
594
+ return found.length > 0;
595
+ },
596
+ };
597
+
598
+ export const ormoluFormatter: FormatterInfo = {
599
+ name: "ormolu",
600
+ command: ["ormolu", "--mode", "inplace", "$FILE"],
601
+ extensions: [".hs", ".lhs"],
602
+ async detect(_cwd: string) {
603
+ return (await which("ormolu")) !== null;
604
+ },
605
+ };
606
+
406
607
  // --- Registry ---
407
608
 
408
609
  const ALL_FORMATTERS: FormatterInfo[] = [
@@ -421,6 +622,12 @@ const ALL_FORMATTERS: FormatterInfo[] = [
421
622
  clangFormatFormatter,
422
623
  ktlintFormatter,
423
624
  terraformFormatter,
625
+ phpCsFixerFormatter,
626
+ csharpierFormatter,
627
+ fantomasFormatter,
628
+ swiftformatFormatter,
629
+ styluaFormatter,
630
+ ormoluFormatter,
424
631
  rubocopFormatter,
425
632
  standardrbFormatter,
426
633
  gleamFormatter,
@@ -514,19 +721,13 @@ export async function formatFile(
514
721
  const cwd = path.dirname(absolutePath);
515
722
  const contentBefore = await fs.readFile(absolutePath, "utf-8");
516
723
 
517
- // Replace $FILE placeholder
518
- let cmd = formatter.command.map((c) => c.replace("$FILE", absolutePath));
519
-
520
- // OPTIMIZATION: Use local biome binary instead of npx if available
521
- if (formatter.name === "biome" && cmd[0] === "npx") {
522
- const localBiome = path.join(cwd, "node_modules", ".bin", "biome");
523
- const localBiomeWin = path.join(cwd, "node_modules", ".bin", "biome.cmd");
524
- if (await fileExists(localBiome)) {
525
- cmd = [localBiome, ...cmd.slice(2)]; // Replace "npx" "@biomejs/biome" with local path
526
- } else if (await fileExists(localBiomeWin)) {
527
- cmd = [localBiomeWin, ...cmd.slice(2)];
528
- }
529
- }
724
+ // Resolve command: prefer local (venv/vendor/node_modules) over global
725
+ const resolved = formatter.resolveCommand
726
+ ? await formatter.resolveCommand(absolutePath, cwd)
727
+ : null;
728
+ const cmd =
729
+ resolved ??
730
+ formatter.command.map((c) => c.replace("$FILE", absolutePath));
530
731
 
531
732
  // Run formatter
532
733
  const result = safeSpawn(cmd[0], cmd.slice(1), { timeout: 15000, cwd });
@@ -39,6 +39,19 @@ import path from "node:path";
39
39
  // Global installation directory for pi-lens tools
40
40
  const TOOLS_DIR = path.join(process.cwd(), ".pi-lens", "tools");
41
41
 
42
+ // Debug flag - set via PI_LENS_DEBUG=1 or --debug
43
+ const DEBUG =
44
+ process.env.PI_LENS_DEBUG === "1" || process.argv.includes("--debug");
45
+
46
+ /**
47
+ * Log debug messages only when DEBUG is enabled
48
+ */
49
+ function debugLog(...args: unknown[]): void {
50
+ if (DEBUG) {
51
+ console.error("[auto-install:debug]", ...args);
52
+ }
53
+ }
54
+
42
55
  // --- Tool Definitions ---
43
56
 
44
57
  interface ToolDefinition {
@@ -75,6 +88,15 @@ const TOOLS: ToolDefinition[] = [
75
88
  binaryName: "pyright",
76
89
  },
77
90
  // Linting/formatting tools
91
+ {
92
+ id: "prettier",
93
+ name: "Prettier",
94
+ checkCommand: "prettier",
95
+ checkArgs: ["--version"],
96
+ installStrategy: "npm",
97
+ packageName: "prettier",
98
+ binaryName: "prettier",
99
+ },
78
100
  {
79
101
  id: "ruff",
80
102
  name: "Ruff",
@@ -237,22 +259,18 @@ async function verifyToolBinary(binPath: string): Promise<boolean> {
237
259
 
238
260
  proc.on("exit", (code) => {
239
261
  if (code === 0) {
240
- console.error(
241
- `[auto-install] Verified: ${binPath} (version: ${stdout.trim()})`,
242
- );
262
+ debugLog(`Verified: ${binPath} (version: ${stdout.trim()})`);
243
263
  resolve(true);
244
264
  } else {
245
- console.error(
246
- `[auto-install] Verification failed for ${binPath}: exit code ${code}, stderr: ${stderr}`,
247
- );
265
+ console.error(`[auto-install] Verification failed for ${binPath}`);
266
+ debugLog("Exit code:", code, "stderr:", stderr);
248
267
  resolve(false);
249
268
  }
250
269
  });
251
270
 
252
271
  proc.on("error", (err) => {
253
- console.error(
254
- `[auto-install] Verification failed for ${binPath}: ${err.message}`,
255
- );
272
+ console.error(`[auto-install] Verification failed for ${binPath}`);
273
+ debugLog("Error:", err.message);
256
274
  resolve(false);
257
275
  });
258
276
  });
@@ -322,11 +340,11 @@ async function installNpmTool(
322
340
  }
323
341
 
324
342
  // NEW: Verify the binary actually works before returning
325
- console.error(`[auto-install] Verifying ${binaryName}...`);
343
+ debugLog(`Verifying ${binaryName}...`);
326
344
  const isValid = await verifyToolBinary(binPath);
327
345
  if (!isValid) {
328
346
  console.error(
329
- `[auto-install] ${packageName} installed but verification failed. The binary may be corrupted.`,
347
+ `[auto-install] ${packageName} installed but verification failed (binary may be corrupted)`,
330
348
  );
331
349
  // Clean up the broken installation
332
350
  try {
@@ -358,9 +376,9 @@ async function installNpmTool(
358
376
  });
359
377
  } catch (err) {
360
378
  console.error(
361
- `[auto-install] Failed to install npm tool ${packageName}:`,
362
- err,
379
+ `[auto-install] Failed to install ${packageName}: ${(err as Error).message}`,
363
380
  );
381
+ debugLog("Full error:", err);
364
382
  return undefined;
365
383
  }
366
384
  }
@@ -394,9 +412,9 @@ async function installPipTool(
394
412
  });
395
413
  } catch (err) {
396
414
  console.error(
397
- `[auto-install] Failed to install pip tool ${packageName}:`,
398
- err,
415
+ `[auto-install] Failed to install ${packageName}: ${(err as Error).message}`,
399
416
  );
417
+ debugLog("Full error:", err);
400
418
  return undefined;
401
419
  }
402
420
  }
@@ -434,7 +452,10 @@ export async function installTool(toolId: string): Promise<boolean> {
434
452
  return false;
435
453
  }
436
454
  } catch (err) {
437
- console.error(`[auto-install] Failed to install ${tool.name}:`, err);
455
+ console.error(
456
+ `[auto-install] Failed to install ${tool.name}: ${(err as Error).message}`,
457
+ );
458
+ debugLog("Full error:", err);
438
459
  return false;
439
460
  }
440
461
  }
@@ -13,7 +13,10 @@ try {
13
13
 
14
14
  export interface LatencyEntry {
15
15
  type: "runner" | "tool_result" | "phase";
16
+ /** ISO timestamp when this entry was written (= finish time for runners) */
16
17
  ts?: string;
18
+ /** ISO timestamp when the runner/phase started — diff with ts = durationMs */
19
+ startedAt?: string;
17
20
  toolName?: string;
18
21
  filePath: string;
19
22
  fullPath?: string;
@@ -25,6 +28,12 @@ export interface LatencyEntry {
25
28
  status?: string;
26
29
  diagnosticCount?: number;
27
30
  semantic?: string;
31
+ /** For dispatch_complete: actual wall-clock time (groups run in parallel) */
32
+ wallClockMs?: number;
33
+ /** For dispatch_complete: sum of all individual runner durationMs */
34
+ sumMs?: number;
35
+ /** wallClockMs - sumMs ≥ 0 means parallelism saved this many ms */
36
+ parallelGainMs?: number;
28
37
  metadata?: Record<string, unknown>;
29
38
  }
30
39