pi-lens 4.1.5 → 4.2.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.
Files changed (219) hide show
  1. package/CHANGELOG.md +415 -0
  2. package/README.md +13 -8
  3. package/config/dependency-cruiser-eager-allowlist.json +6 -1
  4. package/dist/clients/actionable-warnings.js +30 -4
  5. package/dist/clients/agent-behavior-client.js +2 -0
  6. package/dist/clients/analysed-root.js +1 -0
  7. package/dist/clients/ast-grep-tool-logger.js +1 -1
  8. package/dist/clients/bash-file-access.js +26 -0
  9. package/dist/clients/biome-client.js +13 -2
  10. package/dist/clients/blocker-freshness.js +403 -35
  11. package/dist/clients/bootstrap.js +6 -1
  12. package/dist/clients/bounded-telemetry.js +2 -0
  13. package/dist/clients/cache-observability.js +122 -1
  14. package/dist/clients/cascade-format.js +200 -4
  15. package/dist/clients/code-quality-warnings.js +10 -2
  16. package/dist/clients/complexity-client.js +5 -0
  17. package/dist/clients/config-diagnostic-codes.js +2 -0
  18. package/dist/clients/config-resolve.js +6 -3
  19. package/dist/clients/config-schema.js +17 -0
  20. package/dist/clients/config-warn.js +2 -2
  21. package/dist/clients/dead-code-client.js +8 -1
  22. package/dist/clients/degradation-ledger.js +16 -1
  23. package/dist/clients/dependency-checker.js +5 -1
  24. package/dist/clients/diagnostic-dispositions.js +12 -1
  25. package/dist/clients/dispatch/ast-grep-catalog.js +65 -0
  26. package/dist/clients/dispatch/auxiliary-lsp.js +28 -0
  27. package/dist/clients/dispatch/dispatcher.js +47 -17
  28. package/dist/clients/dispatch/facts/function-facts.js +1 -1
  29. package/dist/clients/dispatch/facts/import-facts.js +5 -1
  30. package/dist/clients/dispatch/finding-policy.js +222 -0
  31. package/dist/clients/dispatch/indent-detect.js +389 -13
  32. package/dist/clients/dispatch/integration.js +175 -25
  33. package/dist/clients/dispatch/rule-ignores.js +62 -0
  34. package/dist/clients/dispatch/runners/actionlint.js +2 -1
  35. package/dist/clients/dispatch/runners/ast-grep-napi.js +21 -41
  36. package/dist/clients/dispatch/runners/biome-check.js +8 -4
  37. package/dist/clients/dispatch/runners/cpp-check.js +4 -3
  38. package/dist/clients/dispatch/runners/credo.js +2 -1
  39. package/dist/clients/dispatch/runners/cue-vet.js +2 -1
  40. package/dist/clients/dispatch/runners/dart-analyze.js +2 -1
  41. package/dist/clients/dispatch/runners/detekt.js +2 -1
  42. package/dist/clients/dispatch/runners/dotnet-build.js +2 -1
  43. package/dist/clients/dispatch/runners/elixir-check.js +2 -1
  44. package/dist/clients/dispatch/runners/eslint.js +2 -1
  45. package/dist/clients/dispatch/runners/fish-indent.js +2 -1
  46. package/dist/clients/dispatch/runners/gleam-check.js +2 -1
  47. package/dist/clients/dispatch/runners/go-vet.js +2 -1
  48. package/dist/clients/dispatch/runners/golangci-lint.js +2 -1
  49. package/dist/clients/dispatch/runners/hadolint.js +2 -1
  50. package/dist/clients/dispatch/runners/helm-lint.js +3 -1
  51. package/dist/clients/dispatch/runners/helm-render.js +2 -1
  52. package/dist/clients/dispatch/runners/htmlhint.js +2 -1
  53. package/dist/clients/dispatch/runners/javac.js +2 -1
  54. package/dist/clients/dispatch/runners/ktlint.js +2 -1
  55. package/dist/clients/dispatch/runners/lsp.js +19 -1
  56. package/dist/clients/dispatch/runners/markdownlint.js +3 -2
  57. package/dist/clients/dispatch/runners/mypy.js +2 -1
  58. package/dist/clients/dispatch/runners/oxlint.js +2 -1
  59. package/dist/clients/dispatch/runners/php-lint.js +2 -1
  60. package/dist/clients/dispatch/runners/phpstan.js +2 -1
  61. package/dist/clients/dispatch/runners/prisma-validate.js +2 -1
  62. package/dist/clients/dispatch/runners/psscriptanalyzer.js +12 -12
  63. package/dist/clients/dispatch/runners/pyright.js +2 -1
  64. package/dist/clients/dispatch/runners/rubocop.js +2 -1
  65. package/dist/clients/dispatch/runners/ruff.js +2 -2
  66. package/dist/clients/dispatch/runners/rust-clippy.js +12 -11
  67. package/dist/clients/dispatch/runners/shellcheck.js +2 -1
  68. package/dist/clients/dispatch/runners/shfmt.js +4 -13
  69. package/dist/clients/dispatch/runners/spellcheck.js +2 -2
  70. package/dist/clients/dispatch/runners/spotbugs.js +2 -1
  71. package/dist/clients/dispatch/runners/sqlfluff.js +3 -2
  72. package/dist/clients/dispatch/runners/stylelint.js +3 -2
  73. package/dist/clients/dispatch/runners/swiftlint.js +2 -1
  74. package/dist/clients/dispatch/runners/taplo.js +2 -1
  75. package/dist/clients/dispatch/runners/terragrunt.js +2 -1
  76. package/dist/clients/dispatch/runners/tflint.js +2 -1
  77. package/dist/clients/dispatch/runners/tree-sitter.js +2 -21
  78. package/dist/clients/dispatch/runners/trivy-config.js +2 -1
  79. package/dist/clients/dispatch/runners/utils/availability-policy.js +5 -0
  80. package/dist/clients/dispatch/runners/utils/candidate-probe.js +2 -2
  81. package/dist/clients/dispatch/runners/utils/runner-helpers.js +8 -2
  82. package/dist/clients/dispatch/runners/vale.js +2 -1
  83. package/dist/clients/dispatch/runners/yamllint.js +4 -3
  84. package/dist/clients/dispatch/runners/zig-check.js +2 -1
  85. package/dist/clients/dispatch/types.js +4 -1
  86. package/dist/clients/disposition-publish.js +1 -0
  87. package/dist/clients/effective-config.js +12 -2
  88. package/dist/clients/extension-log.js +2 -0
  89. package/dist/clients/file-kinds.js +26 -1
  90. package/dist/clients/file-role.js +23 -6
  91. package/dist/clients/file-utils.js +156 -10
  92. package/dist/clients/finding-delivery-gate.js +60 -9
  93. package/dist/clients/format-service.js +39 -21
  94. package/dist/clients/formatters.js +113 -200
  95. package/dist/clients/generation-guard.js +6 -2
  96. package/dist/clients/git-guard.js +11 -3
  97. package/dist/clients/gitleaks-client.js +42 -6
  98. package/dist/clients/govulncheck-client.js +48 -7
  99. package/dist/clients/indent-retarget.js +78 -8
  100. package/dist/clients/installer/index.js +524 -161
  101. package/dist/clients/instance-reaper-state.js +7 -0
  102. package/dist/clients/instance-reaper.js +20 -10
  103. package/dist/clients/jscpd-client.js +9 -1
  104. package/dist/clients/knip-client.js +9 -2
  105. package/dist/clients/language-profile.js +35 -3
  106. package/dist/clients/language-registry.js +5 -1
  107. package/dist/clients/latency-logger.js +6 -1
  108. package/dist/clients/lens-config.js +4 -0
  109. package/dist/clients/lens-engine.js +12 -3
  110. package/dist/clients/lens-events.js +2 -1
  111. package/dist/clients/lens-flag-registry.js +79 -0
  112. package/dist/clients/live-bus-emitter.js +2 -1
  113. package/dist/clients/lsp/client.js +25 -21
  114. package/dist/clients/lsp/config.js +36 -7
  115. package/dist/clients/lsp/diagnostic-binding.js +24 -1
  116. package/dist/clients/lsp/document-drift.js +78 -0
  117. package/dist/clients/lsp/index.js +288 -45
  118. package/dist/clients/lsp/launch.js +16 -4
  119. package/dist/clients/lsp/server.js +79 -15
  120. package/dist/clients/lsp/workspace-diagnostics-cache.js +39 -5
  121. package/dist/clients/lsp-mutation.js +5 -3
  122. package/dist/clients/map-with-concurrency.js +12 -8
  123. package/dist/clients/mcp/analyze.js +3 -0
  124. package/dist/clients/mcp/session.js +38 -4
  125. package/dist/clients/ndjson-logger.js +24 -7
  126. package/dist/clients/observed-mutation.js +40 -2
  127. package/dist/clients/opaque-mutation-scan.js +23 -3
  128. package/dist/clients/opengrep-client.js +111 -14
  129. package/dist/clients/package-manager.js +2 -1
  130. package/dist/clients/path-utils.js +123 -3
  131. package/dist/clients/performance-report.js +5 -1
  132. package/dist/clients/php-cs-fixer-config.js +8 -3
  133. package/dist/clients/pipeline.js +77 -7
  134. package/dist/clients/project-diagnostics/cache.js +37 -5
  135. package/dist/clients/project-diagnostics/fresh-fetch.js +173 -18
  136. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +4 -1
  137. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +10 -9
  138. package/dist/clients/project-diagnostics/scanner.js +59 -3
  139. package/dist/clients/project-lens-config.js +48 -2
  140. package/dist/clients/read-guard-logger.js +2 -0
  141. package/dist/clients/read-guard.js +70 -11
  142. package/dist/clients/resource-sampler.js +147 -9
  143. package/dist/clients/review-graph-logger.js +2 -0
  144. package/dist/clients/ruff-client.js +18 -9
  145. package/dist/clients/runtime-agent-end.js +42 -5
  146. package/dist/clients/runtime-context.js +6 -2
  147. package/dist/clients/runtime-coordinator.js +82 -7
  148. package/dist/clients/runtime-session.js +33 -5
  149. package/dist/clients/runtime-tool-call.js +43 -12
  150. package/dist/clients/runtime-tool-result.js +334 -86
  151. package/dist/clients/runtime-turn.js +195 -18
  152. package/dist/clients/safe-spawn.js +258 -5
  153. package/dist/clients/scratch-tree-policy.js +6 -0
  154. package/dist/clients/security-scan-client.js +2 -2
  155. package/dist/clients/session-event-guard.js +119 -6
  156. package/dist/clients/sg-runner.js +25 -12
  157. package/dist/clients/sgconfig.js +79 -2
  158. package/dist/clients/situational-tool-telemetry.js +108 -0
  159. package/dist/clients/test-runner-client.js +258 -78
  160. package/dist/clients/test-runner-delivery.js +43 -0
  161. package/dist/clients/tool-agreement.js +423 -0
  162. package/dist/clients/tool-config.js +249 -0
  163. package/dist/clients/tool-cwd.js +270 -0
  164. package/dist/clients/tool-policy.js +163 -7
  165. package/dist/clients/tool-probe.js +53 -0
  166. package/dist/clients/tool-set-policy.js +42 -0
  167. package/dist/clients/tree-sitter-client.js +120 -0
  168. package/dist/clients/tree-sitter-query-loader.js +127 -189
  169. package/dist/clients/trivy-client.js +9 -1
  170. package/dist/clients/turn-context.js +52 -0
  171. package/dist/clients/widget-state.js +102 -3
  172. package/dist/clients/word-index.js +5 -2
  173. package/dist/clients/write-ordering-guard.js +7 -0
  174. package/dist/index.js +26968 -21741
  175. package/dist/mcp/analyze-cli.js +3 -2
  176. package/dist/mcp/server.js +140 -161
  177. package/dist/tools/activate-tools.js +10 -7
  178. package/dist/tools/ast-grep-outline.js +2 -13
  179. package/dist/tools/ast-grep-replace.js +8 -13
  180. package/dist/tools/ast-grep-search.js +83 -39
  181. package/dist/tools/effective-config.js +4 -3
  182. package/dist/tools/lens-diagnostic-mark.js +8 -11
  183. package/dist/tools/lens-diagnostics.js +516 -133
  184. package/dist/tools/lsp-diagnostics.js +265 -182
  185. package/dist/tools/lsp-navigation.js +20 -44
  186. package/dist/tools/module-report.js +5 -9
  187. package/dist/tools/project-report.js +9 -17
  188. package/dist/tools/render-compact.js +280 -1
  189. package/dist/tools/symbol-search.js +2 -2
  190. package/docs/agent-guide.md +3 -4
  191. package/docs/agent-tools.md +46 -22
  192. package/docs/audit1.md +5 -5
  193. package/docs/configuration.md +9 -0
  194. package/docs/dependencies.md +11 -0
  195. package/docs/dispositions.md +52 -0
  196. package/docs/environment-variables.md +2 -7
  197. package/docs/features.md +7 -7
  198. package/docs/globalconfig.md +14 -2
  199. package/docs/language-coverage.md +1 -0
  200. package/docs/mcp.md +13 -1
  201. package/docs/pi-lens-fixer.md +48 -18
  202. package/docs/pi-lens-investigator.md +38 -18
  203. package/docs/pi-lens-monitor.md +88 -0
  204. package/docs/pi-lens-retro.md +97 -0
  205. package/docs/pi-lens-reviewer.md +53 -31
  206. package/docs/pi-lens-subagent.md +12 -4
  207. package/docs/public-api-stability.md +1 -0
  208. package/docs/real-harness.md +46 -0
  209. package/docs/release-qa-baseline.md +27 -1
  210. package/docs/servercapabilities.md +1 -6
  211. package/docs/settings.md +44 -6
  212. package/docs/tools_improvement2.md +4 -4
  213. package/docs/usage.md +15 -3
  214. package/package.json +10 -1
  215. package/rules/tree-sitter-queries/typescript/sql-injection.yml +23 -7
  216. package/scripts/analyze-pi-lens-logs.mjs +40 -0
  217. package/skills/pi-lens-ast-grep/SKILL.md +23 -6
  218. package/skills/pi-lens-lsp-navigation/SKILL.md +26 -9
  219. package/dist/tools/ast-dump.js +0 -103
@@ -2,8 +2,9 @@ import * as nodeCrypto from "node:crypto";
2
2
  import * as nodeFs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { noteAuthoritativeContentAttachment } from "./agent-nudge.js";
5
- import { captureFileStats, diffFileStats, getOpaqueBaselineStore, recoverOpaqueChangesViaGit, } from "./opaque-mutation-scan.js";
5
+ import { captureFileStats, diffFileContent, getOpaqueBaselineStore, recoverOpaqueChangesViaGit, } from "./opaque-mutation-scan.js";
6
6
  import { normalizeMapKey } from "./path-utils.js";
7
+ import { detectFileKind } from "./file-kinds.js";
7
8
  import { extractReadPathsFromCommand, extractDeletedPathsFromCommand, extractGrepSearchReadsFromOutput, extractWrittenPathsFromCommand, tokenizeShellCommand, } from "./bash-file-access.js";
8
9
  import { registerSearchReads, } from "./search-read-registration.js";
9
10
  import { createFileTime } from "./file-time.js";
@@ -21,14 +22,22 @@ import { replayThroughMutationBridge, storedLineHashesFor, } from "./observed-mu
21
22
  import { getAmbientAbortSignal } from "./safe-spawn.js";
22
23
  import { resolveToolCallCorrelationId } from "./tool-event.js";
23
24
  import { boundedIndexesForCount, createReadGuardEditBatchSummary, getReadGuardCorrelationId, logReadGuardEvent, } from "./read-guard-logger.js";
24
- import { notifyExternalFileChange } from "./lsp/index.js";
25
+ import { countFileLines } from "./read-guard-tool-lines.js";
26
+ import { notifyExternalFileChange, resyncGitChangedFiles, } from "./lsp/index.js";
25
27
  import { runPipeline } from "./pipeline.js";
26
28
  import { renderPostAutofixNotice, } from "./post-autofix-notice.js";
27
29
  import { syncGitGuardRecord } from "./git-guard.js";
28
30
  import { scheduleWordIndexPersist } from "./word-index.js";
29
31
  import { RUNTIME_CONFIG } from "./runtime-config.js";
30
32
  import { getActiveSessionId } from "./session-lifecycle.js";
33
+ import { requestBootstrapClients } from "./bootstrap.js";
34
+ import { bounded } from "./deadline-utils.js";
35
+ import { HOOK_WALL_BUDGET_MS } from "./hook-budgets.js";
36
+ import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
31
37
  const AUTHORITATIVE_CONTENT_MAX_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
38
+ // Keep same-turn analysis below the observed-directory capture bound so one
39
+ // opaque codemod cannot monopolize the hook or analyzer fleet.
40
+ const OBSERVED_DISPATCH_MAX_PATHS = 32;
32
41
  /**
33
42
  * Git subcommands that import ANOTHER commit's content into the index. The
34
43
  * whole family, with a verdict for each (#2060):
@@ -93,6 +102,29 @@ export function isFailedGitIntegrationCommand(command, isError) {
93
102
  return false;
94
103
  });
95
104
  }
105
+ function ensureToolResultClients(deps) {
106
+ if (deps.biomeClient && deps.ruffClient && deps.metricsClient)
107
+ return true;
108
+ const request = {
109
+ reason: "tool-result-analysis",
110
+ hook: "tool_result_edit",
111
+ timeoutMs: HOOK_WALL_BUDGET_MS.tool_result_edit,
112
+ ...(deps.signal === undefined ? {} : { signal: deps.signal }),
113
+ };
114
+ return bounded(requestBootstrapClients(request), {
115
+ ms: HOOK_WALL_BUDGET_MS.tool_result_edit,
116
+ signal: deps.signal,
117
+ hook: "tool_result_edit",
118
+ label: "tool-result-bootstrap-demand",
119
+ }).then((clients) => {
120
+ if (!clients)
121
+ return false;
122
+ deps.biomeClient = clients.biomeClient;
123
+ deps.ruffClient = clients.ruffClient;
124
+ deps.metricsClient = clients.metricsClient;
125
+ return true;
126
+ });
127
+ }
96
128
  function parseDiffRanges(diff) {
97
129
  const changedLines = [];
98
130
  for (const line of diff.split("\n")) {
@@ -144,13 +176,11 @@ export function clearLastAnalyzedStateCache() {
144
176
  * with one owner (#2464 review round 3, F1) rather than two open-coded blocks
145
177
  * ~80 lines apart whose invariants only line up if a reader checks both.
146
178
  *
147
- * Exported for the isolation red in
148
- * `tests/clients/observed-mutation-integration.test.ts`: with
149
- * `claimPipelineDispatch` below now consulted by BOTH dispatch call sites, the
150
- * identity guard in the release has no reachable production trigger left, so
151
- * the only honest way to prove it is to drive the seam directly.
179
+ * Kept private because both dispatch call sites now use the shared claim before
180
+ * registration; publishing this seam without the claim obligation would make
181
+ * the identity guard's precondition easy to violate again.
152
182
  */
153
- export function registerInFlightPipeline(filePath, stateHash, pipeline) {
183
+ function registerInFlightPipeline(filePath, stateHash, pipeline) {
154
184
  let filePipelines = inFlightPipelines.get(filePath);
155
185
  if (!filePipelines) {
156
186
  filePipelines = new Map();
@@ -172,7 +202,7 @@ export function registerInFlightPipeline(filePath, stateHash, pipeline) {
172
202
  * deduping and its `participantIds`/`participantTotal` under-count. Delete only
173
203
  * when the outer map still points at the very map this registration went into.
174
204
  */
175
- export function releaseInFlightPipeline(filePath, stateHash, registered) {
205
+ function releaseInFlightPipeline(filePath, stateHash, registered) {
176
206
  registered.delete(stateHash);
177
207
  if (registered.size === 0 && inFlightPipelines.get(filePath) === registered) {
178
208
  inFlightPipelines.delete(filePath);
@@ -208,7 +238,7 @@ export function releaseInFlightPipeline(filePath, stateHash, registered) {
208
238
  * already analysed this turn — the duplicate-recording inversion #2464 exists
209
239
  * to remove.
210
240
  */
211
- export function claimPipelineDispatch(args) {
241
+ function claimPipelineDispatch(args) {
212
242
  const { filePath, stateHash, turnIndex, participantId, dbg } = args;
213
243
  // Deduplicate concurrent calls for the same final file state (pi can fire one
214
244
  // tool_result per edit hunk). Do not dedupe by file alone: a distinct later
@@ -454,9 +484,10 @@ function refreshCachedExports(runtime, filePath) {
454
484
  * pipeline AND record" block could not express.
455
485
  */
456
486
  async function dispatchPipelineAnalysis(args) {
457
- const { deps, runtime, filePath, dispatchCwd, turnStateCwd, autofixMode, modifiedRanges, writeIndex, initialStateHash, readGuardCorrelationId, requestedEditIndexes, requestedEditTotal, isPartialApplyResult, toolResultStart, nativeAppliedPairs, } = args;
487
+ const { deps, runtime, filePath, dispatchCwd, turnStateCwd, autofixMode, modifiedRanges, writeIndex, initialStateHash, readGuardCorrelationId, requestedEditIndexes, requestedEditTotal, isPartialApplyResult, toolResultStart, nativeAppliedPairs, allowReadGuardWrites, } = args;
458
488
  const { event, getFlag, getFlagSource, dbg, biomeClient, ruffClient, metricsClient, resetLSPService, } = deps;
459
489
  const pipelinePromise = runPipeline({
490
+ signal: deps.signal,
460
491
  filePath,
461
492
  cwd: dispatchCwd,
462
493
  projectRoot: turnStateCwd,
@@ -497,9 +528,9 @@ async function dispatchPipelineAnalysis(args) {
497
528
  scheduleWordIndexPersist(dispatchCwd, index, dbg);
498
529
  },
499
530
  }, {
500
- biomeClient,
501
- ruffClient,
502
- metricsClient,
531
+ biomeClient: biomeClient,
532
+ ruffClient: ruffClient,
533
+ metricsClient: metricsClient,
503
534
  getFormatService,
504
535
  fixedThisTurn: runtime.fixedThisTurn,
505
536
  });
@@ -632,11 +663,20 @@ async function dispatchPipelineAnalysis(args) {
632
663
  // ladder (the #2402 after-write hash was never stamped), and a duplicate
633
664
  // tool_result for the same bytes analysed the file a second time (the
634
665
  // already-analysed latch was never set).
635
- const finalStateHash = getFileStateHash(filePath);
636
- lastAnalyzedStateByFile.set(filePath, {
637
- turnIndex: runtime.turnIndex,
638
- stateHash: finalStateHash,
639
- });
666
+ // The latch identifies the bytes this pipeline actually analysed. A pipeline
667
+ // that reports no write analysed its input state, even if another writer
668
+ // changed disk while the await was parked. A pipeline-reported write selects
669
+ // only the identity captured by runPipeline before analysis awaits (#2499).
670
+ const pipelineOwnedWriteHash = result.fileModified
671
+ ? result.postWriteStateHash
672
+ : undefined;
673
+ const finalStateHash = pipelineOwnedWriteHash ?? initialStateHash;
674
+ if (!result.fileModified || pipelineOwnedWriteHash !== undefined) {
675
+ lastAnalyzedStateByFile.set(filePath, {
676
+ turnIndex: runtime.turnIndex,
677
+ stateHash: finalStateHash,
678
+ });
679
+ }
640
680
  // #2402: pi-lens' own immediate format/autofix may have rewritten the file
641
681
  // after the native edit was recorded by the caller. Stamp the post-pipeline
642
682
  // state so an identical retry against the formatted bytes is still
@@ -645,7 +685,7 @@ async function dispatchPipelineAnalysis(args) {
645
685
  // `initialStateHash` IS the post-write hash at both call sites — the
646
686
  // classified chain passes `postWriteStateHash` verbatim, the observed path
647
687
  // passes the same pre-settle digest.
648
- if (finalStateHash !== initialStateHash) {
688
+ if (pipelineOwnedWriteHash !== undefined) {
649
689
  for (const pair of nativeAppliedPairs) {
650
690
  runtime.partialApplyRecords.noteAfterWriteHash(filePath, pair.oldText, pair.newText, finalStateHash);
651
691
  }
@@ -653,7 +693,7 @@ async function dispatchPipelineAnalysis(args) {
653
693
  // The model's write/edit and pi-lens' own immediate format/autofix are now
654
694
  // reflected on disk. Refresh read-guard staleness stamps so a follow-up edit
655
695
  // is judged by read-range coverage, not by our own previous write.
656
- if (!getFlag("no-read-guard")) {
696
+ if (!getFlag("no-read-guard") && allowReadGuardWrites) {
657
697
  const changedForReadGuard = new Set([
658
698
  path.resolve(filePath),
659
699
  ...(result.changedFiles ?? []).map((changedFile) => path.resolve(changedFile)),
@@ -670,6 +710,7 @@ export async function handleToolResult(deps) {
670
710
  const { event, getFlag, dbg, runtime, cacheManager, agentBehaviorRecord, formatBehaviorWarnings, } = deps;
671
711
  const rawFilePath = event.input.path;
672
712
  const workspaceRoot = runtime.projectRoot || process.cwd();
713
+ let bashAuthorshipConfirmed = deps._readGuardAuthorship ?? event.toolName !== "bash";
673
714
  // #1642: a gitignored worktree edit got re-attributed onto a
674
715
  // same-relative-path file in the parent checkout because this handler
675
716
  // used to always resolve a relative path against `workspaceRoot`
@@ -791,10 +832,16 @@ export async function handleToolResult(deps) {
791
832
  // THIS set, not raw recognized - otherwise a redirect target dropped
792
833
  // by the isError filter would be subtracted from recovery AND
793
834
  // excluded here, attributed nowhere.
794
- const recognizedWritten = event.isError !== true
835
+ let recognizedWritten = event.isError !== true
795
836
  ? recognized.filter((wp) => !isExternalOrVendorFile(wp, workspaceRoot) &&
796
837
  !isPathIgnoredByProject(wp, workspaceRoot, false))
797
838
  : [];
839
+ // Fence the mtime-based session-authored fallback before any async
840
+ // observation. Only later content evidence may clear this fence.
841
+ if (!getFlag("no-read-guard")) {
842
+ for (const recognizedPath of recognizedWritten)
843
+ deps.readGuard?.recordUnchanged?.(recognizedPath);
844
+ }
798
845
  // #2000 phase 2: when the extractor recognizes NOTHING, the command is
799
846
  // opaque-candidate — recover its actual changed set by diffing the pre
800
847
  // snapshot taken at tool_call. Partial writes that landed before a
@@ -802,6 +849,8 @@ export async function handleToolResult(deps) {
802
849
  // them) — a deliberate divergence from the isError filter above, which
803
850
  // exists for restore semantics where attribution would lie.
804
851
  let opaquePaths = [];
852
+ let observedChangedKeys;
853
+ let recognizedAuthored = [];
805
854
  // Recovery runs for EVERY bash command with a pending baseline - not
806
855
  // only recognized-empty ones. A mixed command (`python x.py > out.ts`
807
856
  // plus script-internal writes) previously skipped observation entirely
@@ -829,6 +878,7 @@ export async function handleToolResult(deps) {
829
878
  if (recovery.verdict === "recovered") {
830
879
  opaquePaths = recovery.paths.filter((p) => !isExternalOrVendorFile(p, scanRoot) &&
831
880
  !isPathIgnoredByProject(p, scanRoot, false));
881
+ observedChangedKeys = new Set(opaquePaths);
832
882
  }
833
883
  else if (recovery.verdict === "unknown") {
834
884
  // #2060: deliberately WIDER than the old `recognized.length > 0`
@@ -837,6 +887,12 @@ export async function handleToolResult(deps) {
837
887
  // nothing at all.
838
888
  unknownReason = recovery.unknownReason;
839
889
  }
890
+ else {
891
+ // A clean git verdict is complete evidence that no path
892
+ // changed; it is not missing evidence. Keep recognized writes
893
+ // out of authorship because git found no changed bytes.
894
+ observedChangedKeys = new Set();
895
+ }
840
896
  // #2060: both counts are bounded (one record per tool_result, no
841
897
  // per-path logging) and exist because filtering is invisible in
842
898
  // production otherwise - the dropped paths simply never appear.
@@ -862,9 +918,11 @@ export async function handleToolResult(deps) {
862
918
  else if (pending.stats) {
863
919
  const outcome = await captureFileStats(scanRoot, {
864
920
  withHashes: true,
921
+ ...deps._opaqueCaptureOptions,
865
922
  });
866
923
  if (outcome.snapshot && !outcome.unknownReason) {
867
- opaquePaths = diffFileStats(pending.stats, outcome.snapshot);
924
+ opaquePaths = diffFileContent(pending.stats, outcome.snapshot);
925
+ observedChangedKeys = new Set(opaquePaths);
868
926
  }
869
927
  else {
870
928
  unknownReason =
@@ -880,6 +938,16 @@ export async function handleToolResult(deps) {
880
938
  const survivingKeys = new Set(recognizedWritten.map((p) => normalizeMapKey(path.resolve(p))));
881
939
  opaquePaths = opaquePaths.filter((p) => !survivingKeys.has(p));
882
940
  }
941
+ if (observedChangedKeys) {
942
+ recognizedAuthored = recognizedWritten.filter((file) => observedChangedKeys.has(normalizeMapKey(path.resolve(file))));
943
+ if (recognizedAuthored.length === 0)
944
+ recognizedWritten = [];
945
+ }
946
+ else if (unknownReason) {
947
+ recognizedAuthored = [];
948
+ // The bounded opaque_mutation_coverage_unknown record below remains
949
+ // the production proof that authorship evidence was unavailable.
950
+ }
883
951
  if (unknownReason) {
884
952
  logLatency({
885
953
  type: "phase",
@@ -897,15 +965,24 @@ export async function handleToolResult(deps) {
897
965
  durationMs: Date.now() - started,
898
966
  result: `changed:${opaquePaths.length}`,
899
967
  });
968
+ void resyncGitChangedFiles(opaquePaths).catch((err) => {
969
+ dbg(`git-change LSP resync failed: ${err}`);
970
+ });
900
971
  }
901
972
  }
902
973
  // wp iterates opaquePaths VERBATIM (already normalizeMapKey keys), so the
903
974
  // set must hold those exact strings - no re-resolution.
904
975
  const opaqueSet = new Set(opaquePaths);
905
976
  const written = [...recognizedWritten, ...opaquePaths];
977
+ const recognizedAuthoredSet = new Set(recognizedAuthored);
978
+ bashAuthorshipConfirmed =
979
+ recognizedAuthored.length > 0 || opaquePaths.length > 0;
906
980
  for (const wp of written) {
907
- if (!getFlag("no-read-guard"))
981
+ if (!getFlag("no-read-guard") &&
982
+ (opaqueSet.has(wp) || recognizedAuthoredSet.has(wp)))
908
983
  deps.readGuard?.recordWritten(wp);
984
+ else if (!getFlag("no-read-guard") && recognizedWritten.includes(wp))
985
+ deps.readGuard?.recordUnchanged?.(wp);
909
986
  const receipt = runtime
910
987
  .recordMutationToolReceipt;
911
988
  const autofixMode = receipt
@@ -936,6 +1013,7 @@ export async function handleToolResult(deps) {
936
1013
  _autofixMode: autofixMode,
937
1014
  _attachmentBudget: syntheticAttachmentBudget,
938
1015
  _mutationSourceOverride: isOpaque ? "opaque-script" : undefined,
1016
+ _readGuardAuthorship: opaqueSet.has(wp) || recognizedAuthoredSet.has(wp),
939
1017
  });
940
1018
  if (syntheticResult) {
941
1019
  // #1590: forward verbatim. The synthetic call already charged the
@@ -946,7 +1024,57 @@ export async function handleToolResult(deps) {
946
1024
  }
947
1025
  }
948
1026
  if (event.isError !== true && !getFlag("no-read-guard")) {
949
- for (const span of extractReadPathsFromCommand(command, workspaceRoot)) {
1027
+ const truncation = event.details?.truncation;
1028
+ const parsedSpans = extractReadPathsFromCommand(command, workspaceRoot);
1029
+ let spans = parsedSpans;
1030
+ if (truncation?.truncated === true &&
1031
+ typeof truncation.outputLines === "number") {
1032
+ if (parsedSpans.length === 1) {
1033
+ const span = parsedSpans[0];
1034
+ if (span) {
1035
+ // countFileLines intentionally preserves the split-based guard
1036
+ // convention, where a trailing newline contributes an empty final
1037
+ // element. The host's output line count does not include that
1038
+ // element, so remove it from the span basis before taking the tail.
1039
+ const hasTrailingNewline = nodeFs
1040
+ .readFileSync(span.filePath, "utf8")
1041
+ .endsWith("\n");
1042
+ const spanLineCount = hasTrailingNewline
1043
+ ? Math.max(1, span.limit - 1)
1044
+ : span.limit;
1045
+ const shown = Math.min(truncation.outputLines, spanLineCount);
1046
+ spans =
1047
+ shown > 0
1048
+ ? [
1049
+ {
1050
+ ...span,
1051
+ offset: span.offset + spanLineCount - shown,
1052
+ limit: shown,
1053
+ },
1054
+ ]
1055
+ : [];
1056
+ }
1057
+ }
1058
+ else if (parsedSpans.length > 1) {
1059
+ recordDegradationOnce({
1060
+ kind: "bash-view-clipped",
1061
+ subject: "multi-span",
1062
+ reason: "truncated bash view contained multiple spans",
1063
+ });
1064
+ spans = [];
1065
+ }
1066
+ if (parsedSpans.length === 1 &&
1067
+ spans.length === 1 &&
1068
+ (spans[0]?.offset !== parsedSpans[0]?.offset ||
1069
+ spans[0]?.limit !== parsedSpans[0]?.limit)) {
1070
+ recordDegradationOnce({
1071
+ kind: "bash-view-clipped",
1072
+ subject: parsedSpans[0]?.filePath ?? "unknown",
1073
+ reason: "truncated bash view narrowed a single span",
1074
+ });
1075
+ }
1076
+ }
1077
+ for (const span of spans) {
950
1078
  if (isExternalOrVendorFile(span.filePath, workspaceRoot))
951
1079
  continue;
952
1080
  if (isPathIgnoredByProject(span.filePath, workspaceRoot, false))
@@ -1026,6 +1154,74 @@ export async function handleToolResult(deps) {
1026
1154
  });
1027
1155
  }
1028
1156
  }
1157
+ // Native read results are the authoritative read boundary. The tool_call
1158
+ // input can request past EOF, and the host can cap bytes or lines before the
1159
+ // result reaches the model. Register only the delivered range (#2802).
1160
+ if (deps.readGuard &&
1161
+ event.toolName === "read" &&
1162
+ event.isError !== true &&
1163
+ filePath &&
1164
+ !getFlag("no-read-guard") &&
1165
+ !isExternalOrVendorFile(filePath, workspaceRoot)) {
1166
+ const deliveredFilePath = attribution?.resolvedPath && nodeFs.existsSync(attribution.resolvedPath)
1167
+ ? attribution.resolvedPath
1168
+ : filePath;
1169
+ if (nodeFs.existsSync(deliveredFilePath)) {
1170
+ const nativeReadToolCallId = resolveToolCallCorrelationId(event);
1171
+ const input = event.input;
1172
+ const requestedOffset = input.offset ?? 1;
1173
+ const requestedLimit = input.limit;
1174
+ const truncation = event.details?.truncation;
1175
+ const available = Math.max(0, countFileLines(deliveredFilePath) - requestedOffset + 1);
1176
+ const deliveredLimit = Math.min(available, truncation?.outputLines ?? requestedLimit ?? available);
1177
+ if (deliveredLimit > 0) {
1178
+ if (truncation?.truncated === true && deliveredLimit < available) {
1179
+ recordDegradationOnce({
1180
+ kind: "native-read-clipped",
1181
+ subject: deliveredFilePath,
1182
+ reason: "native read result was clipped before delivery",
1183
+ });
1184
+ }
1185
+ logReadGuardEvent({
1186
+ event: "read_pattern",
1187
+ sessionId: runtime.telemetrySessionId,
1188
+ filePath: deliveredFilePath,
1189
+ requestedOffset,
1190
+ requestedLimit: requestedLimit ?? deliveredLimit,
1191
+ effectiveOffset: requestedOffset,
1192
+ effectiveLimit: deliveredLimit,
1193
+ metadata: {
1194
+ totalLines: countFileLines(deliveredFilePath),
1195
+ isPartial: deliveredLimit < available,
1196
+ fileKind: detectFileKind(deliveredFilePath) ?? "unknown",
1197
+ fractionRead: available > 0
1198
+ ? Math.round((deliveredLimit / available) * 100) / 100
1199
+ : 1,
1200
+ expandedByTs: false,
1201
+ },
1202
+ });
1203
+ const deliveredRecord = {
1204
+ filePath: deliveredFilePath,
1205
+ requestedOffset,
1206
+ requestedLimit: requestedLimit ?? deliveredLimit,
1207
+ effectiveOffset: requestedOffset,
1208
+ effectiveLimit: deliveredLimit,
1209
+ expandedByLsp: false,
1210
+ turnIndex: runtime.turnIndex,
1211
+ writeIndex: runtime.peekWriteIndex(),
1212
+ timestamp: Date.now(),
1213
+ };
1214
+ if (nativeReadToolCallId) {
1215
+ deps.readGuard.recordRead(deliveredRecord, {
1216
+ supersedes: { toolCallId: nativeReadToolCallId },
1217
+ });
1218
+ }
1219
+ else {
1220
+ deps.readGuard.recordRead(deliveredRecord);
1221
+ }
1222
+ }
1223
+ }
1224
+ }
1029
1225
  // #2423: THE inbound gate. Everything below this line is the mutation
1030
1226
  // bookkeeping chain — read-guard staleness stamp, turn state, change-log
1031
1227
  // receipt, deferred autofix and format. Before the seam this compared
@@ -1195,14 +1391,36 @@ export async function handleToolResult(deps) {
1195
1391
  // "edit", so the un-receipted default is "deferred" either way.
1196
1392
  const observedAutofixMode = receiptOutcome?.autofixMode ??
1197
1393
  (observedKind === "edit" ? "deferred" : "immediate");
1198
- // Analyse the file the observation actually RECORDED, not merely the
1199
- // one the tool named: a directory-target tool names a path that is not
1200
- // a file at all, and `runPipeline` on it is meaningless. `changedPaths`
1201
- // is already filtered by the `isRecordable` predicate handed to the
1202
- // settle above, so membership here also implies not-vendored and
1203
- // not-gitignored — the same two gates the classified chain applies
1204
- // before its own dispatch.
1205
- if (observedChangedPaths.some((candidate) => pathsEqual(candidate, filePath))) {
1394
+ // Analyse the files the observation actually RECORDED, not merely the
1395
+ // path the tool named. A directory is never a pipeline target.
1396
+ const observedDispatchPaths = observedChangedPaths
1397
+ .filter((candidate) => {
1398
+ try {
1399
+ return !nodeFs.statSync(candidate).isDirectory();
1400
+ }
1401
+ catch {
1402
+ return false;
1403
+ }
1404
+ })
1405
+ .slice(0, OBSERVED_DISPATCH_MAX_PATHS);
1406
+ const observedDispatchDropped = observedChangedPaths.length - observedDispatchPaths.length;
1407
+ if (observedDispatchDropped > 0) {
1408
+ incrementDegradationCount({
1409
+ kind: "observed-mutation-dispatch-cap",
1410
+ subject: event.toolName,
1411
+ reason: `observed mutation dispatch capped at ${OBSERVED_DISPATCH_MAX_PATHS}; ${observedDispatchDropped} path(s) not dispatched`,
1412
+ });
1413
+ }
1414
+ if (observedDispatchPaths.length > 0) {
1415
+ const observedClients = ensureToolResultClients(deps);
1416
+ if (observedClients !== true &&
1417
+ !(await bounded(Promise.resolve(observedClients), {
1418
+ ms: HOOK_WALL_BUDGET_MS.tool_result_edit,
1419
+ signal: deps.signal ?? undefined,
1420
+ hook: "tool_result_edit",
1421
+ label: "observed-tool-result-analysis",
1422
+ })))
1423
+ return;
1206
1424
  const observedReadGuardCorrelationId = getReadGuardCorrelationId(event);
1207
1425
  // #2464 review round 3 (F1): the SAME two pre-conditions the
1208
1426
  // classified site consults, through the same shared claim. Round 2
@@ -1212,60 +1430,72 @@ export async function handleToolResult(deps) {
1212
1430
  // second overwrote the first's registry entry, and the first's
1213
1431
  // release then evicted the outer entry a live, unrelated classified
1214
1432
  // pipeline had since re-created under it.
1215
- const observedClaim = claimPipelineDispatch({
1216
- filePath,
1217
- stateHash: observedStateHash,
1218
- turnIndex: runtime.turnIndex,
1219
- participantId: observedReadGuardCorrelationId,
1220
- dbg,
1221
- });
1222
- if (!observedClaim.proceed) {
1223
- if (observedClaim.joined) {
1224
- await observedClaim.joined;
1433
+ for (const observedPath of observedDispatchPaths) {
1434
+ const observedDispatchSignal = deps.signal;
1435
+ const observedJoinSignal = deps.signal;
1436
+ const observedStateHashForPath = getFileStateHash(observedPath);
1437
+ const observedClaim = claimPipelineDispatch({
1438
+ filePath: observedPath,
1439
+ stateHash: observedStateHashForPath,
1440
+ turnIndex: runtime.turnIndex,
1441
+ participantId: observedReadGuardCorrelationId,
1442
+ dbg,
1443
+ });
1444
+ if (!observedClaim.proceed) {
1445
+ if (observedClaim.joined) {
1446
+ await bounded(observedClaim.joined, {
1447
+ ms: HOOK_WALL_BUDGET_MS.tool_result_edit,
1448
+ signal: observedJoinSignal,
1449
+ hook: "tool_result_edit",
1450
+ label: "observed-tool-result-join",
1451
+ });
1452
+ }
1453
+ continue;
1454
+ }
1455
+ const observedDispatchOutcome = await bounded(dispatchPipelineAnalysis({
1456
+ deps,
1457
+ runtime,
1458
+ filePath: observedPath,
1459
+ dispatchCwd: resolveLanguageRootForFile(observedPath, workspaceRoot),
1460
+ turnStateCwd: path.resolve(workspaceRoot),
1461
+ autofixMode: observedAutofixMode,
1462
+ modifiedRanges: undefined,
1463
+ writeIndex: runtime.nextWriteIndex(),
1464
+ initialStateHash: observedStateHashForPath,
1465
+ readGuardCorrelationId: observedReadGuardCorrelationId,
1466
+ requestedEditIndexes: getRequestedEditIndexes(event, observedKind),
1467
+ requestedEditTotal: getRequestedEditCount(event, observedKind),
1468
+ isPartialApplyResult: (event.details ?? {})
1469
+ .piLensPartialApply === true,
1470
+ participantIds: [observedReadGuardCorrelationId],
1471
+ participantTotal: 1,
1472
+ toolResultStart,
1473
+ nativeAppliedPairs: observedAppliedPairs,
1474
+ // #2802 authorship rule (master): the observational
1475
+ // settle already recorded this edit through the
1476
+ // bridge, so the dispatch's read-guard write refresh
1477
+ // carries evidence and stays enabled on every
1478
+ // per-path dispatch.
1479
+ allowReadGuardWrites: true,
1480
+ }), {
1481
+ ms: HOOK_WALL_BUDGET_MS.tool_result_edit,
1482
+ signal: observedDispatchSignal,
1483
+ hook: "tool_result_edit",
1484
+ label: "observed-tool-result-analysis",
1485
+ });
1486
+ if (observedDispatchOutcome?.crashed) {
1487
+ // #2464 review round 2, S6: parity with the classified chain — a
1488
+ // pipeline crash surfaces its notice to the agent instead of being
1489
+ // swallowed into a dbg line the model never sees. The recorded
1490
+ // edit stands either way; only the analysis was lost.
1491
+ dbg(`tool_result: pipeline analysis crashed for the observed mutation on ${observedPath}; the recorded edit stands, analysis did not run this turn`);
1492
+ return observedDispatchOutcome.response;
1225
1493
  }
1226
- // Same terminal value as the block's own exit below: the bridge
1227
- // already recorded this edit, and the analysis it would have asked
1228
- // for is either running or already done.
1229
- return syntheticWriteContent.length > 0
1230
- ? { content: [...event.content, ...syntheticWriteContent] }
1231
- : undefined;
1232
- }
1233
- const observedDispatchOutcome = await dispatchPipelineAnalysis({
1234
- deps,
1235
- runtime,
1236
- filePath,
1237
- dispatchCwd: resolveLanguageRootForFile(filePath, workspaceRoot),
1238
- turnStateCwd: path.resolve(workspaceRoot),
1239
- autofixMode: observedAutofixMode,
1240
- // #2423: no adapter/diff ranges exist for a "learned" (unnamed)
1241
- // tool — the classified chain below hits the same gap for this
1242
- // provenance and also leaves `modifiedRanges` undefined, so the
1243
- // dispatch runs unscoped (whole-file) exactly as it would there.
1244
- modifiedRanges: undefined,
1245
- writeIndex: runtime.nextWriteIndex(),
1246
- initialStateHash: observedStateHash,
1247
- readGuardCorrelationId: observedReadGuardCorrelationId,
1248
- requestedEditIndexes: getRequestedEditIndexes(event, observedKind),
1249
- requestedEditTotal: getRequestedEditCount(event, observedKind),
1250
- isPartialApplyResult: (event.details ?? {})
1251
- .piLensPartialApply === true,
1252
- participantIds: [observedReadGuardCorrelationId],
1253
- participantTotal: 1,
1254
- toolResultStart,
1255
- nativeAppliedPairs: observedAppliedPairs,
1256
- });
1257
- if (observedDispatchOutcome.crashed) {
1258
- // #2464 review round 2, S6: parity with the classified chain — a
1259
- // pipeline crash surfaces its notice to the agent instead of being
1260
- // swallowed into a dbg line the model never sees. The recorded
1261
- // edit stands either way; only the analysis was lost.
1262
- dbg(`tool_result: pipeline analysis crashed for the observed mutation on ${filePath}; the recorded edit stands, analysis did not run this turn`);
1263
- return observedDispatchOutcome.response;
1264
1494
  }
1265
1495
  dbg(`tool_result: the observational settle already recorded ${observedReplayed} mutation(s) for "${event.toolName}"; kept the applied-edit records, mutation receipt, cachedExports refresh, and ran the pipeline dispatch (#2464); skipped the bridge-covered staleness stamp / turn-state ranges / change-log receipt / deferred autofix+format`);
1266
1496
  }
1267
1497
  else {
1268
- dbg(`tool_result: the observational settle recorded ${observedReplayed} mutation(s) for "${event.toolName}", none of them ${filePath} — nothing to analyse under the named path`);
1498
+ dbg(`tool_result: the observational settle recorded ${observedReplayed} mutation(s) for "${event.toolName}", but no dispatchable changed paths remained`);
1269
1499
  }
1270
1500
  }
1271
1501
  return syntheticWriteContent.length > 0
@@ -1376,7 +1606,8 @@ export async function handleToolResult(deps) {
1376
1606
  }
1377
1607
  // Refresh the read-guard's FileTime stamp so that the model's own write
1378
1608
  // doesn't trigger a spurious "file_modified" block on the next edit.
1379
- deps.readGuard?.recordWritten(filePath);
1609
+ if (bashAuthorshipConfirmed)
1610
+ deps.readGuard?.recordWritten(filePath);
1380
1611
  // Keep cachedExports in sync after each write/edit so the pre-write STOP
1381
1612
  // check doesn't fire on names that were removed from this file this session.
1382
1613
  refreshCachedExports(runtime, filePath);
@@ -1408,7 +1639,7 @@ export async function handleToolResult(deps) {
1408
1639
  // tool_result is emitted after write/edit has already been applied.
1409
1640
  // Asserting pre-write stamps here produces false positives on rapid edits.
1410
1641
  sessionFileTime.read(filePath);
1411
- if (!getFlag("no-read-guard")) {
1642
+ if (!getFlag("no-read-guard") && bashAuthorshipConfirmed) {
1412
1643
  const readGuard = runtime.readGuard;
1413
1644
  readGuard?.recordWritten?.(filePath);
1414
1645
  }
@@ -1529,7 +1760,16 @@ export async function handleToolResult(deps) {
1529
1760
  // (defined above `handleToolResult`), shared with the observed-mutation
1530
1761
  // early return. This call site is otherwise unchanged — same arguments, same
1531
1762
  // crash-then-return / success-then-continue shape as before the split.
1532
- const dispatchOutcome = await dispatchPipelineAnalysis({
1763
+ const classifiedClients = ensureToolResultClients(deps);
1764
+ if (classifiedClients !== true &&
1765
+ !(await bounded(Promise.resolve(classifiedClients), {
1766
+ ms: HOOK_WALL_BUDGET_MS.tool_result_edit,
1767
+ signal: deps.signal,
1768
+ hook: "tool_result_edit",
1769
+ label: "classified-tool-result-analysis",
1770
+ })))
1771
+ return;
1772
+ const dispatchOutcome = await bounded(dispatchPipelineAnalysis({
1533
1773
  deps,
1534
1774
  runtime,
1535
1775
  filePath,
@@ -1547,7 +1787,15 @@ export async function handleToolResult(deps) {
1547
1787
  participantTotal,
1548
1788
  toolResultStart,
1549
1789
  nativeAppliedPairs,
1790
+ allowReadGuardWrites: bashAuthorshipConfirmed,
1791
+ }), {
1792
+ ms: HOOK_WALL_BUDGET_MS.tool_result_edit,
1793
+ signal: deps.signal || undefined,
1794
+ hook: "tool_result_edit",
1795
+ label: "pipeline-analysis",
1550
1796
  });
1797
+ if (!dispatchOutcome)
1798
+ return;
1551
1799
  if (dispatchOutcome.crashed) {
1552
1800
  return dispatchOutcome.response;
1553
1801
  }
@@ -1681,7 +1929,7 @@ export async function handleToolResult(deps) {
1681
1929
  // #1561: stamp the verdict with THIS dispatch's write token — the same
1682
1930
  // counter `lsp_diagnostics`' reconciliation seam draws from — so a later
1683
1931
  // confirmed-clean result can be ordered against it instead of racing it.
1684
- runtime.recordInlineBlockers(filePath, result.inlineBlockerSummary, writeIndex, result.inlineBlockerSources, result.inlineBlockerLines);
1932
+ runtime.recordInlineBlockers(filePath, result.inlineBlockerSummary, writeIndex, result.inlineBlockerSources, result.inlineBlockerLines, result.inlineBlockerFileContent);
1685
1933
  }
1686
1934
  else {
1687
1935
  runtime.clearInlineBlockers(filePath);