pi-lens 3.8.39 → 3.8.40

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 (38) hide show
  1. package/CHANGELOG.md +41 -5
  2. package/clients/biome-client.ts +5 -4
  3. package/clients/dispatch/integration.ts +2 -0
  4. package/clients/lsp/client.ts +62 -27
  5. package/clients/lsp/index.ts +12 -1
  6. package/clients/lsp/launch.ts +107 -34
  7. package/clients/lsp/server.ts +76 -57
  8. package/clients/pipeline.ts +21 -5
  9. package/clients/read-guard-tool-lines.ts +15 -2
  10. package/clients/read-guard.ts +56 -36
  11. package/clients/runtime-session.ts +2 -0
  12. package/clients/runtime-tool-result.ts +24 -1
  13. package/clients/tool-policy.ts +1982 -1936
  14. package/commands/booboo.ts +33 -1
  15. package/index.ts +31 -9
  16. package/package.json +2 -2
  17. package/rules/rule-catalog.json +25 -1
  18. package/rules/tree-sitter-queries/cobol/lock-table-cobol.yml +35 -0
  19. package/rules/tree-sitter-queries/cpp/unnecessary-bit-ops.yml +58 -0
  20. package/rules/tree-sitter-queries/java/infinite-loop.yml +58 -0
  21. package/rules/tree-sitter-queries/java/infinite-recursion.yml +58 -0
  22. package/rules/tree-sitter-queries/java/mockito-initialized.yml +66 -0
  23. package/rules/tree-sitter-queries/java/name-capitalization-conflict.yml +54 -0
  24. package/rules/tree-sitter-queries/java/no-octal-values.yml +48 -0
  25. package/rules/tree-sitter-queries/java/resources-closed.yml +57 -0
  26. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +57 -0
  27. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +60 -0
  28. package/rules/tree-sitter-queries/java/unnecessary-bit-ops-java.yml +57 -0
  29. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +64 -0
  30. package/rules/tree-sitter-queries/plsql/lock-table.yml +42 -0
  31. package/rules/tree-sitter-queries/plsql/nchar-nvarchar2-bytes.yml +54 -0
  32. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +52 -0
  33. package/rules/tree-sitter-queries/typescript/default-not-last.yml +54 -0
  34. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +51 -0
  35. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +54 -0
  36. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +55 -0
  37. package/rules/tree-sitter-queries/typescript/self-assignment.yml +46 -0
  38. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +64 -0
@@ -12,10 +12,11 @@ import { existsSync, mkdirSync, readdirSync } from "node:fs";
12
12
  import { access, appendFile, mkdir, stat } from "node:fs/promises";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
- import { KIND_EXTENSIONS } from "../file-kinds.js"
15
+ import { KIND_EXTENSIONS } from "../file-kinds.js";
16
16
  import { ensureTool, getToolEnvironment } from "../installer/index.js";
17
17
  import { logLatency } from "../latency-logger.js";
18
18
  import { type LSPProcess, launchLSP } from "./launch.js";
19
+ import { normalizeMapKey } from "./path-utils.js";
19
20
 
20
21
  // --- Types ---
21
22
 
@@ -489,56 +490,90 @@ export function NearestRoot(
489
490
  excludePatterns?: string[],
490
491
  stopDir?: string,
491
492
  ): RootFunction {
493
+ // Per-instance caches — each NearestRoot(markers) call gets its own Map so
494
+ // different servers (e.g. TypeScript vs Go) with different marker sets never
495
+ // share entries. vi.resetModules() in tests resets module state between cases.
496
+ const cache = new Map<string, string>();
497
+ const inFlight = new Map<string, Promise<string | undefined>>();
498
+
492
499
  return async (file: string): Promise<string | undefined> => {
493
- let currentDir = path.resolve(path.dirname(file));
494
- const fsRoot = path.parse(currentDir).root;
495
- const stop = stopDir ? path.resolve(stopDir) : fsRoot;
500
+ // Cache key is the resolved directory — all files in the same dir share a root.
501
+ const startDir = path.resolve(path.dirname(file));
502
+ const dirKey = normalizeMapKey(startDir);
503
+
504
+ // Fast path: already resolved for this directory.
505
+ const cached = cache.get(dirKey);
506
+ if (cached !== undefined) return cached;
507
+
508
+ // In-flight deduplication: if N parallel pipelines edit files in the same
509
+ // directory simultaneously, only one stat-walk runs; the rest await the same
510
+ // promise. This is the main fix for parallel-turn LSP timeout spikes.
511
+ const flying = inFlight.get(dirKey);
512
+ if (flying) return flying;
513
+
514
+ const promise = (async (): Promise<string | undefined> => {
515
+ let currentDir = startDir;
516
+ const fsRoot = path.parse(currentDir).root;
517
+ const stop = stopDir ? path.resolve(stopDir) : fsRoot;
518
+
519
+ while (true) {
520
+ if (
521
+ stop !== fsRoot &&
522
+ currentDir.startsWith(stop + path.sep) === false &&
523
+ currentDir !== stop
524
+ ) {
525
+ break;
526
+ }
496
527
 
497
- while (true) {
498
- if (
499
- stop !== fsRoot &&
500
- currentDir.startsWith(stop + path.sep) === false &&
501
- currentDir !== stop
502
- ) {
503
- break;
504
- }
528
+ // Check exclude patterns — skip this dir (but keep walking up)
529
+ if (excludePatterns) {
530
+ let excluded = false;
531
+ for (const pattern of excludePatterns) {
532
+ try {
533
+ await stat(path.join(currentDir, pattern));
534
+ excluded = true;
535
+ break;
536
+ } catch {
537
+ /* not found */
538
+ }
539
+ }
540
+ if (excluded) {
541
+ currentDir = path.dirname(currentDir);
542
+ continue;
543
+ }
544
+ }
505
545
 
506
- // Check exclude patterns — skip this dir (but keep walking up)
507
- if (excludePatterns) {
508
- let excluded = false;
509
- for (const pattern of excludePatterns) {
546
+ // Check include patterns
547
+ for (const pattern of includePatterns) {
510
548
  try {
511
549
  await stat(path.join(currentDir, pattern));
512
- excluded = true;
513
- break;
550
+ return currentDir;
514
551
  } catch {
515
552
  /* not found */
516
553
  }
517
554
  }
518
- if (excluded) {
519
- currentDir = path.dirname(currentDir);
520
- continue;
521
- }
522
- }
523
555
 
524
- // Check include patterns
525
- for (const pattern of includePatterns) {
526
- try {
527
- await stat(path.join(currentDir, pattern));
528
- return currentDir;
529
- } catch {
530
- /* not found */
556
+ if (currentDir === stop || currentDir === fsRoot) {
557
+ break;
531
558
  }
532
- }
533
559
 
534
- if (currentDir === stop || currentDir === fsRoot) {
535
- break;
560
+ currentDir = path.dirname(currentDir);
536
561
  }
537
562
 
538
- currentDir = path.dirname(currentDir);
539
- }
563
+ return undefined;
564
+ })();
540
565
 
541
- return undefined;
566
+ inFlight.set(dirKey, promise);
567
+ try {
568
+ const result = await promise;
569
+ // Only cache successful hits. Undefined results are not cached so that
570
+ // a newly-created root marker (e.g. package.json added mid-session) is
571
+ // detected on the next call.
572
+ if (result !== undefined) cache.set(dirKey, result);
573
+ return result;
574
+ } finally {
575
+ inFlight.delete(dirKey);
576
+ }
542
577
  };
543
578
  }
544
579
 
@@ -1362,11 +1397,7 @@ export const NixServer = createInteractiveServer({
1362
1397
  export const BashServer: LSPServerInfo = {
1363
1398
  id: "bash",
1364
1399
  name: "Bash Language Server",
1365
- extensions: [
1366
- ".bash",
1367
- ".sh",
1368
- ".zsh",
1369
- ],
1400
+ extensions: [".bash", ".sh", ".zsh"],
1370
1401
  root: FileDirRoot,
1371
1402
  spawn(root, options) {
1372
1403
  return resolveAndLaunch(
@@ -1384,10 +1415,7 @@ export const BashServer: LSPServerInfo = {
1384
1415
  export const DockerServer: LSPServerInfo = {
1385
1416
  id: "docker",
1386
1417
  name: "Dockerfile Language Server",
1387
- extensions: [
1388
- ".dockerfile",
1389
- "Dockerfile",
1390
- ],
1418
+ extensions: [".dockerfile", "Dockerfile"],
1391
1419
  root: RootWithFallback(
1392
1420
  PriorityRoot([
1393
1421
  [
@@ -1525,9 +1553,7 @@ export const PrismaServer: LSPServerInfo = {
1525
1553
  export const VueServer: LSPServerInfo = {
1526
1554
  id: "vue",
1527
1555
  name: "Vue Language Server",
1528
- extensions: [
1529
- ".vue",
1530
- ],
1556
+ extensions: [".vue"],
1531
1557
  root: RootWithFallback(
1532
1558
  IgnoreHomeRoot(
1533
1559
  createRootDetector([
@@ -1577,9 +1603,7 @@ export const VueServer: LSPServerInfo = {
1577
1603
  export const SvelteServer: LSPServerInfo = {
1578
1604
  id: "svelte",
1579
1605
  name: "Svelte Language Server",
1580
- extensions: [
1581
- ".svelte",
1582
- ],
1606
+ extensions: [".svelte"],
1583
1607
  root: RootWithFallback(
1584
1608
  IgnoreHomeRoot(
1585
1609
  createRootDetector([
@@ -1621,12 +1645,7 @@ export const ESLintServer: LSPServerInfo = {
1621
1645
  id: "eslint",
1622
1646
  name: "ESLint Language Server",
1623
1647
  // Note: .ts/.tsx handled by TypeScript LSP + Biome
1624
- extensions: [
1625
- ".js",
1626
- ".jsx",
1627
- ".svelte",
1628
- ".vue",
1629
- ],
1648
+ extensions: [".js", ".jsx", ".svelte", ".vue"],
1630
1649
  root: IgnoreHomeRoot(
1631
1650
  createRootDetector([
1632
1651
  ".eslintrc",
@@ -725,7 +725,12 @@ export async function runFormatPhase(
725
725
  if (result.anyChanged) {
726
726
  formatChanged = true;
727
727
  dbg(
728
- "autoformat: " + result.formatters.map((f) => f.name + "(" + (f.changed ? "changed" : "unchanged") + ")").join(", "),
728
+ "autoformat: " +
729
+ result.formatters
730
+ .map(
731
+ (f) => f.name + "(" + (f.changed ? "changed" : "unchanged") + ")",
732
+ )
733
+ .join(", "),
729
734
  );
730
735
  }
731
736
  if (!result.allSucceeded) {
@@ -734,7 +739,10 @@ export async function runFormatPhase(
734
739
  ...failures.map((f) => `${f.name}: ${f.error ?? "unknown error"}`),
735
740
  );
736
741
  dbg(
737
- "autoformat: " + failures.map((f) => f.name + " failed: " + (f.error ?? "unknown error")).join("; "),
742
+ "autoformat: " +
743
+ failures
744
+ .map((f) => f.name + " failed: " + (f.error ?? "unknown error"))
745
+ .join("; "),
738
746
  );
739
747
  }
740
748
  } catch (err) {
@@ -946,9 +954,17 @@ export async function runPipeline(
946
954
  const changedList = [...piChangedFiles].map((changedFile) =>
947
955
  toRunnerDisplayPath(cwd, changedFile),
948
956
  );
949
- const topFiles = changedList.slice(0, 8).map((f) => " - " + f).join("\n");
950
- const overflow = changedList.length > 8 ? "\n - ... and " + (changedList.length - 8) + " more" : "";
951
- const fileList = changedList.length ? "\nModified files:\n" + topFiles + overflow : "";
957
+ const topFiles = changedList
958
+ .slice(0, 8)
959
+ .map((f) => " - " + f)
960
+ .join("\n");
961
+ const overflow =
962
+ changedList.length > 8
963
+ ? "\n - ... and " + (changedList.length - 8) + " more"
964
+ : "";
965
+ const fileList = changedList.length
966
+ ? "\nModified files:\n" + topFiles + overflow
967
+ : "";
952
968
  output += `\n\n⚠️ **File was modified by auto-format/fix. You MUST re-read modified file(s) before making any further edits — the content on disk has changed (whitespace, indentation, quotes, or code). Editing from memory will produce mismatches.**${fileList}`;
953
969
  }
954
970
  phase.end("dispatch_lint", {
@@ -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
 
@@ -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({
@@ -136,6 +136,7 @@ export class ReadGuard {
136
136
  checkEdit(
137
137
  filePath: string,
138
138
  touchedLines?: [number, number],
139
+ editRanges?: [number, number][],
139
140
  ): ReadGuardVerdict {
140
141
  // Check exemptions
141
142
  if (this.exemptions.has(filePath)) {
@@ -195,44 +196,50 @@ export class ReadGuard {
195
196
  }
196
197
 
197
198
  // 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;
199
+ // When the edit touches multiple disjoint spots (e.g. rename across 4 tool
200
+ // registrations), check each spot independently. Collapsing to a bounding
201
+ // box would falsely flag reads that cover exactly the right lines.
202
+ const rangesToCheck: [number, number][] =
203
+ editRanges && editRanges.length > 1 ? editRanges : [touchedLines];
204
+
205
+ let viaSymbol = false;
206
+ for (const range of rangesToCheck) {
207
+ const coverage = this.checkCoverage(filePath, range);
208
+ if (!coverage.covered) {
209
+ const lastRead = fileReads[fileReads.length - 1];
210
+ const [editStart, editEnd] = range;
211
+ const lastReadEnd =
212
+ lastRead.effectiveOffset + lastRead.effectiveLimit - 1;
213
+ const verdict = this.blockOrWarn(
214
+ "out-of-range",
215
+ `🔴 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\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}"\``,
216
+ {
217
+ editRange: range,
218
+ readRanges: fileReads.map((r) => ({
219
+ start: r.effectiveOffset,
220
+ end: r.effectiveOffset + r.effectiveLimit - 1,
221
+ })),
222
+ symbolRanges: fileReads
223
+ .filter((r) => r.enclosingSymbol)
224
+ .map((r) => ({
225
+ name: r.enclosingSymbol!.name,
226
+ start: r.enclosingSymbol!.startLine,
227
+ end: r.enclosingSymbol!.endLine,
228
+ })),
229
+ },
230
+ );
231
+ this.recordVerdict(filePath, "edit", touchedLines, verdict, {
232
+ reasonKind: "out_of_range",
233
+ });
234
+ return verdict;
235
+ }
236
+ if (coverage.viaSymbol) viaSymbol = true;
207
237
  }
208
238
 
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
- );
239
+ const verdict = this.allow();
234
240
  this.recordVerdict(filePath, "edit", touchedLines, verdict, {
235
- reasonKind: "out_of_range",
241
+ reasonKind: viaSymbol ? "symbol_coverage" : "range_coverage",
242
+ viaSymbol,
236
243
  });
237
244
  return verdict;
238
245
  }
@@ -249,6 +256,15 @@ export class ReadGuard {
249
256
  }
250
257
  }
251
258
 
259
+ /**
260
+ * Refresh the FileTime stamp after the model's own write lands on disk.
261
+ * Call this from the tool_result handler so the next checkEdit on the same
262
+ * file doesn't see "file_modified" caused by our own previous edit.
263
+ */
264
+ recordWritten(filePath: string): void {
265
+ this.fileTime.read(filePath);
266
+ }
267
+
252
268
  /**
253
269
  * Add a one-time exemption for a file.
254
270
  * Called via /lens-allow-edit command.
@@ -473,7 +489,11 @@ export class ReadGuard {
473
489
  const reads = this.reads.get(filePath) ?? [];
474
490
  logReadGuardEvent({
475
491
  event:
476
- verdict.action === "allow" ? "edit_allowed" : (verdict.action === "warn" ? "edit_warned" : "edit_blocked"),
492
+ verdict.action === "allow"
493
+ ? "edit_allowed"
494
+ : verdict.action === "warn"
495
+ ? "edit_warned"
496
+ : "edit_blocked",
477
497
  sessionId: this.sessionId,
478
498
  filePath,
479
499
  metadata: {
@@ -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";
@@ -440,6 +441,7 @@ export async function handleSessionStart(
440
441
 
441
442
  metricsClient.reset();
442
443
  getDiagnosticTracker().reset();
444
+ clearFileTimeSessions();
443
445
  runtime.complexityBaselines.clear();
444
446
  resetDispatchBaselines();
445
447
  runtime.resetForSession();
@@ -4,6 +4,7 @@ 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";
8
9
  import { resolveLanguageRootForFile } from "./language-profile.js";
9
10
  import { logLatency } from "./latency-logger.js";
@@ -35,6 +36,7 @@ interface ToolResultDeps {
35
36
  resetLSPService: () => void;
36
37
  agentBehaviorRecord: (toolName: string, filePath?: string) => unknown[];
37
38
  formatBehaviorWarnings: (warnings: unknown[]) => string;
39
+ readGuard?: ReadGuard;
38
40
  }
39
41
 
40
42
  function parseDiffRanges(diff: string): { start: number; end: number }[] {
@@ -77,6 +79,13 @@ const lastAnalyzedStateByFile = new Map<
77
79
  { turnIndex: number; stateHash: string }
78
80
  >();
79
81
 
82
+ // Called at turn_start — entries from the previous turn can never match the new
83
+ // turnIndex so they're dead weight. Clearing here keeps the map bounded to the
84
+ // files touched in the current turn only (typically < 20).
85
+ export function clearLastAnalyzedStateCache(): void {
86
+ lastAnalyzedStateByFile.clear();
87
+ }
88
+
80
89
  function getFileStateHash(filePath: string): string {
81
90
  try {
82
91
  const content = nodeFs.readFileSync(filePath);
@@ -108,7 +117,9 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
108
117
  const rawFilePath = (event.input as { path?: string }).path;
109
118
  const workspaceRoot = runtime.projectRoot || process.cwd();
110
119
  const filePath = rawFilePath
111
- ? (path.isAbsolute(rawFilePath) ? rawFilePath : path.resolve(workspaceRoot, rawFilePath))
120
+ ? path.isAbsolute(rawFilePath)
121
+ ? rawFilePath
122
+ : path.resolve(workspaceRoot, rawFilePath)
112
123
  : rawFilePath;
113
124
  const behaviorWarnings = agentBehaviorRecord(event.toolName, filePath);
114
125
 
@@ -125,6 +136,10 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
125
136
  return;
126
137
  }
127
138
 
139
+ // Refresh the read-guard's FileTime stamp so that the model's own write
140
+ // doesn't trigger a spurious "file_modified" block on the next edit.
141
+ deps.readGuard?.recordWritten(filePath);
142
+
128
143
  // Keep cachedExports in sync after each write/edit so the pre-write STOP
129
144
  // check doesn't fire on names that were removed from this file this session.
130
145
  if (runtime.cachedExports.size > 0 && nodeFs.existsSync(filePath)) {
@@ -177,6 +192,14 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
177
192
  // tool_result is emitted after write/edit has already been applied.
178
193
  // Asserting pre-write stamps here produces false positives on rapid edits.
179
194
  sessionFileTime.read(filePath);
195
+ if (!getFlag("no-read-guard")) {
196
+ const readGuard = (
197
+ runtime as {
198
+ readGuard?: { recordWritten?: (writtenPath: string) => void };
199
+ }
200
+ ).readGuard;
201
+ readGuard?.recordWritten?.(filePath);
202
+ }
180
203
 
181
204
  const toolResultStart = Date.now();
182
205
  dbg(`tool_result: tracking turn state for ${event.toolName} on ${filePath}`);