pi-lens 3.8.39 → 3.8.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +84 -5
  2. package/README.md +37 -1
  3. package/clients/biome-client.ts +5 -4
  4. package/clients/cache/rule-cache.ts +1 -1
  5. package/clients/complexity-client.ts +1 -1
  6. package/clients/dependency-checker.ts +1 -1
  7. package/clients/dispatch/diagnostic-taxonomy.ts +13 -1
  8. package/clients/dispatch/dispatcher.ts +9 -0
  9. package/clients/dispatch/fact-scheduler.ts +1 -1
  10. package/clients/dispatch/integration.ts +58 -3
  11. package/clients/dispatch/runners/index.ts +2 -0
  12. package/clients/dispatch/runners/semgrep.ts +269 -0
  13. package/clients/dispatch/runners/shellcheck.ts +2 -8
  14. package/clients/dispatch/runners/tree-sitter.ts +32 -11
  15. package/clients/dispatch/tool-profile.ts +1 -0
  16. package/clients/format-service.ts +10 -0
  17. package/clients/formatters.ts +22 -8
  18. package/clients/installer/index.ts +3 -3
  19. package/clients/knip-client.ts +360 -362
  20. package/clients/lsp/aggregation.ts +91 -0
  21. package/clients/lsp/client.ts +91 -38
  22. package/clients/lsp/index.ts +88 -72
  23. package/clients/lsp/launch.ts +107 -34
  24. package/clients/lsp/server-strategies.ts +71 -0
  25. package/clients/lsp/server.ts +76 -57
  26. package/clients/path-utils.ts +17 -0
  27. package/clients/pipeline.ts +23 -5
  28. package/clients/production-readiness.ts +2 -2
  29. package/clients/read-guard-logger.ts +41 -1
  30. package/clients/read-guard-tool-lines.ts +17 -4
  31. package/clients/read-guard.ts +95 -46
  32. package/clients/runtime-agent-end.ts +3 -0
  33. package/clients/runtime-session.ts +5 -0
  34. package/clients/runtime-tool-result.ts +48 -1
  35. package/clients/runtime-turn.ts +48 -4
  36. package/clients/sanitize.ts +1 -1
  37. package/clients/semgrep-config.ts +213 -0
  38. package/clients/tool-policy.ts +1982 -1936
  39. package/clients/tree-sitter-client.ts +1 -1
  40. package/clients/widget-state.ts +283 -0
  41. package/commands/booboo.ts +34 -2
  42. package/index.ts +231 -17
  43. package/package.json +3 -2
  44. package/rules/rule-catalog.json +25 -1
  45. package/rules/tree-sitter-queries/cobol/lock-table-cobol.yml +35 -0
  46. package/rules/tree-sitter-queries/cpp/unnecessary-bit-ops.yml +58 -0
  47. package/rules/tree-sitter-queries/java/infinite-loop.yml +58 -0
  48. package/rules/tree-sitter-queries/java/infinite-recursion.yml +58 -0
  49. package/rules/tree-sitter-queries/java/mockito-initialized.yml +66 -0
  50. package/rules/tree-sitter-queries/java/name-capitalization-conflict.yml +54 -0
  51. package/rules/tree-sitter-queries/java/no-octal-values.yml +48 -0
  52. package/rules/tree-sitter-queries/java/resources-closed.yml +57 -0
  53. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +57 -0
  54. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +60 -0
  55. package/rules/tree-sitter-queries/java/unnecessary-bit-ops-java.yml +57 -0
  56. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +64 -0
  57. package/rules/tree-sitter-queries/plsql/lock-table.yml +42 -0
  58. package/rules/tree-sitter-queries/plsql/nchar-nvarchar2-bytes.yml +54 -0
  59. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +52 -0
  60. package/rules/tree-sitter-queries/typescript/default-not-last.yml +54 -0
  61. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +51 -0
  62. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +54 -0
  63. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +55 -0
  64. package/rules/tree-sitter-queries/typescript/self-assignment.yml +46 -0
  65. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +64 -0
@@ -4,6 +4,9 @@ import { logReadGuardEvent } from "./read-guard-logger.js";
4
4
 
5
5
  export interface GuardLineResult {
6
6
  touchedLines: [number, number] | undefined;
7
+ // Individual ranges for multi-edit calls (e.g. rename at 4 scattered spots).
8
+ // When set, read-guard checks each range independently instead of the bounding box.
9
+ editRanges?: [number, number][];
7
10
  preflightError?: string;
8
11
  }
9
12
 
@@ -98,7 +101,7 @@ function resolveOldTextEdits(
98
101
  if (occurrenceLines.length === 0) {
99
102
  const preview = oldText.trimStart().substring(0, 60).replace(/\n/g, "↵");
100
103
  errors.push(
101
- `edits[${editIndex}].oldText ("${preview}") was not found in the current file content. Re-read the file and refresh the edit target before retrying.`,
104
+ `edits[${editIndex}].oldText ("${preview}") was not found in the current file content. Re-read the relevant section of the file to confirm the exact text, then retry with the verbatim content.`,
102
105
  );
103
106
  logReadGuardEvent({
104
107
  event: "oldtext_not_found",
@@ -154,7 +157,7 @@ function resolveOldTextEdits(
154
157
  errors.length > 0
155
158
  ? errors
156
159
  : [
157
- "One or more edit targets could not be resolved to exact lines. Re-read the file and retry with more precise oldText context.",
160
+ "One or more edit targets could not be resolved to exact lines. Re-read the relevant section and retry with the exact content as it appears in the file.",
158
161
  ];
159
162
  return {
160
163
  touchedLines: undefined,
@@ -182,6 +185,7 @@ function resolveOldTextEdits(
182
185
  Math.min(...starts),
183
186
  Math.max(...ends),
184
187
  ];
188
+ const editRanges = resolvedRanges.length > 1 ? resolvedRanges : undefined;
185
189
  logReadGuardEvent({
186
190
  event: "touched_lines_detected",
187
191
  sessionId,
@@ -194,7 +198,7 @@ function resolveOldTextEdits(
194
198
  totalEditCount: edits.length,
195
199
  },
196
200
  });
197
- return { touchedLines };
201
+ return { touchedLines, editRanges };
198
202
  }
199
203
 
200
204
  /**
@@ -309,6 +313,7 @@ export function getTouchedLinesForGuard(
309
313
  return { touchedLines: undefined };
310
314
  }
311
315
  let oldTextTouchedLines: [number, number] | undefined;
316
+ let oldTextEditRanges: [number, number][] | undefined;
312
317
  if (unresolvedOldTextEdits.length > 0 && filePath) {
313
318
  const resolved = resolveOldTextEdits(
314
319
  unresolvedOldTextEdits,
@@ -319,6 +324,7 @@ export function getTouchedLinesForGuard(
319
324
  return resolved;
320
325
  }
321
326
  oldTextTouchedLines = resolved.touchedLines;
327
+ oldTextEditRanges = resolved.editRanges;
322
328
  }
323
329
  const starts = rangedEdits.map(([start]) => start);
324
330
  const ends = rangedEdits.map(([, end]) => end);
@@ -330,6 +336,13 @@ export function getTouchedLinesForGuard(
330
336
  Math.min(...starts),
331
337
  Math.max(...ends),
332
338
  ];
339
+ const allEditRanges = [...rangedEdits];
340
+ if (oldTextEditRanges?.length) {
341
+ allEditRanges.push(...oldTextEditRanges);
342
+ } else if (oldTextTouchedLines) {
343
+ allEditRanges.push(oldTextTouchedLines);
344
+ }
345
+ const editRanges = allEditRanges.length > 1 ? allEditRanges : undefined;
333
346
  if (filePath) {
334
347
  logReadGuardEvent({
335
348
  event: "touched_lines_detected",
@@ -348,7 +361,7 @@ export function getTouchedLinesForGuard(
348
361
  },
349
362
  });
350
363
  }
351
- return { touchedLines };
364
+ return { touchedLines, editRanges };
352
365
  }
353
366
  if (filePath) {
354
367
  logReadGuardEvent({
@@ -43,6 +43,7 @@ export interface EditRecord {
43
43
  precedingReads: ReadRecord[];
44
44
  verdict: "allowed" | "blocked" | "warned";
45
45
  reason?: string;
46
+ timestamp: number;
46
47
  }
47
48
 
48
49
  export interface ReadGuardVerdict {
@@ -78,6 +79,14 @@ const DEFAULT_CONFIG: ReadGuardConfig = {
78
79
  ],
79
80
  };
80
81
 
82
+ const OWN_EDIT_STALE_GRACE_MS = Math.max(
83
+ 0,
84
+ Number.parseInt(
85
+ process.env.PI_LENS_READ_GUARD_OWN_EDIT_GRACE_MS ?? "120000",
86
+ 10,
87
+ ) || 120000,
88
+ );
89
+
81
90
  // --- ReadGuard Class ---
82
91
 
83
92
  export class ReadGuard {
@@ -136,6 +145,7 @@ export class ReadGuard {
136
145
  checkEdit(
137
146
  filePath: string,
138
147
  touchedLines?: [number, number],
148
+ editRanges?: [number, number][],
139
149
  ): ReadGuardVerdict {
140
150
  // Check exemptions
141
151
  if (this.exemptions.has(filePath)) {
@@ -163,7 +173,7 @@ export class ReadGuard {
163
173
  if (!fileReads || fileReads.length === 0) {
164
174
  const verdict = this.blockOrWarn(
165
175
  "zero-read",
166
- `🔴 BLOCKED — Edit without read\n\nYou are trying to edit \`${filePath}\` but have not read it in this conversation.\n\nTo proceed:\n 1. Read the file first: \`read path="${filePath}"\`\n 2. Or run \`/lens-allow-edit ${filePath}\` to override (use sparingly)`,
176
+ `🔴 BLOCKED — Edit without read\n\nYou are trying to edit \`${filePath}\` but have not read it in this conversation.\n\nRead the file first, then retry the edit: \`read path="${filePath}"\``,
167
177
  );
168
178
  this.recordVerdict(filePath, "edit", touchedLines, verdict, {
169
179
  reasonKind: "zero_read",
@@ -172,17 +182,22 @@ export class ReadGuard {
172
182
  }
173
183
 
174
184
  // 2. FileTime check (actual staleness)
185
+ let ignoredOwnEditStaleness = false;
175
186
  if (this.fileTime.hasChanged(filePath)) {
176
187
  const lastRead = fileReads[fileReads.length - 1];
177
- const verdict = this.blockOrWarn(
178
- "file-modified",
179
- `🔴 BLOCKED — File modified since read\n\nYou last read \`${filePath}\` at ${new Date(lastRead.timestamp).toISOString()}.\nThe file has been modified on disk since then (auto-format, external tool, or previous edit).\n\nYour mental model is out of sync with the actual file content.\nTo proceed:\n 1. Re-read the file: \`read path="${filePath}"\``,
180
- );
181
- this.recordVerdict(filePath, "edit", touchedLines, verdict, {
182
- reasonKind: "file_modified",
183
- lastReadTimestamp: lastRead.timestamp,
184
- });
185
- return verdict;
188
+ if (this.canTreatStalenessAsOwnPriorEdit(filePath, lastRead.timestamp)) {
189
+ ignoredOwnEditStaleness = true;
190
+ } else {
191
+ const verdict = this.blockOrWarn(
192
+ "file-modified",
193
+ `🔴 BLOCKED — File modified since read\n\nYou last read \`${filePath}\` at ${new Date(lastRead.timestamp).toISOString()}.\nThe file has been modified on disk since then (auto-format, external tool, or previous edit).\n\nYour mental model is out of sync with the actual file content.\nTo proceed:\n 1. Re-read the file: \`read path="${filePath}"\``,
194
+ );
195
+ this.recordVerdict(filePath, "edit", touchedLines, verdict, {
196
+ reasonKind: "file_modified",
197
+ lastReadTimestamp: lastRead.timestamp,
198
+ });
199
+ return verdict;
200
+ }
186
201
  }
187
202
 
188
203
  // If no line range specified, we can only check zero-read and FileTime
@@ -195,44 +210,51 @@ export class ReadGuard {
195
210
  }
196
211
 
197
212
  // 3. Range coverage check
198
- const coverage = this.checkCoverage(filePath, touchedLines);
199
-
200
- if (coverage.covered) {
201
- const verdict = this.allow();
202
- this.recordVerdict(filePath, "edit", touchedLines, verdict, {
203
- reasonKind: coverage.viaSymbol ? "symbol_coverage" : "range_coverage",
204
- viaSymbol: coverage.viaSymbol,
205
- });
206
- return verdict;
213
+ // When the edit touches multiple disjoint spots (e.g. rename across 4 tool
214
+ // registrations), check each spot independently. Collapsing to a bounding
215
+ // box would falsely flag reads that cover exactly the right lines.
216
+ const rangesToCheck: [number, number][] =
217
+ editRanges && editRanges.length > 1 ? editRanges : [touchedLines];
218
+
219
+ let viaSymbol = false;
220
+ for (const range of rangesToCheck) {
221
+ const coverage = this.checkCoverage(filePath, range);
222
+ if (!coverage.covered) {
223
+ const lastRead = fileReads[fileReads.length - 1];
224
+ const [editStart, editEnd] = range;
225
+ const lastReadEnd =
226
+ lastRead.effectiveOffset + lastRead.effectiveLimit - 1;
227
+ const verdict = this.blockOrWarn(
228
+ "out-of-range",
229
+ `🔴 BLOCKED — Edit outside read range\n\nYou read \`${filePath}\` lines ${lastRead.effectiveOffset}-${lastReadEnd}${lastRead.enclosingSymbol ? ` (${lastRead.enclosingSymbol.kind} \`${lastRead.enclosingSymbol.name}\`)` : ""}, but your edit touches lines ${editStart}-${editEnd}.\n\nRead the relevant section first, then retry the edit:\n \`read path="${filePath}" offset=${Math.max(1, editStart - 5)} limit=${Math.min(30, editEnd - editStart + 10)}\``,
230
+ {
231
+ editRange: range,
232
+ readRanges: fileReads.map((r) => ({
233
+ start: r.effectiveOffset,
234
+ end: r.effectiveOffset + r.effectiveLimit - 1,
235
+ })),
236
+ symbolRanges: fileReads
237
+ .filter((r) => r.enclosingSymbol)
238
+ .map((r) => ({
239
+ name: r.enclosingSymbol!.name,
240
+ start: r.enclosingSymbol!.startLine,
241
+ end: r.enclosingSymbol!.endLine,
242
+ })),
243
+ },
244
+ );
245
+ this.recordVerdict(filePath, "edit", touchedLines, verdict, {
246
+ reasonKind: "out_of_range",
247
+ });
248
+ return verdict;
249
+ }
250
+ if (coverage.viaSymbol) viaSymbol = true;
207
251
  }
208
252
 
209
- // Not covered — block or warn
210
- const lastRead = fileReads[fileReads.length - 1];
211
- const [editStart, editEnd] = touchedLines;
212
- const lastReadEnd = lastRead.effectiveOffset + lastRead.effectiveLimit - 1;
213
- const symbolCtx = lastRead.enclosingSymbol
214
- ? ` (${lastRead.enclosingSymbol.kind} \`${lastRead.enclosingSymbol.name}\`)`
215
- : "";
216
- const verdict = this.blockOrWarn(
217
- "out-of-range",
218
- `🔴 BLOCKED — Edit outside read range\n\nYou read \`${filePath}\` lines ${lastRead.effectiveOffset}-${lastReadEnd}${symbolCtx}, but your edit touches lines ${editStart}-${editEnd}.\n\nThe edit target is outside the context you previously read.\nTo proceed:\n 1. Read the relevant section: \`read path="${filePath}" offset=${Math.max(1, editStart - 5)} limit=${Math.min(30, editEnd - editStart + 10)}\`\n 2. Or read the full file: \`read path="${filePath}"\``,
219
- {
220
- editRange: touchedLines,
221
- readRanges: fileReads.map((r) => ({
222
- start: r.effectiveOffset,
223
- end: r.effectiveOffset + r.effectiveLimit - 1,
224
- })),
225
- symbolRanges: fileReads
226
- .filter((r) => r.enclosingSymbol)
227
- .map((r) => ({
228
- name: r.enclosingSymbol!.name,
229
- start: r.enclosingSymbol!.startLine,
230
- end: r.enclosingSymbol!.endLine,
231
- })),
232
- },
233
- );
253
+ const verdict = this.allow();
234
254
  this.recordVerdict(filePath, "edit", touchedLines, verdict, {
235
- reasonKind: "out_of_range",
255
+ reasonKind: viaSymbol ? "symbol_coverage" : "range_coverage",
256
+ viaSymbol,
257
+ ignoredOwnEditStaleness,
236
258
  });
237
259
  return verdict;
238
260
  }
@@ -249,6 +271,15 @@ export class ReadGuard {
249
271
  }
250
272
  }
251
273
 
274
+ /**
275
+ * Refresh the FileTime stamp after the model's own write lands on disk.
276
+ * Call this from the tool_result handler so the next checkEdit on the same
277
+ * file doesn't see "file_modified" caused by our own previous edit.
278
+ */
279
+ recordWritten(filePath: string): void {
280
+ this.fileTime.read(filePath);
281
+ }
282
+
252
283
  /**
253
284
  * Add a one-time exemption for a file.
254
285
  * Called via /lens-allow-edit command.
@@ -331,6 +362,19 @@ export class ReadGuard {
331
362
 
332
363
  // --- Private helpers ---
333
364
 
365
+ private canTreatStalenessAsOwnPriorEdit(
366
+ filePath: string,
367
+ lastReadTimestamp: number,
368
+ ): boolean {
369
+ const edits = this.edits.get(filePath) ?? [];
370
+ const latest = edits.at(-1);
371
+ if (!latest) return false;
372
+ if (latest.verdict !== "allowed" && latest.verdict !== "warned")
373
+ return false;
374
+ if (latest.timestamp < lastReadTimestamp) return false;
375
+ return Date.now() - latest.timestamp <= OWN_EDIT_STALE_GRACE_MS;
376
+ }
377
+
334
378
  private checkCoverage(
335
379
  filePath: string,
336
380
  touchedLines: [number, number],
@@ -457,6 +501,7 @@ export class ReadGuard {
457
501
  precedingReads: this.reads.get(filePath) ?? [],
458
502
  verdict: mapVerdictAction(verdict.action),
459
503
  reason: verdict.reason,
504
+ timestamp: Date.now(),
460
505
  });
461
506
  this.edits.set(filePath, arr);
462
507
  }
@@ -473,7 +518,11 @@ export class ReadGuard {
473
518
  const reads = this.reads.get(filePath) ?? [];
474
519
  logReadGuardEvent({
475
520
  event:
476
- verdict.action === "allow" ? "edit_allowed" : (verdict.action === "warn" ? "edit_warned" : "edit_blocked"),
521
+ verdict.action === "allow"
522
+ ? "edit_allowed"
523
+ : verdict.action === "warn"
524
+ ? "edit_warned"
525
+ : "edit_blocked",
477
526
  sessionId: this.sessionId,
478
527
  filePath,
479
528
  metadata: {
@@ -84,6 +84,9 @@ export async function handleAgentEnd({
84
84
 
85
85
  if (result.formatChanged) {
86
86
  summary.changed.push(filePath);
87
+ if (!getFlag("no-read-guard")) {
88
+ runtime.readGuard.recordWritten(filePath);
89
+ }
87
90
  try {
88
91
  const content = nodeFs.readFileSync(filePath, "utf-8");
89
92
  const lineCount = content.split("\n").length;
@@ -5,6 +5,7 @@ import type { BiomeClient } from "./biome-client.js";
5
5
  import type { CacheManager } from "./cache-manager.js";
6
6
  import type { DependencyChecker } from "./dependency-checker.js";
7
7
  import { getDiagnosticTracker } from "./diagnostic-tracker.js";
8
+ import { clearAllSessions as clearFileTimeSessions } from "./file-time.js";
8
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";
@@ -15,6 +16,7 @@ import {
15
16
  getDefaultStartupTools,
16
17
  } from "./language-profile.js";
17
18
  import { runLogCleanup } from "./log-cleanup.js";
19
+ import { setSessionLanguages } from "./widget-state.js";
18
20
  import { initLSPConfig, loadLSPConfig } from "./lsp/config.js";
19
21
  import { getLSPService } from "./lsp/index.js";
20
22
  import type { MetricsClient } from "./metrics-client.js";
@@ -440,6 +442,7 @@ export async function handleSessionStart(
440
442
 
441
443
  metricsClient.reset();
442
444
  getDiagnosticTracker().reset();
445
+ clearFileTimeSessions();
443
446
  runtime.complexityBaselines.clear();
444
447
  resetDispatchBaselines();
445
448
  runtime.resetForSession();
@@ -624,6 +627,8 @@ export async function handleSessionStart(
624
627
  }
625
628
  }
626
629
 
630
+ setSessionLanguages(languageProfile.detectedKinds);
631
+
627
632
  dbg(
628
633
  `session_start total: ${Date.now() - sessionStartMs}ms (interactive path; background tasks may continue)`,
629
634
  );
@@ -4,7 +4,9 @@ import * as path from "node:path";
4
4
  import type { BiomeClient } from "./biome-client.js";
5
5
  import type { CacheManager } from "./cache-manager.js";
6
6
  import { createFileTime } from "./file-time.js";
7
+ import type { ReadGuard } from "./read-guard.js";
7
8
  import { getFormatService } from "./format-service.js";
9
+ import { isExternalOrVendorFile } from "./path-utils.js";
8
10
  import { resolveLanguageRootForFile } from "./language-profile.js";
9
11
  import { logLatency } from "./latency-logger.js";
10
12
  import type { MetricsClient } from "./metrics-client.js";
@@ -35,6 +37,7 @@ interface ToolResultDeps {
35
37
  resetLSPService: () => void;
36
38
  agentBehaviorRecord: (toolName: string, filePath?: string) => unknown[];
37
39
  formatBehaviorWarnings: (warnings: unknown[]) => string;
40
+ readGuard?: ReadGuard;
38
41
  }
39
42
 
40
43
  function parseDiffRanges(diff: string): { start: number; end: number }[] {
@@ -77,6 +80,13 @@ const lastAnalyzedStateByFile = new Map<
77
80
  { turnIndex: number; stateHash: string }
78
81
  >();
79
82
 
83
+ // Called at turn_start — entries from the previous turn can never match the new
84
+ // turnIndex so they're dead weight. Clearing here keeps the map bounded to the
85
+ // files touched in the current turn only (typically < 20).
86
+ export function clearLastAnalyzedStateCache(): void {
87
+ lastAnalyzedStateByFile.clear();
88
+ }
89
+
80
90
  function getFileStateHash(filePath: string): string {
81
91
  try {
82
92
  const content = nodeFs.readFileSync(filePath);
@@ -108,7 +118,9 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
108
118
  const rawFilePath = (event.input as { path?: string }).path;
109
119
  const workspaceRoot = runtime.projectRoot || process.cwd();
110
120
  const filePath = rawFilePath
111
- ? (path.isAbsolute(rawFilePath) ? rawFilePath : path.resolve(workspaceRoot, rawFilePath))
121
+ ? path.isAbsolute(rawFilePath)
122
+ ? rawFilePath
123
+ : path.resolve(workspaceRoot, rawFilePath)
112
124
  : rawFilePath;
113
125
  const behaviorWarnings = agentBehaviorRecord(event.toolName, filePath);
114
126
 
@@ -124,6 +136,16 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
124
136
  );
125
137
  return;
126
138
  }
139
+ if (isExternalOrVendorFile(filePath, workspaceRoot)) {
140
+ dbg(
141
+ `tool_result: skipped pipeline - file outside project root or in node_modules: ${filePath}`,
142
+ );
143
+ return;
144
+ }
145
+
146
+ // Refresh the read-guard's FileTime stamp so that the model's own write
147
+ // doesn't trigger a spurious "file_modified" block on the next edit.
148
+ deps.readGuard?.recordWritten(filePath);
127
149
 
128
150
  // Keep cachedExports in sync after each write/edit so the pre-write STOP
129
151
  // check doesn't fire on names that were removed from this file this session.
@@ -177,6 +199,14 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
177
199
  // tool_result is emitted after write/edit has already been applied.
178
200
  // Asserting pre-write stamps here produces false positives on rapid edits.
179
201
  sessionFileTime.read(filePath);
202
+ if (!getFlag("no-read-guard")) {
203
+ const readGuard = (
204
+ runtime as {
205
+ readGuard?: { recordWritten?: (writtenPath: string) => void };
206
+ }
207
+ ).readGuard;
208
+ readGuard?.recordWritten?.(filePath);
209
+ }
180
210
 
181
211
  const toolResultStart = Date.now();
182
212
  dbg(`tool_result: tracking turn state for ${event.toolName} on ${filePath}`);
@@ -307,6 +337,23 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
307
337
  stateHash: getFileStateHash(filePath),
308
338
  });
309
339
 
340
+ // The model's write/edit and pi-lens' own immediate format/autofix are now
341
+ // reflected on disk. Refresh read-guard staleness stamps so a follow-up edit
342
+ // is judged by read-range coverage, not by our own previous write.
343
+ if (!getFlag("no-read-guard")) {
344
+ const changedForReadGuard = new Set([
345
+ path.resolve(filePath),
346
+ ...(result.changedFiles ?? []).map((changedFile) =>
347
+ path.resolve(changedFile),
348
+ ),
349
+ ]);
350
+ for (const changedFile of changedForReadGuard) {
351
+ if (nodeFs.existsSync(changedFile)) {
352
+ deps.readGuard?.recordWritten(changedFile);
353
+ }
354
+ }
355
+ }
356
+
310
357
  if (
311
358
  !result.isError &&
312
359
  !getFlag("no-autoformat") &&
@@ -11,6 +11,7 @@ import {
11
11
  import { getKnipIgnorePatterns } from "./file-utils.js";
12
12
  import type { JscpdClient } from "./jscpd-client.js";
13
13
  import type { KnipClient, KnipIssue } from "./knip-client.js";
14
+ import { logLatency } from "./latency-logger.js";
14
15
  import { RUNTIME_CONFIG } from "./runtime-config.js";
15
16
  import type { RuntimeCoordinator } from "./runtime-coordinator.js";
16
17
  import type { TestRunnerClient } from "./test-runner-client.js";
@@ -117,6 +118,7 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
117
118
  return;
118
119
  }
119
120
 
121
+ const turnEndStart = Date.now();
120
122
  const blockerParts: string[] = [];
121
123
 
122
124
  // Merge accumulated cascade results from all pipeline runs this turn.
@@ -124,6 +126,7 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
124
126
  // 1. Primary-level: dedup by primary file (last writer wins).
125
127
  // 2. Neighbor-level: each neighbor is claimed by the latest cascade result
126
128
  // that covers it — suppresses stale neighbor state from earlier writes.
129
+ const t0 = Date.now();
127
130
  const cascadeResults = runtime.consumeCascadeResults();
128
131
  if (cascadeResults.length > 0) {
129
132
  const seen = new Map<string, (typeof cascadeResults)[number]>();
@@ -163,7 +166,16 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
163
166
  },
164
167
  });
165
168
  }
166
-
169
+ logLatency({
170
+ type: "phase",
171
+ toolName: "turn_end",
172
+ filePath: cwd,
173
+ phase: "cascade_merge",
174
+ durationMs: Date.now() - t0,
175
+ metadata: { resultCount: cascadeResults.length },
176
+ });
177
+
178
+ const t1 = Date.now();
167
179
  if (runtime.isStartupScanInFlight("jscpd")) {
168
180
  dbg("turn_end: skipping jscpd (startup scan still in flight)");
169
181
  } else if (await jscpdClient.ensureAvailable()) {
@@ -238,7 +250,15 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
238
250
  cacheManager.writeCache("jscpd", result, cwd);
239
251
  }
240
252
  }
241
-
253
+ logLatency({
254
+ type: "phase",
255
+ toolName: "turn_end",
256
+ filePath: cwd,
257
+ phase: "jscpd",
258
+ durationMs: Date.now() - t1,
259
+ });
260
+
261
+ const t2 = Date.now();
242
262
  if (runtime.isStartupScanInFlight("knip")) {
243
263
  dbg("turn_end: skipping knip (startup scan still in flight)");
244
264
  } else if (await knipClient.ensureAvailable()) {
@@ -283,7 +303,15 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
283
303
  }
284
304
  }
285
305
  }
286
-
306
+ logLatency({
307
+ type: "phase",
308
+ toolName: "turn_end",
309
+ filePath: cwd,
310
+ phase: "knip",
311
+ durationMs: Date.now() - t2,
312
+ });
313
+
314
+ const t3 = Date.now();
287
315
  if (await depChecker.ensureAvailable()) {
288
316
  const madgeFiles = cacheManager.getFilesForMadge(cwd);
289
317
  if (madgeFiles.length > 0) {
@@ -308,6 +336,14 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
308
336
  }
309
337
  }
310
338
 
339
+ logLatency({
340
+ type: "phase",
341
+ toolName: "turn_end",
342
+ filePath: cwd,
343
+ phase: "madge",
344
+ durationMs: Date.now() - t3,
345
+ });
346
+
311
347
  // --- Test runner: fire once per turn after all edits are done ---
312
348
  // Runs for each unique test target across modified files; results appear
313
349
  // in the next turn's context injection alongside jscpd/madge findings.
@@ -386,7 +422,7 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
386
422
  `turn_end: ${blockerParts.length} blocker section(s) found, persisting for next context`,
387
423
  );
388
424
  const content = capTurnEndMessage(blockerParts.join("\n\n"));
389
- const signature = `${files.slice().sort().join("|")}::${content}`;
425
+ const signature = `${files.slice().sort((a, b) => a.localeCompare(b)).join("|")}::${content}`;
390
426
  const last = cacheManager.readCache<{ signature: string }>(
391
427
  "turn-end-findings-last",
392
428
  cwd,
@@ -407,5 +443,13 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
407
443
  }
408
444
 
409
445
  runtime.fixedThisTurn.clear();
446
+ logLatency({
447
+ type: "tool_result",
448
+ toolName: "turn_end",
449
+ filePath: cwd,
450
+ durationMs: Date.now() - turnEndStart,
451
+ result: blockerParts.length > 0 ? "blockers_found" : "clean",
452
+ metadata: { fileCount: files.length, blockerSections: blockerParts.length },
453
+ });
410
454
  resetFormatService();
411
455
  }
@@ -244,7 +244,7 @@ export function sanitizeGoOutput(output: string): string {
244
244
 
245
245
  return extractDiagnosticLines(output, (line) => {
246
246
  const clean = stripAnsi(line);
247
- return /^\.\/|\.go:/.test(clean) || isErrorLine(clean);
247
+ return /(?:^\.\/)|(?:\.go:)/.test(clean) || isErrorLine(clean);
248
248
  });
249
249
  }
250
250