pi-lens 3.8.18 → 3.8.21

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.
@@ -6,9 +6,17 @@ import type { BiomeClient } from "./biome-client.js";
6
6
  import type { CacheManager } from "./cache-manager.js";
7
7
  import type { DependencyChecker } from "./dependency-checker.js";
8
8
  import { getDiagnosticTracker } from "./diagnostic-tracker.js";
9
+ import { getKnipIgnorePatterns } from "./file-utils.js";
9
10
  import type { GoClient } from "./go-client.js";
10
11
  import type { JscpdClient } from "./jscpd-client.js";
11
12
  import type { KnipClient } from "./knip-client.js";
13
+ import {
14
+ detectProjectLanguageProfile,
15
+ getDefaultStartupTools,
16
+ hasLanguage,
17
+ isLanguageConfigured,
18
+ } from "./language-profile.js";
19
+ import { canRunStartupHeavyScans } from "./language-policy.js";
12
20
  import type { MetricsClient } from "./metrics-client.js";
13
21
  import {
14
22
  buildProjectIndex,
@@ -16,11 +24,11 @@ import {
16
24
  loadIndex,
17
25
  saveIndex,
18
26
  } from "./project-index.js";
19
- import { scanProjectRules } from "./rules-scanner.js";
20
27
  import type { RuffClient } from "./ruff-client.js";
28
+ import { scanProjectRules } from "./rules-scanner.js";
21
29
  import type { RuntimeCoordinator } from "./runtime-coordinator.js";
22
30
  import type { RustClient } from "./rust-client.js";
23
- import { getKnipIgnorePatterns } from "./file-utils.js";
31
+ import { safeSpawn } from "./safe-spawn.js";
24
32
  import { getSourceFiles } from "./scan-utils.js";
25
33
  import { resolveStartupScanContext } from "./startup-scan.js";
26
34
  import type { TestRunnerClient } from "./test-runner-client.js";
@@ -54,7 +62,43 @@ interface SessionStartDeps {
54
62
  resetLSPService: () => void;
55
63
  }
56
64
 
57
- export async function handleSessionStart(deps: SessionStartDeps): Promise<void> {
65
+ function isCommandAvailable(command: string, args: string[] = ["--version"]): boolean {
66
+ const result = safeSpawn(command, args, { timeout: 5000 });
67
+ return !result.error && result.status === 0;
68
+ }
69
+
70
+ function getLanguageInstallHints(
71
+ languageProfile: ReturnType<typeof detectProjectLanguageProfile>,
72
+ ): string[] {
73
+ const hints: string[] = [];
74
+ const hasStrongSignal = (
75
+ kind: "go" | "rust" | "ruby",
76
+ minCount = 3,
77
+ ): boolean => {
78
+ if (!hasLanguage(languageProfile, kind)) return false;
79
+ if (isLanguageConfigured(languageProfile, kind)) return true;
80
+ return (languageProfile.counts[kind] ?? 0) >= minCount;
81
+ };
82
+
83
+ if (hasStrongSignal("go") && !isCommandAvailable("gopls")) {
84
+ hints.push("Go detected: install gopls (`go install golang.org/x/tools/gopls@latest`).");
85
+ }
86
+ if (hasStrongSignal("rust") && !isCommandAvailable("rust-analyzer")) {
87
+ hints.push(
88
+ "Rust detected: install rust-analyzer (`rustup component add rust-analyzer`).",
89
+ );
90
+ }
91
+ if (hasStrongSignal("ruby") && !isCommandAvailable("ruby-lsp")) {
92
+ hints.push("Ruby detected: install ruby-lsp (`gem install ruby-lsp`).");
93
+ }
94
+
95
+ return hints;
96
+ }
97
+
98
+ export async function handleSessionStart(
99
+ deps: SessionStartDeps,
100
+ ): Promise<void> {
101
+ const sessionStartMs = Date.now();
58
102
  const {
59
103
  ctxCwd,
60
104
  getFlag,
@@ -100,6 +144,13 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
100
144
  delete process.env.PI_LENS_AUTO_INSTALL;
101
145
  }
102
146
 
147
+ if (getFlag("no-lsp-install")) {
148
+ process.env.PI_LENS_DISABLE_LSP_INSTALL = "1";
149
+ dbg("session_start: LSP install disabled (PI_LENS_DISABLE_LSP_INSTALL=1)");
150
+ } else {
151
+ delete process.env.PI_LENS_DISABLE_LSP_INSTALL;
152
+ }
153
+
103
154
  const tools: string[] = [];
104
155
  if (getFlag("lens-lsp") && !getFlag("no-lsp")) {
105
156
  tools.push("LSP Service");
@@ -126,34 +177,76 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
126
177
  }
127
178
  }
128
179
 
129
- if (getFlag("lens-lsp") && !getFlag("no-lsp")) {
130
- dbg("session_start: pre-installing TypeScript LSP...");
131
- ensureTool("typescript-language-server")
132
- .then((toolPath) => {
133
- if (toolPath) {
134
- dbg(`session_start: TypeScript LSP ready at ${toolPath}`);
135
- } else {
136
- console.error("[lens] TypeScript LSP installation failed");
137
- }
138
- })
139
- .catch((err) => {
140
- console.error("[lens] TypeScript LSP pre-install error:", err);
141
- });
142
- }
143
-
180
+ const hasWorkspaceCwd = typeof ctxCwd === "string" && ctxCwd.length > 0;
144
181
  const cwd = ctxCwd ?? process.cwd();
145
182
  const startupScan = resolveStartupScanContext(cwd);
146
183
  const scanRoot = startupScan.projectRoot ?? cwd;
147
- const analysisRoot = scanRoot;
148
- runtime.projectRoot = scanRoot;
184
+ const useScanRootForSignals =
185
+ startupScan.canWarmCaches || startupScan.reason === "too-many-source-files";
186
+ const analysisRoot = useScanRootForSignals ? scanRoot : cwd;
187
+ runtime.projectRoot = cwd;
188
+ const languageProfile = detectProjectLanguageProfile(analysisRoot);
149
189
  dbg(`session_start cwd: ${cwd}`);
150
190
  dbg(
151
191
  `session_start scan root: ${scanRoot} (warmCaches=${startupScan.canWarmCaches}${startupScan.reason ? `, reason=${startupScan.reason}` : ""})`,
152
192
  );
153
- if (analysisRoot !== cwd) {
193
+ dbg(`session_start analysis root: ${analysisRoot}`);
194
+ dbg(`session_start workspace root: ${runtime.projectRoot}`);
195
+ dbg(
196
+ `session_start language profile: ${languageProfile.detectedKinds.join(", ") || "none"}`,
197
+ );
198
+ dbg(
199
+ `session_start language counts: ${JSON.stringify(languageProfile.counts)} configured=${JSON.stringify(languageProfile.configured)}`,
200
+ );
201
+ dbg(`session_start workspace cwd available: ${hasWorkspaceCwd}`);
202
+ if (useScanRootForSignals && analysisRoot !== cwd) {
154
203
  dbg(`session_start: monorepo analysis root override -> ${analysisRoot}`);
155
204
  }
156
205
 
206
+ const lensLspEnabled = !!getFlag("lens-lsp") && !getFlag("no-lsp");
207
+ const startupDefaults = getDefaultStartupTools(languageProfile).filter((tool) => {
208
+ if (
209
+ (tool === "typescript-language-server" || tool === "pyright") &&
210
+ !lensLspEnabled
211
+ ) {
212
+ return false;
213
+ }
214
+ if (tool === "ruff" && getFlag("no-autofix-ruff")) {
215
+ return false;
216
+ }
217
+ return true;
218
+ });
219
+
220
+ if (startupDefaults.length > 0) {
221
+ dbg(`session_start: pre-install defaults -> ${startupDefaults.join(", ")}`);
222
+ for (const tool of startupDefaults) {
223
+ const startedAt = Date.now();
224
+ dbg(`session_start preinstall ${tool}: start`);
225
+ ensureTool(tool)
226
+ .then((toolPath) => {
227
+ if (toolPath) {
228
+ dbg(`session_start: ${tool} ready at ${toolPath}`);
229
+ dbg(
230
+ `session_start preinstall ${tool}: success (${Date.now() - startedAt}ms)`,
231
+ );
232
+ } else {
233
+ dbg(`session_start: ${tool} installation unavailable`);
234
+ dbg(
235
+ `session_start preinstall ${tool}: unavailable (${Date.now() - startedAt}ms)`,
236
+ );
237
+ }
238
+ })
239
+ .catch((err) => {
240
+ dbg(`session_start: ${tool} pre-install error: ${err}`);
241
+ dbg(
242
+ `session_start preinstall ${tool}: error (${Date.now() - startedAt}ms)`,
243
+ );
244
+ });
245
+ }
246
+ } else {
247
+ dbg("session_start: no language defaults selected for pre-install");
248
+ }
249
+
157
250
  {
158
251
  const pkgPath = path.join(analysisRoot, "package.json");
159
252
  try {
@@ -174,9 +267,7 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
174
267
  if (p) dbg(`session_start: prettier ready at ${p}`);
175
268
  else dbg("session_start: prettier install failed silently");
176
269
  })
177
- .catch((err) =>
178
- dbg(`session_start: prettier install error: ${err}`),
179
- );
270
+ .catch((err) => dbg(`session_start: prettier install error: ${err}`));
180
271
  }
181
272
  } catch {
182
273
  // no package.json at cwd root
@@ -196,14 +287,17 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
196
287
  dbg(`session_start tools: ${tools.join(", ")}`);
197
288
 
198
289
  const startupNotes: string[] = [];
199
- startupNotes.push(
200
- "📌 pi-lens active — fix any errors you find (including pre-existing). Prefer: lsp_navigation for definitions/references, ast_grep_search for code patterns, grep for text/TODO search.",
290
+ const agentStartupGuidance: string[] = [];
291
+ agentStartupGuidance.push(
292
+ "📌 pi-lens active — as you work on this project, fix any errors you encounter (including pre-existing). Prefer: lsp_navigation for definitions/references, ast_grep_search for code patterns, grep for text/TODO search.",
201
293
  );
202
294
 
203
295
  runtime.projectRulesScan = scanProjectRules(analysisRoot);
204
296
  if (runtime.projectRulesScan.hasCustomRules) {
205
297
  const ruleCount = runtime.projectRulesScan.rules.length;
206
- const sources = [...new Set(runtime.projectRulesScan.rules.map((r) => r.source))];
298
+ const sources = [
299
+ ...new Set(runtime.projectRulesScan.rules.map((r) => r.source)),
300
+ ];
207
301
  dbg(
208
302
  `session_start: found ${ruleCount} project rule(s) from ${sources.join(", ")}`,
209
303
  );
@@ -214,102 +308,196 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
214
308
  dbg("session_start: no project rules found");
215
309
  }
216
310
 
217
- const todoResult = todoScanner.scanDirectory(analysisRoot);
218
- dbg(
219
- `session_start TODO scan: ${todoResult.items.length} items (baseline stored)`,
220
- );
221
- cacheManager.writeCache("todo-baseline", { items: todoResult.items }, analysisRoot);
311
+ if (hasWorkspaceCwd) {
312
+ const installHints = getLanguageInstallHints(languageProfile);
313
+ dbg(`session_start tooling hints count: ${installHints.length}`);
314
+ if (installHints.length > 0) {
315
+ startupNotes.push(`🧰 Tooling hints: ${installHints.join(" ")}`);
316
+ }
317
+ } else {
318
+ dbg("session_start: skipping tooling hints (workspace cwd unavailable)");
319
+ }
320
+
321
+ if (agentStartupGuidance.length > 0) {
322
+ cacheManager.writeCache(
323
+ "session-start-guidance",
324
+ { content: agentStartupGuidance.join("\n") },
325
+ analysisRoot,
326
+ );
327
+ }
328
+
329
+ const sessionGeneration = runtime.sessionGeneration;
330
+ const runStartupTask = (name: string, task: () => Promise<void>): void => {
331
+ const startedAt = Date.now();
332
+ dbg(`session_start task ${name}: start`);
333
+ runtime.markStartupScanInFlight(name, sessionGeneration);
334
+ void task()
335
+ .then(() => {
336
+ dbg(`session_start task ${name}: success (${Date.now() - startedAt}ms)`);
337
+ })
338
+ .catch((err) => {
339
+ dbg(`session_start: ${name} background scan failed: ${err}`);
340
+ dbg(`session_start task ${name}: failed (${Date.now() - startedAt}ms)`);
341
+ })
342
+ .finally(() => {
343
+ runtime.clearStartupScanInFlight(name, sessionGeneration);
344
+ dbg(`session_start task ${name}: end`);
345
+ });
346
+ };
347
+
348
+ // Fire off heavy scans as background tasks — don't block session start.
349
+ // Each consumer already handles the "not ready yet" case gracefully
350
+ // (cachedExports.size > 0, cachedProjectIndex != null, cache miss paths).
222
351
 
223
352
  if (!startupScan.canWarmCaches) {
224
- dbg(`session_start: skipping heavy scans (${startupScan.reason ?? "unknown"})`);
353
+ dbg(
354
+ `session_start: skipping heavy scans (${startupScan.reason ?? "unknown"})`,
355
+ );
356
+ dbg(`session_start: skipping TODO scan (${startupScan.reason ?? "unknown"})`);
225
357
  } else {
226
- if (await knipClient.ensureAvailable()) {
227
- const cached = cacheManager.readCache<ReturnType<KnipClient["analyze"]>>(
228
- "knip",
229
- analysisRoot,
230
- );
231
- if (cached) {
232
- dbg(
233
- `session_start Knip: cache hit (${Math.round((Date.now() - new Date(cached.meta.timestamp).getTime()) / 1000)}s ago)`,
234
- );
235
- } else {
236
- const startMs = Date.now();
237
- const knipResult = knipClient.analyze(
238
- analysisRoot,
239
- getKnipIgnorePatterns(),
240
- );
241
- cacheManager.writeCache("knip", knipResult, analysisRoot, {
242
- scanDurationMs: Date.now() - startMs,
243
- });
244
- dbg("session_start Knip scan done");
245
- }
246
- } else {
247
- dbg("session_start Knip: not available");
358
+ const canRunJsTsHeavyScans = canRunStartupHeavyScans(
359
+ languageProfile,
360
+ "jsts",
361
+ );
362
+ const scanNames = ["todo"];
363
+ if (canRunJsTsHeavyScans) {
364
+ scanNames.push("knip", "jscpd", "ast-grep exports", "project index");
248
365
  }
366
+ dbg(
367
+ `session_start: launching background scans (${scanNames.join(", ")})`,
368
+ );
249
369
 
250
- if (await jscpdClient.ensureAvailable()) {
251
- const cached = cacheManager.readCache<ReturnType<JscpdClient["scan"]>>(
252
- "jscpd",
370
+ runStartupTask("todo", async () => {
371
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
372
+ const todoResult = todoScanner.scanDirectory(analysisRoot);
373
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
374
+ dbg(
375
+ `session_start TODO scan: ${todoResult.items.length} items (baseline stored)`,
376
+ );
377
+ cacheManager.writeCache(
378
+ "todo-baseline",
379
+ { items: todoResult.items },
253
380
  analysisRoot,
254
381
  );
255
- if (cached) {
256
- dbg("session_start jscpd: cache hit");
257
- } else {
258
- const startMs = Date.now();
259
- const jscpdResult = jscpdClient.scan(analysisRoot);
260
- cacheManager.writeCache("jscpd", jscpdResult, analysisRoot, {
261
- scanDurationMs: Date.now() - startMs,
262
- });
263
- dbg("session_start jscpd scan done");
264
- }
382
+ });
383
+
384
+ if (!canRunJsTsHeavyScans) {
385
+ dbg(
386
+ "session_start: skipping JS/TS startup scans (requires JS/TS language + project config)",
387
+ );
265
388
  } else {
266
- dbg("session_start jscpd: not available");
267
- }
389
+ // Knip dead code / unused exports
390
+ runStartupTask("knip", async () => {
391
+ if (await knipClient.ensureAvailable()) {
392
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
393
+ const cached = cacheManager.readCache<
394
+ ReturnType<KnipClient["analyze"]>
395
+ >("knip", analysisRoot);
396
+ if (cached) {
397
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
398
+ dbg(
399
+ `session_start Knip: cache hit (${Math.round((Date.now() - new Date(cached.meta.timestamp).getTime()) / 1000)}s ago)`,
400
+ );
401
+ } else {
402
+ const startMs = Date.now();
403
+ const knipResult = knipClient.analyze(
404
+ analysisRoot,
405
+ getKnipIgnorePatterns(),
406
+ );
407
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
408
+ cacheManager.writeCache("knip", knipResult, analysisRoot, {
409
+ scanDurationMs: Date.now() - startMs,
410
+ });
411
+ dbg(`session_start Knip scan done (${Date.now() - startMs}ms)`);
412
+ }
413
+ } else {
414
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
415
+ dbg("session_start Knip: not available");
416
+ }
417
+ });
268
418
 
269
- if (await astGrepClient.ensureAvailable()) {
270
- const exports = await astGrepClient.scanExports(analysisRoot, "typescript");
271
- dbg(`session_start exports scan: ${exports.size} functions found`);
272
- for (const [name, file] of exports) {
273
- runtime.cachedExports.set(name, file);
274
- }
275
- }
419
+ // jscpd duplicate code detection
420
+ runStartupTask("jscpd", async () => {
421
+ if (await jscpdClient.ensureAvailable()) {
422
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
423
+ const cached = cacheManager.readCache<ReturnType<JscpdClient["scan"]>>(
424
+ "jscpd",
425
+ analysisRoot,
426
+ );
427
+ if (cached) {
428
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
429
+ dbg("session_start jscpd: cache hit");
430
+ } else {
431
+ const startMs = Date.now();
432
+ const jscpdResult = jscpdClient.scan(analysisRoot);
433
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
434
+ cacheManager.writeCache("jscpd", jscpdResult, analysisRoot, {
435
+ scanDurationMs: Date.now() - startMs,
436
+ });
437
+ dbg(`session_start jscpd scan done (${Date.now() - startMs}ms)`);
438
+ }
439
+ } else {
440
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
441
+ dbg("session_start jscpd: not available");
442
+ }
443
+ });
276
444
 
277
- try {
278
- const existing = await loadIndex(analysisRoot);
279
- if (
280
- existing &&
281
- existing.entries.size > 0 &&
282
- (await isIndexFresh(analysisRoot))
283
- ) {
284
- runtime.cachedProjectIndex = existing;
285
- dbg(
286
- `session_start: loaded fresh project index (${existing.entries.size} entries)`,
287
- );
288
- } else {
289
- const sourceFiles = getSourceFiles(analysisRoot, true);
290
- const tsFiles = sourceFiles.filter(
291
- (f) => f.endsWith(".ts") || f.endsWith(".tsx"),
292
- );
293
- if (tsFiles.length > 0 && tsFiles.length <= 500) {
294
- runtime.cachedProjectIndex = await buildProjectIndex(
445
+ // ast-grep — export scan for duplicate detection
446
+ runStartupTask("ast-grep-exports", async () => {
447
+ if (await astGrepClient.ensureAvailable()) {
448
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
449
+ const exports = await astGrepClient.scanExports(
295
450
  analysisRoot,
296
- tsFiles,
451
+ "typescript",
297
452
  );
298
- await saveIndex(runtime.cachedProjectIndex, analysisRoot);
453
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
454
+ dbg(`session_start exports scan: ${exports.size} functions found`);
455
+ for (const [name, file] of exports) {
456
+ runtime.cachedExports.set(name, file);
457
+ }
458
+ }
459
+ });
460
+
461
+ // Project index — structural similarity detection
462
+ runStartupTask("project-index", async () => {
463
+ const existing = await loadIndex(analysisRoot);
464
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
465
+ if (
466
+ existing &&
467
+ existing.entries.size > 0 &&
468
+ (await isIndexFresh(analysisRoot))
469
+ ) {
470
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
471
+ runtime.cachedProjectIndex = existing;
299
472
  dbg(
300
- `session_start: built project index (${runtime.cachedProjectIndex.entries.size} entries from ${tsFiles.length} files)`,
473
+ `session_start: loaded fresh project index (${existing.entries.size} entries)`,
301
474
  );
302
475
  } else {
303
- dbg(`session_start: skipped project index (${tsFiles.length} files)`);
476
+ const sourceFiles = getSourceFiles(analysisRoot, true);
477
+ const tsFiles = sourceFiles.filter(
478
+ (f) => f.endsWith(".ts") || f.endsWith(".tsx"),
479
+ );
480
+ if (tsFiles.length > 0 && tsFiles.length <= 500) {
481
+ runtime.cachedProjectIndex = await buildProjectIndex(
482
+ analysisRoot,
483
+ tsFiles,
484
+ );
485
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
486
+ await saveIndex(runtime.cachedProjectIndex, analysisRoot);
487
+ dbg(
488
+ `session_start: built project index (${runtime.cachedProjectIndex.entries.size} entries from ${tsFiles.length} files)`,
489
+ );
490
+ } else {
491
+ if (!runtime.isCurrentSession(sessionGeneration)) return;
492
+ dbg(`session_start: skipped project index (${tsFiles.length} files)`);
493
+ }
304
494
  }
305
- }
306
- } catch (err) {
307
- dbg(`session_start: project index build failed: ${err}`);
495
+ });
308
496
  }
309
497
  }
310
498
 
311
499
  dbg(
312
- `session_start: scans complete (${startupNotes.length} startup note(s)), cached for commands`,
500
+ `session_start: background scans launched (${startupNotes.length} startup note(s))`,
313
501
  );
314
502
 
315
503
  const errorDebtEnabled = getFlag("error-debt");
@@ -368,4 +556,6 @@ export async function handleSessionStart(deps: SessionStartDeps): Promise<void>
368
556
  if (startupNotes.length > 0) {
369
557
  notify(startupNotes.join("\n"), "info");
370
558
  }
559
+
560
+ dbg(`session_start total: ${Date.now() - sessionStartMs}ms`);
371
561
  }
@@ -1,6 +1,7 @@
1
1
  import * as nodeFs from "node:fs";
2
- import { FileTimeError, createFileTime } from "./file-time.js";
2
+ import { createFileTime } from "./file-time.js";
3
3
  import { getFormatService } from "./format-service.js";
4
+ import { resolveLanguageRootForFile } from "./language-profile.js";
4
5
  import { logLatency } from "./latency-logger.js";
5
6
  import { runPipeline } from "./pipeline.js";
6
7
  import type { BiomeClient } from "./biome-client.js";
@@ -101,19 +102,16 @@ export async function handleToolResult(
101
102
  }
102
103
 
103
104
  const sessionFileTime = createFileTime("default");
104
- try {
105
- sessionFileTime.assert(filePath);
106
- } catch (err: unknown) {
107
- if (err instanceof FileTimeError) {
108
- dbg(`⚠️ FileTime warning: ${err.message}`);
109
- }
110
- }
105
+ // tool_result is emitted after write/edit has already been applied.
106
+ // Asserting pre-write stamps here produces false positives on rapid edits.
111
107
  sessionFileTime.read(filePath);
112
108
 
113
109
  const toolResultStart = Date.now();
114
110
  dbg(`tool_result: tracking turn state for ${event.toolName} on ${filePath}`);
115
111
 
116
- const cwd = runtime.projectRoot;
112
+ const workspaceRoot = runtime.projectRoot;
113
+ const cwd = resolveLanguageRootForFile(filePath, workspaceRoot);
114
+ dbg(`tool_result: resolved dispatch cwd ${cwd} for ${filePath}`);
117
115
  if (event.model || event.provider || event.sessionId || event.session?.id) {
118
116
  runtime.setTelemetryIdentity({
119
117
  model: event.model,
@@ -183,7 +181,7 @@ export async function handleToolResult(
183
181
  result = await runPipeline(
184
182
  {
185
183
  filePath,
186
- cwd: runtime.projectRoot,
184
+ cwd,
187
185
  toolName: event.toolName,
188
186
  modifiedRanges,
189
187
  telemetry: {
@@ -82,7 +82,9 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
82
82
  blockerParts.push(runtime.consumeLastCascadeOutput());
83
83
  }
84
84
 
85
- if (await jscpdClient.ensureAvailable()) {
85
+ if (runtime.isStartupScanInFlight("jscpd")) {
86
+ dbg("turn_end: skipping jscpd (startup scan still in flight)");
87
+ } else if (await jscpdClient.ensureAvailable()) {
86
88
  const jscpdFiles = cacheManager.getFilesForJscpd(cwd);
87
89
  if (jscpdFiles.length > 0) {
88
90
  dbg(`turn_end: jscpd scanning ${jscpdFiles.length} file(s)`);
@@ -135,7 +137,7 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
135
137
  report += ` ${displayA}:${clone.startA} ↔ ${displayB}:${clone.startB} (${clone.lines} lines)\n`;
136
138
  }
137
139
  if (firstPath) {
138
- report += ` Inspect first location with read ${firstPath}\n`;
140
+ report += ` First location: ${firstPath}\n`;
139
141
  }
140
142
  blockerParts.push(report);
141
143
  }
@@ -143,7 +145,9 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
143
145
  }
144
146
  }
145
147
 
146
- if (await knipClient.ensureAvailable()) {
148
+ if (runtime.isStartupScanInFlight("knip")) {
149
+ dbg("turn_end: skipping knip (startup scan still in flight)");
150
+ } else if (await knipClient.ensureAvailable()) {
147
151
  const knipResult = knipClient.analyze(cwd, getKnipIgnorePatterns());
148
152
  const prevKnip = cacheManager.readCache<ReturnType<KnipClient["analyze"]>>(
149
153
  "knip",
@@ -178,7 +182,7 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
178
182
  report += ` ${display}${issue.line ? `:${issue.line}` : ""} — ${issue.type}: ${issue.name}\n`;
179
183
  }
180
184
  if (firstPath) {
181
- report += ` Inspect first location with read ${firstPath}\n`;
185
+ report += ` First location: ${firstPath}\n`;
182
186
  }
183
187
  blockerParts.push(report);
184
188
  }
@@ -231,7 +235,20 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
231
235
  `turn_end: ${blockerParts.length} blocker section(s) found, persisting for next context`,
232
236
  );
233
237
  const content = capTurnEndMessage(blockerParts.join("\n\n"));
238
+ const signature = `${files.slice().sort().join("|")}::${content}`;
239
+ const last = cacheManager.readCache<{ signature: string }>(
240
+ "turn-end-findings-last",
241
+ cwd,
242
+ );
243
+ if (last?.data?.signature === signature) {
244
+ dbg("turn_end: duplicate blocker findings detected, suppressing re-prompt");
245
+ cacheManager.clearTurnState(cwd);
246
+ runtime.fixedThisTurn.clear();
247
+ resetFormatService();
248
+ return;
249
+ }
234
250
  cacheManager.writeCache("turn-end-findings", { content }, cwd);
251
+ cacheManager.writeCache("turn-end-findings-last", { signature }, cwd);
235
252
  } else {
236
253
  cacheManager.clearTurnState(cwd);
237
254
  }
@@ -91,7 +91,12 @@ export class TodoScanner {
91
91
  const absolutePath = path.resolve(filePath);
92
92
  if (!fs.existsSync(absolutePath)) return [];
93
93
 
94
- const content = fs.readFileSync(absolutePath, "utf-8");
94
+ let content: string;
95
+ try {
96
+ content = fs.readFileSync(absolutePath, "utf-8");
97
+ } catch {
98
+ return [];
99
+ }
95
100
  const lines = content.split("\n");
96
101
  const items: TodoItem[] = [];
97
102
 
@@ -105,7 +105,7 @@ export class TypeCoverageClient {
105
105
  if (result.percentage >= 95) icon = "✓";
106
106
  else if (result.percentage >= 80) icon = "⚠";
107
107
 
108
- let output = `[type-coverage] ${icon} ${pct}% typed (${result.typed}/${result.total} identifiers)`;
108
+ let output = `[type-coverage] ${icon} ${pct}% typed (${result.typed}/${result.total} identifiers; any-typed flagged)`;
109
109
 
110
110
  if (result.untypedLocations.length === 0) {
111
111
  output += " — fully typed\n";
@@ -910,6 +910,8 @@ export async function handleBooboo(
910
910
  });
911
911
 
912
912
  let fullSection = `## Type Coverage\n\n**${tcResult.percentage.toFixed(1)}% typed** (${tcResult.typed}/${tcResult.total} identifiers)\n\n`;
913
+ fullSection +=
914
+ "Type coverage highlights identifiers that resolve to `any` (implicit or explicit). Inferred non-`any` types are treated as typed.\n\n";
913
915
  const byFile: Record<string, number> = {};
914
916
  for (const u of filteredLocations) {
915
917
  byFile[u.file] = (byFile[u.file] || 0) + 1;
@@ -920,7 +922,7 @@ export async function handleBooboo(
920
922
  .slice(0, 10);
921
923
 
922
924
  if (sortedFiles.length > 0) {
923
- fullSection += `### Top Files by Untyped Count\n\n| File | Untyped Count |\n|------|---------------|\n`;
925
+ fullSection += `### Top Files by Any-Typed Identifier Count\n\n| File | Any-Typed Count |\n|------|-----------------|\n`;
924
926
  for (const [file, count] of sortedFiles) {
925
927
  fullSection += `| ${file} | ${count} |\n`;
926
928
  }