pi-lens 4.1.2 → 4.1.4

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 (359) hide show
  1. package/CHANGELOG.md +1308 -0
  2. package/README.md +3 -0
  3. package/config/biome/core.jsonc +11 -2
  4. package/config/dependency-cruiser-eager-allowlist.json +91 -0
  5. package/dist/clients/actionable-warnings-logger.js +18 -3
  6. package/dist/clients/actionable-warnings.js +927 -80
  7. package/dist/clients/agent-behavior-client.js +13 -4
  8. package/dist/clients/ast-grep-client.js +136 -9
  9. package/dist/clients/ast-grep-rule-manager.js +60 -5
  10. package/dist/clients/ast-grep-tool-logger.js +2 -2
  11. package/dist/clients/availability-probe-flight.js +12 -0
  12. package/dist/clients/bash-file-access.js +1 -3
  13. package/dist/clients/biome-client.js +16 -2
  14. package/dist/clients/blocker-freshness.js +34 -0
  15. package/dist/clients/bootstrap.js +509 -73
  16. package/dist/clients/bounded-cache.js +152 -12
  17. package/dist/clients/bounded-telemetry.js +115 -23
  18. package/dist/clients/build-identity.js +95 -0
  19. package/dist/clients/bundled-resource-health.js +113 -0
  20. package/dist/clients/bus-events-logger.js +2 -2
  21. package/dist/clients/bus-publish.js +3 -0
  22. package/dist/clients/cache/rule-cache.js +33 -5
  23. package/dist/clients/cache-manager.js +105 -6
  24. package/dist/clients/cache-observability.js +46 -19
  25. package/dist/clients/cargo-manifest.js +422 -0
  26. package/dist/clients/cascade-logger.js +17 -3
  27. package/dist/clients/code-quality-warnings.js +13 -3
  28. package/dist/clients/complexity-client.js +16 -5
  29. package/dist/clients/config-core/deny.js +221 -0
  30. package/dist/clients/config-core/index.js +47 -0
  31. package/dist/clients/config-core/merge.js +357 -0
  32. package/dist/clients/config-core/normalize.js +340 -0
  33. package/dist/clients/config-core/process-spec.js +248 -0
  34. package/dist/clients/config-core/provenance.js +171 -0
  35. package/dist/clients/config-core/records.js +218 -0
  36. package/dist/clients/config-core/resolve.js +125 -0
  37. package/dist/clients/config-core/safe-object.js +78 -0
  38. package/dist/clients/config-core/schema.js +167 -0
  39. package/dist/clients/config-diagnostic-codes.js +281 -0
  40. package/dist/clients/config-locations.js +160 -0
  41. package/dist/clients/config-resolve.js +789 -0
  42. package/dist/clients/config-schema.js +197 -0
  43. package/dist/clients/config-warn.js +407 -0
  44. package/dist/clients/dead-code-client.js +33 -25
  45. package/dist/clients/dead-code-logger.js +2 -2
  46. package/dist/clients/deadline-utils.js +178 -0
  47. package/dist/clients/debug-handles.js +2 -2
  48. package/dist/clients/debug-heap.js +3 -3
  49. package/dist/clients/deferred-lsp-work.js +106 -0
  50. package/dist/clients/degradation-ledger.js +166 -8
  51. package/dist/clients/demoted-finding-render.js +14 -0
  52. package/dist/clients/dependency-checker.js +28 -22
  53. package/dist/clients/deps/minimatch.js +1 -1
  54. package/dist/clients/diagnostic-line-freshness.js +12 -9
  55. package/dist/clients/diagnostic-logger.js +14 -4
  56. package/dist/clients/dispatch/collect-later-tier.js +39 -0
  57. package/dist/clients/dispatch/dispatcher.js +304 -19
  58. package/dist/clients/dispatch/fact-store.js +328 -9
  59. package/dist/clients/dispatch/integration.js +214 -107
  60. package/dist/clients/dispatch/pending-runner-findings.js +87 -0
  61. package/dist/clients/dispatch/plan.js +21 -5
  62. package/dist/clients/dispatch/runners/actionlint.js +0 -1
  63. package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
  64. package/dist/clients/dispatch/runners/biome-check.js +0 -1
  65. package/dist/clients/dispatch/runners/cpp-check.js +0 -1
  66. package/dist/clients/dispatch/runners/credo.js +0 -1
  67. package/dist/clients/dispatch/runners/cue-vet.js +0 -1
  68. package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  69. package/dist/clients/dispatch/runners/detekt.js +0 -1
  70. package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  71. package/dist/clients/dispatch/runners/elixir-check.js +0 -1
  72. package/dist/clients/dispatch/runners/eslint.js +5 -2
  73. package/dist/clients/dispatch/runners/fact-rules.js +0 -1
  74. package/dist/clients/dispatch/runners/fish-indent.js +0 -1
  75. package/dist/clients/dispatch/runners/gleam-check.js +0 -1
  76. package/dist/clients/dispatch/runners/go-vet.js +1 -3
  77. package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  78. package/dist/clients/dispatch/runners/hadolint.js +0 -1
  79. package/dist/clients/dispatch/runners/helm-lint.js +16 -4
  80. package/dist/clients/dispatch/runners/helm-render.js +54 -10
  81. package/dist/clients/dispatch/runners/htmlhint.js +0 -1
  82. package/dist/clients/dispatch/runners/index.js +1 -3
  83. package/dist/clients/dispatch/runners/javac.js +0 -1
  84. package/dist/clients/dispatch/runners/ktlint.js +0 -1
  85. package/dist/clients/dispatch/runners/lsp.js +37 -2
  86. package/dist/clients/dispatch/runners/markdownlint.js +4 -2
  87. package/dist/clients/dispatch/runners/mypy.js +0 -1
  88. package/dist/clients/dispatch/runners/oxlint.js +24 -22
  89. package/dist/clients/dispatch/runners/php-lint.js +0 -1
  90. package/dist/clients/dispatch/runners/phpstan.js +0 -1
  91. package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  92. package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
  93. package/dist/clients/dispatch/runners/pyright.js +7 -4
  94. package/dist/clients/dispatch/runners/rubocop.js +0 -1
  95. package/dist/clients/dispatch/runners/ruff.js +0 -1
  96. package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
  97. package/dist/clients/dispatch/runners/shellcheck.js +0 -1
  98. package/dist/clients/dispatch/runners/shfmt.js +0 -1
  99. package/dist/clients/dispatch/runners/spellcheck.js +0 -1
  100. package/dist/clients/dispatch/runners/spotbugs.js +0 -1
  101. package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  102. package/dist/clients/dispatch/runners/stylelint.js +0 -1
  103. package/dist/clients/dispatch/runners/swiftlint.js +0 -1
  104. package/dist/clients/dispatch/runners/taplo.js +0 -1
  105. package/dist/clients/dispatch/runners/terragrunt.js +0 -1
  106. package/dist/clients/dispatch/runners/tflint.js +0 -1
  107. package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  108. package/dist/clients/dispatch/runners/trivy-config.js +0 -1
  109. package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  110. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
  111. package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
  112. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  113. package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  114. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
  115. package/dist/clients/dispatch/runners/vale.js +0 -1
  116. package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  117. package/dist/clients/dispatch/runners/yamllint.js +0 -1
  118. package/dist/clients/dispatch/runners/zig-check.js +0 -1
  119. package/dist/clients/disposition-logger.js +3 -3
  120. package/dist/clients/effective-config.js +403 -0
  121. package/dist/clients/error-class.js +23 -0
  122. package/dist/clients/event-loop-hold.js +274 -0
  123. package/dist/clients/event-loop-monitor.js +66 -1
  124. package/dist/clients/extension-log.js +2 -2
  125. package/dist/clients/feature-hints.js +2 -1
  126. package/dist/clients/file-time.js +6 -1
  127. package/dist/clients/file-utils.js +266 -13
  128. package/dist/clients/finding-delivery-gate.js +55 -13
  129. package/dist/clients/format-service.js +6 -1
  130. package/dist/clients/formatters.js +303 -104
  131. package/dist/clients/freshness-cadence.js +17 -0
  132. package/dist/clients/generated-artifacts.js +114 -19
  133. package/dist/clients/generation-guard.js +4 -9
  134. package/dist/clients/git-guard.js +198 -48
  135. package/dist/clients/git-tracked-ignore.js +32 -1
  136. package/dist/clients/go-client.js +39 -0
  137. package/dist/clients/govulncheck-client.js +5 -1
  138. package/dist/clients/gradle-ktfmt-style.js +252 -0
  139. package/dist/clients/gzip-stage-write.js +7 -0
  140. package/dist/clients/hashline-anchor.js +424 -0
  141. package/dist/clients/hook-budgets.js +76 -0
  142. package/dist/clients/host-edit-normalize.js +5 -2
  143. package/dist/clients/host-ports.js +1 -1
  144. package/dist/clients/installer/index.js +200 -84
  145. package/dist/clients/installer/managed-tool-refresh.js +3 -1
  146. package/dist/clients/instance-reaper.js +108 -243
  147. package/dist/clients/instance-registry-lock.js +145 -0
  148. package/dist/clients/instance-registry.js +435 -122
  149. package/dist/clients/jscpd-client.js +6 -1
  150. package/dist/clients/language-profile.js +4 -2
  151. package/dist/clients/language-registry.js +595 -0
  152. package/dist/clients/latency-logger.js +179 -3
  153. package/dist/clients/ledger-bounds.js +13 -0
  154. package/dist/clients/lens-config.js +141 -30
  155. package/dist/clients/lens-engine.js +30 -37
  156. package/dist/clients/lens-flag-registry.js +67 -6
  157. package/dist/clients/lens-map.js +3 -1
  158. package/dist/clients/log-cleanup.js +2 -2
  159. package/dist/clients/lsp/aggregation.js +3 -1
  160. package/dist/clients/lsp/cascade-tier.js +4 -0
  161. package/dist/clients/lsp/client.js +672 -49
  162. package/dist/clients/lsp/config.js +504 -156
  163. package/dist/clients/lsp/diagnostic-binding.js +10 -5
  164. package/dist/clients/lsp/edits.js +15 -2
  165. package/dist/clients/lsp/index.js +1179 -247
  166. package/dist/clients/lsp/jvm-runtime.js +5 -0
  167. package/dist/clients/lsp/language.js +18 -169
  168. package/dist/clients/lsp/launch.js +38 -9
  169. package/dist/clients/lsp/pending-aux-coverage.js +113 -12
  170. package/dist/clients/lsp/server.js +426 -160
  171. package/dist/clients/lsp/session-roots.js +192 -0
  172. package/dist/clients/lsp/spawn-history.js +2 -7
  173. package/dist/clients/lsp/tsserver-sync.js +9 -2
  174. package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
  175. package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  176. package/dist/clients/lsp-mutation.js +179 -23
  177. package/dist/clients/map-with-concurrency.js +36 -0
  178. package/dist/clients/mcp/analyze.js +45 -10
  179. package/dist/clients/mcp/session.js +19 -17
  180. package/dist/clients/memory-sampler.js +28 -1
  181. package/dist/clients/message-end-attribution.js +30 -0
  182. package/dist/clients/metrics-history.js +21 -4
  183. package/dist/clients/middle-man-analysis.js +2 -4
  184. package/dist/clients/module-report.js +20 -45
  185. package/dist/clients/mutating-tool.js +651 -0
  186. package/dist/clients/mutation-attribution.js +368 -0
  187. package/dist/clients/mutation-bridge.js +240 -0
  188. package/dist/clients/ndjson-logger.js +247 -20
  189. package/dist/clients/observed-mutation-sources.js +101 -0
  190. package/dist/clients/observed-mutation.js +1215 -0
  191. package/dist/clients/opaque-mutation-scan.js +300 -40
  192. package/dist/clients/opengrep-config.js +4 -0
  193. package/dist/clients/package-manager.js +201 -44
  194. package/dist/clients/partial-edit-apply.js +359 -80
  195. package/dist/clients/path-attribution-telemetry.js +23 -7
  196. package/dist/clients/path-keyed-map.js +35 -3
  197. package/dist/clients/path-utils.js +460 -13
  198. package/dist/clients/persist-debounce.js +8 -1
  199. package/dist/clients/php-cs-fixer-config.js +114 -0
  200. package/dist/clients/pipeline.js +77 -24
  201. package/dist/clients/probe-home-state.js +228 -0
  202. package/dist/clients/process-bridge.js +66 -0
  203. package/dist/clients/process-singletons.js +210 -0
  204. package/dist/clients/process-snapshot.js +68 -0
  205. package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  206. package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  207. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
  208. package/dist/clients/project-diagnostics/scanner.js +61 -24
  209. package/dist/clients/project-lens-config.js +393 -122
  210. package/dist/clients/project-snapshot.js +85 -35
  211. package/dist/clients/python-environment.js +217 -0
  212. package/dist/clients/python-provenance.js +639 -0
  213. package/dist/clients/quiet-window.js +6 -1
  214. package/dist/clients/read-bridge.js +13 -21
  215. package/dist/clients/read-expansion.js +9 -46
  216. package/dist/clients/read-guard-logger.js +39 -6
  217. package/dist/clients/read-guard-tool-lines.js +364 -212
  218. package/dist/clients/read-guard.js +95 -7
  219. package/dist/clients/recent-touches.js +17 -5
  220. package/dist/clients/resource-sampler.js +195 -96
  221. package/dist/clients/review-graph/builder.js +752 -132
  222. package/dist/clients/review-graph/service.js +55 -3
  223. package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  224. package/dist/clients/review-graph/tsconfig-paths.js +5 -1
  225. package/dist/clients/review-graph/workspace-modules.js +10 -74
  226. package/dist/clients/review-graph-logger.js +30 -4
  227. package/dist/clients/runtime-agent-end.js +18 -2
  228. package/dist/clients/runtime-context.js +49 -16
  229. package/dist/clients/runtime-coordinator.js +93 -6
  230. package/dist/clients/runtime-session.js +169 -34
  231. package/dist/clients/runtime-tool-call.js +183 -35
  232. package/dist/clients/runtime-tool-result.js +894 -241
  233. package/dist/clients/runtime-turn.js +1264 -74
  234. package/dist/clients/rust-client.js +31 -0
  235. package/dist/clients/safe-spawn.js +191 -28
  236. package/dist/clients/sanitize.js +7 -1
  237. package/dist/clients/scratch-tree-policy.js +1 -3
  238. package/dist/clients/security-scan-client.js +51 -17
  239. package/dist/clients/session-lifecycle.js +238 -42
  240. package/dist/clients/session-start-observability.js +79 -0
  241. package/dist/clients/sessionstart-logger.js +12 -3
  242. package/dist/clients/sg-runner.js +27 -15
  243. package/dist/clients/sgconfig.js +6 -1
  244. package/dist/clients/shared-checkout-guard.js +338 -0
  245. package/dist/clients/skills-resolver.js +105 -0
  246. package/dist/clients/smells-rollup.js +2 -2
  247. package/dist/clients/spawn-output-cap.js +37 -0
  248. package/dist/clients/spawn-timeout-cooldown.js +15 -1
  249. package/dist/clients/startup-scan.js +6 -7
  250. package/dist/clients/startup-timing.js +69 -4
  251. package/dist/clients/string-utils.js +33 -0
  252. package/dist/clients/subagent-mode.js +17 -4
  253. package/dist/clients/test-runner-client.js +586 -118
  254. package/dist/clients/test-runner-delivery.js +239 -0
  255. package/dist/clients/tool-definition.js +41 -1
  256. package/dist/clients/tool-policy.js +92 -22
  257. package/dist/clients/tree-sitter-cache.js +40 -40
  258. package/dist/clients/tree-sitter-client.js +97 -53
  259. package/dist/clients/tree-sitter-logger.js +19 -3
  260. package/dist/clients/tree-sitter-query-loader.js +81 -1
  261. package/dist/clients/tree-sitter-shared.js +51 -46
  262. package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  263. package/dist/clients/typos-config.js +5 -0
  264. package/dist/clients/user-notify.js +6 -2
  265. package/dist/clients/warm-attach.js +8 -10
  266. package/dist/clients/widget-state.js +191 -26
  267. package/dist/clients/word-index-logger.js +15 -3
  268. package/dist/clients/word-index-store.js +463 -0
  269. package/dist/clients/word-index.js +815 -125
  270. package/dist/clients/workspace-topology.js +25 -1
  271. package/dist/clients/zizmor-config.js +29 -6
  272. package/dist/index.js +68577 -53950
  273. package/dist/mcp/analyze-cli.js +3 -1
  274. package/dist/mcp/build-staleness.js +8 -7
  275. package/dist/mcp/cli.js +3 -1
  276. package/dist/mcp/server.js +133 -6
  277. package/dist/scripts/lib/process-scan.mjs +583 -0
  278. package/dist/scripts/lib/skills-predicate.mjs +129 -0
  279. package/dist/tools/effective-config.js +89 -0
  280. package/dist/tools/lens-diagnostics.js +99 -24
  281. package/dist/tools/lsp-diagnostics.js +81 -85
  282. package/dist/tools/lsp-navigation.js +40 -17
  283. package/dist/tools/shared.js +0 -1
  284. package/docs/agent-guide.md +68 -3
  285. package/docs/ast-grep_rules_catalog.md +41 -10
  286. package/docs/astplayground.md +26 -17
  287. package/docs/configuration.md +222 -0
  288. package/docs/dependencies.md +3 -3
  289. package/docs/features.md +44 -5
  290. package/docs/language-coverage.md +2 -2
  291. package/docs/pi-lens-fixer.md +25 -0
  292. package/docs/pi-lens-investigator.md +25 -0
  293. package/docs/pi-lens-reviewer.md +27 -0
  294. package/docs/pi-lens-subagent.md +38 -0
  295. package/docs/pi-lens-warden.md +55 -0
  296. package/docs/public-api-stability.md +359 -0
  297. package/docs/release-qa-baseline.md +182 -0
  298. package/docs/subagent-compat.md +110 -30
  299. package/docs/tree-sitter_rules_catalog.md +2 -2
  300. package/package.json +17 -21
  301. package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  302. package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  303. package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  304. package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  305. package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  306. package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  307. package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  308. package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  309. package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  310. package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  311. package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  312. package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  313. package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  314. package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  315. package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  316. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  317. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  318. package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  319. package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  320. package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  321. package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  322. package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  323. package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  324. package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  325. package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  326. package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  327. package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  328. package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  329. package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  330. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  331. package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  332. package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  333. package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  334. package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  335. package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  336. package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  337. package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  338. package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  339. package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  340. package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  341. package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  342. package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  343. package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  344. package/rules/ast-grep-rules/rules/no-important.yml +21 -0
  345. package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  346. package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  347. package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  348. package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  349. package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  350. package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  351. package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  352. package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  353. package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  354. package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  355. package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  356. package/scripts/analyze-pi-lens-logs.mjs +192 -1
  357. package/scripts/install-selftest.mjs +58 -2
  358. package/scripts/lib/skills-predicate.mjs +129 -0
  359. package/scripts/rpc-load-check.mjs +3 -1
@@ -4,16 +4,21 @@ import * as path from "node:path";
4
4
  import { noteAuthoritativeContentAttachment } from "./agent-nudge.js";
5
5
  import { captureFileStats, diffFileStats, getOpaqueBaselineStore, recoverOpaqueChangesViaGit, } from "./opaque-mutation-scan.js";
6
6
  import { normalizeMapKey } from "./path-utils.js";
7
- import { extractReadPathsFromCommand, extractDeletedPathsFromCommand, extractGrepSearchReadsFromOutput, extractWrittenPathsFromCommand, } from "./bash-file-access.js";
7
+ import { extractReadPathsFromCommand, extractDeletedPathsFromCommand, extractGrepSearchReadsFromOutput, extractWrittenPathsFromCommand, tokenizeShellCommand, } from "./bash-file-access.js";
8
8
  import { registerSearchReads, } from "./search-read-registration.js";
9
9
  import { createFileTime } from "./file-time.js";
10
10
  import { publishFormatQueued } from "./format-events-publish.js";
11
- import { isPathIgnoredByProject } from "./file-utils.js";
11
+ import { invalidateProjectIgnoreMatcherForPath, isPathIgnoredByProject, } from "./file-utils.js";
12
+ import { invalidateFormatterCacheForPath } from "./formatters.js";
12
13
  import { getFormatService } from "./format-service.js";
13
14
  import { isExternalOrVendorFile, normalizeEphemeralMapKey, pathsEqual, } from "./path-utils.js";
14
15
  import { PathKeyedMap } from "./path-keyed-map.js";
15
16
  import { resolveLanguageRootForFile } from "./language-profile.js";
16
17
  import { logLatency } from "./latency-logger.js";
18
+ import { classifyMutatingTool, PI_LENS_SYNTHETIC_MUTATION_FIELD, } from "./mutating-tool.js";
19
+ import { hasPendingObservation, noteMutationHandled, settleObservedMutation, } from "./observed-mutation.js";
20
+ import { replayThroughMutationBridge, storedLineHashesFor, } from "./observed-mutation-sources.js";
21
+ import { getAmbientAbortSignal } from "./safe-spawn.js";
17
22
  import { resolveToolCallCorrelationId } from "./tool-event.js";
18
23
  import { boundedIndexesForCount, createReadGuardEditBatchSummary, getReadGuardCorrelationId, logReadGuardEvent, } from "./read-guard-logger.js";
19
24
  import { notifyExternalFileChange } from "./lsp/index.js";
@@ -22,7 +27,72 @@ import { renderPostAutofixNotice, } from "./post-autofix-notice.js";
22
27
  import { syncGitGuardRecord } from "./git-guard.js";
23
28
  import { scheduleWordIndexPersist } from "./word-index.js";
24
29
  import { RUNTIME_CONFIG } from "./runtime-config.js";
30
+ import { getActiveSessionId } from "./session-lifecycle.js";
25
31
  const AUTHORITATIVE_CONTENT_MAX_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
32
+ /**
33
+ * Git subcommands that import ANOTHER commit's content into the index. The
34
+ * whole family, with a verdict for each (#2060):
35
+ * - merge, rebase, cherry-pick, pull, revert, am: IN. Each stages the other
36
+ * side's clean files beside the unmerged ones. `pull` is fetch+merge and
37
+ * `am --3way` reaches the same unmerged state, both probed on git 2.55.
38
+ * - stash pop / stash apply: OUT. A conflicted pop leaves `M ` entries too,
39
+ * but that content is the agent's OWN stashed work. Excluding it would
40
+ * destroy exactly what opaque recovery exists to capture.
41
+ * - checkout -m: OUT. Its "incoming" side is the agent's local modifications
42
+ * carried across the switch, so the same reasoning applies.
43
+ * - apply -3 / --3way: OUT. The patch is normally one the agent wrote, and
44
+ * `apply` is far more often used without conflicts, so the narrower default
45
+ * is to keep capturing.
46
+ * Membership only ARMS the filter; it still needs a real unmerged entry to do
47
+ * anything, so a non-integration use of a listed subcommand is inert.
48
+ */
49
+ const GIT_INTEGRATION_SUBCOMMANDS = new Set([
50
+ "merge",
51
+ "rebase",
52
+ "cherry-pick",
53
+ "pull",
54
+ "revert",
55
+ "am",
56
+ ]);
57
+ const GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set([
58
+ "-C",
59
+ "-c",
60
+ "--config-env",
61
+ "--git-dir",
62
+ "--namespace",
63
+ "--work-tree",
64
+ ]);
65
+ /**
66
+ * Failed integration commands are the one opaque-recovery case where Git's
67
+ * index contains changes made by the other branch rather than the agent.
68
+ * Keep this narrow: ordinary scripts and successful Git operations retain the
69
+ * normal recovery contract.
70
+ */
71
+ export function isFailedGitIntegrationCommand(command, isError) {
72
+ if (isError !== true)
73
+ return false;
74
+ return tokenizeShellCommand(command).some(({ tokens, unsupported }) => {
75
+ if (unsupported)
76
+ return false;
77
+ const executable = path.win32
78
+ .basename(tokens[0] ?? "")
79
+ .toLowerCase()
80
+ .replace(/\.(?:cmd|exe)$/, "");
81
+ if (executable !== "git")
82
+ return false;
83
+ for (let index = 1; index < tokens.length; index += 1) {
84
+ const token = tokens[index] ?? "";
85
+ if (GIT_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
86
+ index += 1;
87
+ continue;
88
+ }
89
+ if (token.startsWith("-"))
90
+ continue;
91
+ return GIT_INTEGRATION_SUBCOMMANDS.has(token);
92
+ }
93
+ return false;
94
+ });
95
+ }
26
96
  function parseDiffRanges(diff) {
27
97
  const changedLines = [];
28
98
  for (const line of diff.split("\n")) {
@@ -66,6 +136,102 @@ const lastAnalyzedStateByFile = new PathKeyedMap(normalizeEphemeralMapKey);
66
136
  export function clearLastAnalyzedStateCache() {
67
137
  lastAnalyzedStateByFile.clear();
68
138
  }
139
+ /**
140
+ * Register one in-flight pipeline and hand back the inner map it landed in.
141
+ *
142
+ * Paired with {@link releaseInFlightPipeline}; split out of
143
+ * `dispatchPipelineAnalysis` so registration and deregistration are one seam
144
+ * with one owner (#2464 review round 3, F1) rather than two open-coded blocks
145
+ * ~80 lines apart whose invariants only line up if a reader checks both.
146
+ *
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.
152
+ */
153
+ export function registerInFlightPipeline(filePath, stateHash, pipeline) {
154
+ let filePipelines = inFlightPipelines.get(filePath);
155
+ if (!filePipelines) {
156
+ filePipelines = new Map();
157
+ inFlightPipelines.set(filePath, filePipelines);
158
+ }
159
+ filePipelines.set(stateHash, pipeline);
160
+ return filePipelines;
161
+ }
162
+ /**
163
+ * Release one in-flight pipeline, pruning the per-file inner map once it is
164
+ * empty so a file touched once this session doesn't leave a permanent empty
165
+ * entry in the outer map.
166
+ *
167
+ * The IDENTITY check on the outer delete is load-bearing (#2464 review round 3,
168
+ * F1). `registered` is a reference captured BEFORE the pipeline await. Deleting
169
+ * by path alone assumes the outer entry still IS that map, and when it is not —
170
+ * anything that replaced it while this pipeline ran — the delete evicts a LIVE,
171
+ * unrelated pipeline's registration: its concurrent duplicates then stop
172
+ * deduping and its `participantIds`/`participantTotal` under-count. Delete only
173
+ * when the outer map still points at the very map this registration went into.
174
+ */
175
+ export function releaseInFlightPipeline(filePath, stateHash, registered) {
176
+ registered.delete(stateHash);
177
+ if (registered.size === 0 && inFlightPipelines.get(filePath) === registered) {
178
+ inFlightPipelines.delete(filePath);
179
+ }
180
+ }
181
+ /**
182
+ * The two pre-conditions EVERY pipeline dispatch shares (#2464 review round 3,
183
+ * F1): the in-flight dedup for a concurrent call on the same post-write state,
184
+ * and the already-analysed latch for a sequential duplicate in the same turn.
185
+ *
186
+ * Both call sites of {@link dispatchPipelineAnalysis} consult this, so the
187
+ * PRE-conditions are as by-construction as the post-conditions that helper
188
+ * already owns. The observed-mutation path shipped without either check in
189
+ * round 2: two concurrent observed `tool_result`s for one file+hash both
190
+ * registered, the second overwrote the first's entry in the per-file map, and
191
+ * the first's release then evicted the whole outer entry — taking a live,
192
+ * unrelated classified pipeline's registration with it.
193
+ *
194
+ * ## Why this is SYNCHRONOUS and hands the join promise back
195
+ *
196
+ * Check-then-act here has to be atomic against the microtask queue.
197
+ * `dispatchPipelineAnalysis` registers synchronously before its own first
198
+ * await, so a caller that claims and then dispatches with no `await` in between
199
+ * cannot be interleaved. An `async` pre-check would reintroduce exactly that
200
+ * interleave — both racers see an empty registry, both resume, both dispatch —
201
+ * so the join's `await` is deliberately left to the caller.
202
+ *
203
+ * ## Why this is NOT folded into `dispatchPipelineAnalysis`
204
+ *
205
+ * On the classified chain the latch check sits ABOVE `addModifiedRange` and
206
+ * `recordProjectChange`. Moving it down into the helper would write
207
+ * `turn-state.json` ranges and an attributed change-log receipt for a state
208
+ * already analysed this turn — the duplicate-recording inversion #2464 exists
209
+ * to remove.
210
+ */
211
+ export function claimPipelineDispatch(args) {
212
+ const { filePath, stateHash, turnIndex, participantId, dbg } = args;
213
+ // Deduplicate concurrent calls for the same final file state (pi can fire one
214
+ // tool_result per edit hunk). Do not dedupe by file alone: a distinct later
215
+ // same-turn edit to this file must still be analyzed.
216
+ const inFlight = inFlightPipelines.get(filePath)?.get(stateHash);
217
+ if (inFlight) {
218
+ dbg(`tool_result: skipping duplicate concurrent state for ${filePath}`);
219
+ if (inFlight.participantIds.length < 100) {
220
+ inFlight.participantIds.push(participantId);
221
+ }
222
+ inFlight.participantTotal += 1;
223
+ return { proceed: false, joined: inFlight.promise };
224
+ }
225
+ // Deduplicate sequential duplicate events for the same post-write state in
226
+ // the same turn while allowing later same-file edits whose content changed.
227
+ const lastAnalyzed = lastAnalyzedStateByFile.get(filePath);
228
+ if (lastAnalyzed?.turnIndex === turnIndex &&
229
+ lastAnalyzed.stateHash === stateHash) {
230
+ dbg(`tool_result: skipping already-analyzed file state this turn for ${filePath}`);
231
+ return { proceed: false, joined: undefined };
232
+ }
233
+ return { proceed: true };
234
+ }
69
235
  const debouncedPipelines = new PathKeyedMap(normalizeEphemeralMapKey);
70
236
  const DEFAULT_DEBOUNCE_MS = 0;
71
237
  const MAX_DEBOUNCE_MS = 1000;
@@ -167,21 +333,32 @@ function getFileStateHash(filePath) {
167
333
  return `unreadable:${code}`;
168
334
  }
169
335
  }
170
- function getRequestedEditCount(event) {
171
- if (event.toolName === "write")
336
+ function getRequestedEditCount(event, kind) {
337
+ if (kind === "write")
172
338
  return 1;
173
339
  const edits = event.input?.edits;
174
340
  return Array.isArray(edits) && edits.length > 0 ? edits.length : 1;
175
341
  }
176
- function getRequestedEditIndexes(event) {
177
- return boundedIndexesForCount(getRequestedEditCount(event));
342
+ function getRequestedEditIndexes(event, kind) {
343
+ return boundedIndexesForCount(getRequestedEditCount(event, kind));
178
344
  }
179
- function sourceForToolName(toolName, details) {
345
+ /**
346
+ * Change-log attribution for one classified mutation (#2423).
347
+ *
348
+ * A third-party tool gets its OWN source (`agent-tool:<name>`) rather than
349
+ * being folded into `agent-edit`: a report that attributes a change to "the
350
+ * model's edit tool" must not silently absorb an extension's rewrite.
351
+ */
352
+ function sourceForMutation(classification, details) {
180
353
  if (details
181
354
  ?.piLensPartialApply) {
182
355
  return "partial-apply";
183
356
  }
184
- return toolName === "write" ? "agent-write" : "agent-edit";
357
+ if (classification.provenance === "builtin" ||
358
+ classification.provenance === "bash-derived") {
359
+ return classification.kind === "write" ? "agent-write" : "agent-edit";
360
+ }
361
+ return `agent-tool:${classification.toolName}`;
185
362
  }
186
363
  function singleRange(ranges) {
187
364
  return ranges?.length === 1 ? ranges[0] : undefined;
@@ -198,8 +375,299 @@ function recordProjectChange(args) {
198
375
  onAppendError: (err) => args.dbg(`project change log append failed for ${args.filePath}: ${err}`),
199
376
  });
200
377
  }
378
+ /**
379
+ * #2402 applied-edit records for a native edit tool, so an identical retry is
380
+ * recognized as already-applied instead of escalating through the
381
+ * oldText-not-found ladder. `input` carries the EXECUTED args (post-autopatch),
382
+ * the same text the preflight resolves against.
383
+ *
384
+ * Extracted (#2449 review round 4, S4) because the observational-settle skip
385
+ * path needs the IDENTICAL records: the mutation bridge does not write them, so
386
+ * an early return that skipped this block left every retry of an observed
387
+ * tool's edit looking unapplied. One body, two call sites — a second copy on the
388
+ * skip path is the drift this repo keeps catching.
389
+ */
390
+ function recordNativeAppliedPairs(args) {
391
+ const pairs = [];
392
+ if (args.kind !== "edit")
393
+ return pairs;
394
+ const appliedInput = args.input;
395
+ const record = (oldText, newText) => {
396
+ if (typeof oldText === "string" && oldText.length > 0) {
397
+ args.runtime.partialApplyRecords.record(args.filePath, oldText, newText, args.stateHash);
398
+ pairs.push({ oldText, newText });
399
+ }
400
+ };
401
+ if (Array.isArray(appliedInput.edits)) {
402
+ for (const edit of appliedInput.edits)
403
+ record(edit.oldText, edit.newText);
404
+ }
405
+ else {
406
+ record(appliedInput.oldText, appliedInput.newText);
407
+ }
408
+ return pairs;
409
+ }
410
+ /**
411
+ * Keep `cachedExports` in sync after each write/edit so the pre-write STOP check
412
+ * does not fire on names that were removed from this file this session.
413
+ *
414
+ * Extracted for the same reason as {@link recordNativeAppliedPairs} (#2449
415
+ * review round 4, S4): the mutation bridge does not touch this cache, so the
416
+ * observational-settle skip path left it holding names an observed tool had
417
+ * just deleted.
418
+ */
419
+ function refreshCachedExports(runtime, filePath) {
420
+ if (runtime.cachedExports.size === 0 || !nodeFs.existsSync(filePath))
421
+ return;
422
+ const exportRe = new RegExp("export\\s+(?:async\\s+)?(?:function|class|const|let|type|interface)\\s+(\\w+)", "g");
423
+ for (const [name, file] of runtime.cachedExports) {
424
+ if (path.resolve(file) === path.resolve(filePath)) {
425
+ runtime.cachedExports.delete(name);
426
+ }
427
+ }
428
+ try {
429
+ const freshContent = nodeFs.readFileSync(filePath, "utf-8");
430
+ for (const match of freshContent.matchAll(exportRe)) {
431
+ const name = match[1];
432
+ if (!runtime.cachedExports.has(name)) {
433
+ runtime.cachedExports.set(name, filePath);
434
+ }
435
+ }
436
+ }
437
+ catch {
438
+ // Non-fatal — stale entry is worse than a missing one
439
+ }
440
+ }
441
+ /**
442
+ * #2464: the pipeline's own lint/diagnostics dispatch, factored out of the
443
+ * block below that ALSO writes `turn-state.json` modified ranges and the
444
+ * attributed change-log receipt (`cacheManager.addModifiedRange` /
445
+ * `recordProjectChange`). The classified chain in `handleToolResult` still
446
+ * does both, in the same order as before this split — see the call site
447
+ * right after `recordProjectChange` below, which is byte-identical to what
448
+ * used to be inlined there.
449
+ *
450
+ * The observed-mutation early return (#2430/#2449 round 4, S4) is the SECOND
451
+ * caller: the mutation bridge already recorded the turn-state range and the
452
+ * change-log receipt for that edit, so it calls this helper for analysis
453
+ * WITHOUT asking for the recording a second time — which a shared "run the
454
+ * pipeline AND record" block could not express.
455
+ */
456
+ async function dispatchPipelineAnalysis(args) {
457
+ const { deps, runtime, filePath, dispatchCwd, turnStateCwd, autofixMode, modifiedRanges, writeIndex, initialStateHash, readGuardCorrelationId, requestedEditIndexes, requestedEditTotal, isPartialApplyResult, toolResultStart, nativeAppliedPairs, } = args;
458
+ const { event, getFlag, getFlagSource, dbg, biomeClient, ruffClient, metricsClient, resetLSPService, } = deps;
459
+ const pipelinePromise = runPipeline({
460
+ filePath,
461
+ cwd: dispatchCwd,
462
+ projectRoot: turnStateCwd,
463
+ toolName: event.toolName,
464
+ autofixMode,
465
+ modifiedRanges,
466
+ telemetry: {
467
+ model: runtime.telemetryModel,
468
+ sessionId: runtime.telemetrySessionId,
469
+ turnIndex: runtime.turnIndex,
470
+ writeIndex,
471
+ modelId: runtime.telemetryModelId,
472
+ provider: runtime.telemetryProviderId,
473
+ },
474
+ getFlag,
475
+ getFlagSource,
476
+ dbg,
477
+ // #451: hand the deferred cascade live sequence accessors so the
478
+ // review-graph builder can skip its per-build O(project) sweep when
479
+ // only pi-observed edits happened. projectSeq is a function because the
480
+ // cascade runs after this returns (#450) — read current, not captured.
481
+ seqState: {
482
+ projectSeq: () => runtime.projectSeq,
483
+ getFilesChangedSince: (seq) => runtime.getFilesChangedSince(seq),
484
+ },
485
+ // The settle clock is live because the deferred cascade may reach its
486
+ // budget derivation before or after turn_end starts waiting.
487
+ turnEndCascadeSettleStart: () => runtime.getTurnEndCascadeSettleStart(),
488
+ // #348 phase 2: live reference so the deferred cascade can update the
489
+ // warm word index in place at the same seam as the graph rebuild.
490
+ // `runtime.wordIndex` is read fresh (not captured) via this closure-free
491
+ // property access being re-evaluated at object-literal construction
492
+ // time here — that's fine because runPipeline reads `ctx.wordIndex`
493
+ // synchronously into computeCascadeForFile's options before returning
494
+ // (the deferred part is the cascade's OWN execution, not this handoff).
495
+ wordIndex: runtime.wordIndex,
496
+ onWordIndexUpdated: (index) => {
497
+ scheduleWordIndexPersist(dispatchCwd, index, dbg);
498
+ },
499
+ }, {
500
+ biomeClient,
501
+ ruffClient,
502
+ metricsClient,
503
+ getFormatService,
504
+ fixedThisTurn: runtime.fixedThisTurn,
505
+ });
506
+ const pipelineTelemetry = {
507
+ promise: pipelinePromise,
508
+ participantIds: [...new Set(args.participantIds)].slice(0, 100),
509
+ participantTotal: args.participantTotal,
510
+ };
511
+ // Synchronous, and the FIRST thing after `runPipeline` handed back its
512
+ // promise: `claimPipelineDispatch` at each call site is only atomic because
513
+ // nothing awaits between the claim and this registration.
514
+ const registeredPipelines = registerInFlightPipeline(filePath, initialStateHash, pipelineTelemetry);
515
+ let result;
516
+ try {
517
+ result = await pipelinePromise;
518
+ }
519
+ catch (pipelineErr) {
520
+ if (getFlag("lens-guard")) {
521
+ runtime.markGitGuardCacheUnknown("pipeline_crash");
522
+ }
523
+ dbg(`runPipeline crashed: ${pipelineErr}`);
524
+ logReadGuardEvent({
525
+ event: "edit_post_edit_pipeline_failed",
526
+ correlationId: readGuardCorrelationId,
527
+ filePath,
528
+ metadata: {
529
+ tool: event.toolName,
530
+ commitStatus: "committed",
531
+ reasonCode: "pipeline_failed",
532
+ },
533
+ });
534
+ logReadGuardEvent({
535
+ event: "edit_batch_summary",
536
+ correlationId: readGuardCorrelationId,
537
+ filePath,
538
+ metadata: {
539
+ tool: event.toolName,
540
+ editBatchSummary: createReadGuardEditBatchSummary({
541
+ requestedIndexes: requestedEditIndexes,
542
+ requestedTotal: requestedEditTotal,
543
+ resolvedIndexes: requestedEditIndexes,
544
+ resolvedTotal: requestedEditTotal,
545
+ appliedIndexes: requestedEditIndexes,
546
+ appliedTotal: requestedEditTotal,
547
+ participantIds: pipelineTelemetry.participantIds,
548
+ participantTotal: pipelineTelemetry.participantTotal,
549
+ commitStatus: "committed",
550
+ postEditStatus: "failed",
551
+ terminalStatus: "failed",
552
+ durationMs: Date.now() - toolResultStart,
553
+ }),
554
+ },
555
+ });
556
+ dbg(`runPipeline crash stack: ${pipelineErr.stack}`);
557
+ // The LSP fleet is process-wide, but a pipeline crash belongs to one
558
+ // evaluation. A registered primary owns the fleet; a known secondary
559
+ // must not tear it down. Keep the historical reset when no registration
560
+ // exists because synthetic callers and early startup have no role evidence.
561
+ const activePrimarySessionId = getActiveSessionId();
562
+ const crashBelongsToPrimary = activePrimarySessionId === undefined ||
563
+ activePrimarySessionId === runtime.telemetrySessionId;
564
+ if (!getFlag("no-lsp") && crashBelongsToPrimary) {
565
+ resetLSPService({ fast: true, reason: "pipeline_crash" });
566
+ }
567
+ logLatency({
568
+ type: "tool_result",
569
+ toolName: event.toolName,
570
+ filePath,
571
+ durationMs: Date.now() - toolResultStart,
572
+ result: "pipeline_crash",
573
+ });
574
+ const notice = runtime.formatPipelineCrashNotice(filePath, pipelineErr);
575
+ return {
576
+ crashed: true,
577
+ response: {
578
+ content: notice
579
+ ? [...event.content, { type: "text", text: notice }]
580
+ : event.content,
581
+ isError: true,
582
+ },
583
+ };
584
+ }
585
+ finally {
586
+ releaseInFlightPipeline(filePath, initialStateHash, registeredPipelines);
587
+ }
588
+ if (!isPartialApplyResult) {
589
+ const postEditStatus = result.isError ? "failed" : "succeeded";
590
+ if (result.isError) {
591
+ logReadGuardEvent({
592
+ event: "edit_post_edit_pipeline_failed",
593
+ correlationId: readGuardCorrelationId,
594
+ filePath,
595
+ metadata: {
596
+ tool: event.toolName,
597
+ commitStatus: "committed",
598
+ reasonCode: "pipeline_failed",
599
+ },
600
+ });
601
+ }
602
+ logReadGuardEvent({
603
+ event: "edit_batch_summary",
604
+ correlationId: readGuardCorrelationId,
605
+ filePath,
606
+ metadata: {
607
+ tool: event.toolName,
608
+ editBatchSummary: createReadGuardEditBatchSummary({
609
+ requestedIndexes: requestedEditIndexes,
610
+ requestedTotal: requestedEditTotal,
611
+ resolvedIndexes: requestedEditIndexes,
612
+ resolvedTotal: requestedEditTotal,
613
+ appliedIndexes: requestedEditIndexes,
614
+ appliedTotal: requestedEditTotal,
615
+ participantIds: pipelineTelemetry.participantIds,
616
+ participantTotal: pipelineTelemetry.participantTotal,
617
+ commitStatus: "committed",
618
+ postEditStatus,
619
+ terminalStatus: postEditStatus === "failed" ? "failed" : "success",
620
+ durationMs: Date.now() - toolResultStart,
621
+ }),
622
+ },
623
+ });
624
+ }
625
+ // ── Post-pipeline post-conditions (#2464 review round 2, S1) ───────────────
626
+ // These live INSIDE the helper rather than at its call sites so EVERY
627
+ // provenance gets them by construction. The observed path used to discard
628
+ // `result` entirely, so under `--immediate-format` the pipeline rewrote the
629
+ // file and none of the three below ran: the next edit was then blocked with
630
+ // a spurious `file_modified` (the read-guard staleness stamp was never
631
+ // re-taken), an identical retry escalated through the oldText-not-found
632
+ // ladder (the #2402 after-write hash was never stamped), and a duplicate
633
+ // tool_result for the same bytes analysed the file a second time (the
634
+ // already-analysed latch was never set).
635
+ const finalStateHash = getFileStateHash(filePath);
636
+ lastAnalyzedStateByFile.set(filePath, {
637
+ turnIndex: runtime.turnIndex,
638
+ stateHash: finalStateHash,
639
+ });
640
+ // #2402: pi-lens' own immediate format/autofix may have rewritten the file
641
+ // after the native edit was recorded by the caller. Stamp the post-pipeline
642
+ // state so an identical retry against the formatted bytes is still
643
+ // recognized as already-applied. Only the pairs recorded this invocation are
644
+ // stamped, and only when the pipeline actually changed the bytes.
645
+ // `initialStateHash` IS the post-write hash at both call sites — the
646
+ // classified chain passes `postWriteStateHash` verbatim, the observed path
647
+ // passes the same pre-settle digest.
648
+ if (finalStateHash !== initialStateHash) {
649
+ for (const pair of nativeAppliedPairs) {
650
+ runtime.partialApplyRecords.noteAfterWriteHash(filePath, pair.oldText, pair.newText, finalStateHash);
651
+ }
652
+ }
653
+ // The model's write/edit and pi-lens' own immediate format/autofix are now
654
+ // reflected on disk. Refresh read-guard staleness stamps so a follow-up edit
655
+ // is judged by read-range coverage, not by our own previous write.
656
+ if (!getFlag("no-read-guard")) {
657
+ const changedForReadGuard = new Set([
658
+ path.resolve(filePath),
659
+ ...(result.changedFiles ?? []).map((changedFile) => path.resolve(changedFile)),
660
+ ]);
661
+ for (const changedFile of changedForReadGuard) {
662
+ if (nodeFs.existsSync(changedFile)) {
663
+ deps.readGuard?.recordWritten(changedFile);
664
+ }
665
+ }
666
+ }
667
+ return { crashed: false, result };
668
+ }
201
669
  export async function handleToolResult(deps) {
202
- const { event, getFlag, getFlagSource, dbg, runtime, cacheManager, biomeClient, ruffClient, metricsClient, resetLSPService, agentBehaviorRecord, formatBehaviorWarnings, } = deps;
670
+ const { event, getFlag, dbg, runtime, cacheManager, agentBehaviorRecord, formatBehaviorWarnings, } = deps;
203
671
  const rawFilePath = event.input.path;
204
672
  const workspaceRoot = runtime.projectRoot || process.cwd();
205
673
  // #1642: a gitignored worktree edit got re-attributed onto a
@@ -269,6 +737,10 @@ export async function handleToolResult(deps) {
269
737
  ? rawFilePath
270
738
  : path.resolve(resolutionBasis, rawFilePath)
271
739
  : rawFilePath;
740
+ if (filePath) {
741
+ invalidateProjectIgnoreMatcherForPath(filePath);
742
+ invalidateFormatterCacheForPath(filePath);
743
+ }
272
744
  // Purely diagnostic: tool_call's call-time verdict (computed on ITS OWN
273
745
  // resolved path, which may since have been superseded) disagreed with
274
746
  // what actually executed. This does NOT gate anything by itself — the
@@ -351,16 +823,41 @@ export async function handleToolResult(deps) {
351
823
  }
352
824
  else if (pending.strategy === "git") {
353
825
  // Git-first: no universe cap - works on any repo size.
354
- const recovery = await recoverOpaqueChangesViaGit(scanRoot, pending.startedAt);
826
+ const recovery = await recoverOpaqueChangesViaGit(scanRoot, pending.startedAt, {
827
+ excludeIndexOnlyWhenUnmerged: isFailedGitIntegrationCommand(command, event.isError),
828
+ });
355
829
  if (recovery.verdict === "recovered") {
356
830
  opaquePaths = recovery.paths.filter((p) => !isExternalOrVendorFile(p, scanRoot) &&
357
831
  !isPathIgnoredByProject(p, scanRoot, false));
358
832
  }
359
- else if (recovery.verdict === "unknown" && recognized.length > 0) {
360
- // Git hiccup on a partially-recognized command: the
361
- // remainder's coverage is unknown, never clean-by-default.
833
+ else if (recovery.verdict === "unknown") {
834
+ // #2060: deliberately WIDER than the old `recognized.length > 0`
835
+ // guard. A fully opaque command whose probe failed is the shape
836
+ // whose coverage is least knowable, and it used to record
837
+ // nothing at all.
362
838
  unknownReason = recovery.unknownReason;
363
839
  }
840
+ // #2060: both counts are bounded (one record per tool_result, no
841
+ // per-path logging) and exist because filtering is invisible in
842
+ // production otherwise - the dropped paths simply never appear.
843
+ if ((recovery.excludedIncomingCount ?? 0) > 0) {
844
+ logLatency({
845
+ type: "phase",
846
+ phase: "opaque_mutation_incoming_excluded",
847
+ filePath: command.slice(0, 80),
848
+ durationMs: Date.now() - started,
849
+ result: `excluded:${recovery.excludedIncomingCount}`,
850
+ });
851
+ }
852
+ if ((recovery.unknownStatusCount ?? 0) > 0) {
853
+ logLatency({
854
+ type: "phase",
855
+ phase: "opaque_mutation_status_pair_unknown",
856
+ filePath: command.slice(0, 80),
857
+ durationMs: Date.now() - started,
858
+ result: `kept:${recovery.unknownStatusCount}`,
859
+ });
860
+ }
364
861
  }
365
862
  else if (pending.stats) {
366
863
  const outcome = await captureFileStats(scanRoot, {
@@ -427,6 +924,10 @@ export async function handleToolResult(deps) {
427
924
  toolName: "write",
428
925
  input: { path: wp },
429
926
  isError: false,
927
+ // #2423: pi-lens SYNTHESIZED this write from a bash command, so the
928
+ // seam reports `provenance: "bash-derived"` and a consumer can tell
929
+ // it apart from the host's own write tool.
930
+ [PI_LENS_SYNTHETIC_MUTATION_FIELD]: "bash",
430
931
  };
431
932
  const syntheticResult = await handleToolResult({
432
933
  ...deps,
@@ -525,8 +1026,254 @@ export async function handleToolResult(deps) {
525
1026
  });
526
1027
  }
527
1028
  }
528
- if (event.toolName !== "write" && event.toolName !== "edit") {
529
- dbg(`tool_result: skipped turn tracking - toolName="${event.toolName}" (not write/edit)`);
1029
+ // #2423: THE inbound gate. Everything below this line is the mutation
1030
+ // bookkeeping chain — read-guard staleness stamp, turn state, change-log
1031
+ // receipt, deferred autofix and format. Before the seam this compared
1032
+ // `event.toolName` to two literals, so a host or extension edit tool under
1033
+ // any other name was dropped here with the path already resolved.
1034
+ // `recognizeOnly` (#2423 review round 1, finding F5): the tool_call side
1035
+ // already ran the adapters against the PRE-edit file and logged what they
1036
+ // resolved. Re-running them here logged a second `touched_lines_detected`
1037
+ // for every edit and an `edit_preflight_blocked` for edits that were never
1038
+ // blocked, and any anchor it re-resolved would be resolved against content
1039
+ // the edit has already rewritten. The seam carries the tool_call ranges
1040
+ // forward by `toolCallId` instead.
1041
+ const mutation = classifyMutatingTool(event, {
1042
+ filePath: filePath || undefined,
1043
+ sessionId: deps.sessionId,
1044
+ recognizeOnly: true,
1045
+ });
1046
+ // #2430: settle the observational baseline BEFORE the classification gate
1047
+ // returns. On the FIRST call of an unknown tool nothing below this line will
1048
+ // run — that is the bug — so the disk diff is the only thing that can put
1049
+ // the file in `turn-state.json`, and it replays through the mutation bridge
1050
+ // to get the identical bookkeeping a `write` gets.
1051
+ //
1052
+ // The PROBE is synchronous and is the first thing asked, so the
1053
+ // overwhelmingly common call — no baseline armed — pays one map lookup and
1054
+ // nothing else (#2449 round 2, F1).
1055
+ //
1056
+ // The SETTLE is async (#2449 round 3, T4: no synchronous filesystem work on
1057
+ // this path), so it yields. `handleToolResult` has no other `await` before
1058
+ // it registers with `debouncedPipelines`/`inFlightPipelines`, and
1059
+ // `tests/clients/runtime-tool-result-debounce.test.ts` asserts that a
1060
+ // racing second tool_result for the same path cannot miss the first's
1061
+ // entry — so everything the chain below derives from the file's POST-RESULT
1062
+ // bytes is read HERE, before the yield. Otherwise the racing call rewrites
1063
+ // the file while this one is awaiting and this one registers under the
1064
+ // OTHER call's state hash, collapsing two distinct pipelines into one.
1065
+ const observationPending = !getFlag("no-read-guard") &&
1066
+ hasPendingObservation(resolveToolCallCorrelationId(event));
1067
+ // #2464 review round 2 (S2): NOT gated on `mutation !== undefined` any more.
1068
+ // The observed path now dispatches on call ONE of an unknown tool too, and
1069
+ // that call is by definition unclassified — gating the pre-yield digest on a
1070
+ // classification that does not exist yet would have left exactly the racing
1071
+ // re-read this hoist exists to prevent.
1072
+ const preSettleStateHash = observationPending && filePath ? getFileStateHash(filePath) : undefined;
1073
+ let observedReplayed = 0;
1074
+ let observedChangedPaths = [];
1075
+ if (observationPending) {
1076
+ const observed = await settleObservedMutation({
1077
+ toolCallId: resolveToolCallCorrelationId(event),
1078
+ toolName: event.toolName,
1079
+ sessionGeneration: runtime.sessionGeneration,
1080
+ turnIndex: runtime.turnIndex,
1081
+ signal: getAmbientAbortSignal(),
1082
+ record: replayThroughMutationBridge,
1083
+ getStoredLineHashes: (candidate) => storedLineHashesFor(deps.readGuard, candidate),
1084
+ isRecordable: (candidate) => !isExternalOrVendorFile(candidate, workspaceRoot) &&
1085
+ !isPathIgnoredByProject(candidate, workspaceRoot, false),
1086
+ dbg,
1087
+ });
1088
+ observedReplayed = observed.replayed;
1089
+ observedChangedPaths = observed.changedPaths;
1090
+ if (observed.replayed > 0) {
1091
+ dbg(`tool_result: observed ${observed.replayed} mutation(s) from tool "${event.toolName}"`);
1092
+ }
1093
+ else if (observed.reason) {
1094
+ dbg(`tool_result: observation for "${event.toolName}" did not settle: ${observed.reason}`);
1095
+ }
1096
+ }
1097
+ // #2464 review round 2 (S5): ONE clock, started above BOTH exits, so
1098
+ // `edit_batch_summary.durationMs` and the `tool_result` latency row measure
1099
+ // the same span whichever provenance a tool takes. The observed path used to
1100
+ // start its clock at the dispatch call itself, so its durations were
1101
+ // pipeline-only and silently incomparable with the classified path's.
1102
+ // Deliberately below the settle: neither path should be charged for the
1103
+ // observational disk diff, which is arm-time work, not tool_result work.
1104
+ const toolResultStart = Date.now();
1105
+ // #2449 review round 3 (S2), narrowed in round 4 (S4). A tool learned from
1106
+ // ONE observation is classified by NAME from here on AND is still armed —
1107
+ // that second property is what makes a second observation, and therefore
1108
+ // persistence, reachable at all (round 2, F2). Both halves then recorded the
1109
+ // same physical edit: the settle above replayed it through the mutation
1110
+ // bridge with measured ranges, and the chain below recorded it AGAIN as a
1111
+ // whole-file change, so a three-edit session produced four change-log
1112
+ // receipts.
1113
+ //
1114
+ // The settle wins, deliberately. It ran the disk diff, so it knows which
1115
+ // LINES moved; the chain, reached through `recognizeOnly`, has no ranges for
1116
+ // an unknown tool and over-approximates to the file. Skipping the arm
1117
+ // instead would be the other way to fix this, and it is the wrong one — it
1118
+ // makes PERSIST_AFTER_OBSERVATIONS unreachable again.
1119
+ //
1120
+ // ## What this return skips, stated in full (round 4, S4; #2464)
1121
+ //
1122
+ // The first cut called this "skipping turn tracking" and returned. It skipped
1123
+ // considerably more than turn tracking, and THREE of those steps have no
1124
+ // counterpart in `recordMutationThroughSeam`, so nothing else ran them. Those
1125
+ // three run here, before the return:
1126
+ //
1127
+ // - the #2402 applied-edit records — without them an identical retry of
1128
+ // the observed tool's edit escalates through the oldText-not-found ladder
1129
+ // instead of being recognized as already applied;
1130
+ // - `recordMutationToolReceipt`, the write→edit sticky turn transition,
1131
+ // which is pure ordering state the bridge never touches;
1132
+ // - the `cachedExports` refresh — without it the pre-write STOP check keeps
1133
+ // firing on names this very edit removed.
1134
+ //
1135
+ // What stays skipped is skipped because the BRIDGE already did it: the
1136
+ // read-guard staleness stamp, the `turn-state.json` modified ranges, the
1137
+ // attributed change-log receipt, and the deferred autofix/format pair —
1138
+ // `recordMutationThroughSeam` steps 1-4. #2464 pulled the pipeline's own
1139
+ // lint/diagnostics dispatch (`dispatchPipelineAnalysis`, defined above) out
1140
+ // of the block below that also writes those same turn-state ranges and the
1141
+ // change-log receipt, so it can be called HERE too — analysis without asking
1142
+ // for the recording a second time.
1143
+ //
1144
+ // ## Why this sits ABOVE the classification gates (#2464 review round 2, S2)
1145
+ //
1146
+ // It used to sit BELOW `mutation === undefined`, which meant CALL ONE of every
1147
+ // unknown tool — the call the observational net exists for — was recorded by
1148
+ // the bridge and then returned unanalysed: `runPipeline` was never reached, so
1149
+ // the file was fixed and formatted by the `agent_settled` drain but never
1150
+ // linted, and AC2 ("an observed mutation gets its pipeline analysis in the
1151
+ // same turn") was met on the second call only. `observedReplayed > 0` is the
1152
+ // whole condition now: the bridge recorded this edit, whoever the tool is.
1153
+ if (observedReplayed > 0) {
1154
+ // ## The `hostToolResultFailed` asymmetry, stated rather than hidden
1155
+ //
1156
+ // The classified chain returns below on `event.isError === true` with a
1157
+ // `commitStatus: "failed"` receipt; this block does not consult it, so an
1158
+ // unknown tool that reports failure and STILL moved bytes is recorded,
1159
+ // analysed, and receipted as `committed`. That is deliberate: the
1160
+ // observation is disk evidence, not a claim — the bytes changed whatever
1161
+ // the tool says about itself — and the classified early return is safe
1162
+ // only because a failed host edit did not write. Reading a third-party
1163
+ // tool's self-reported status as authority over what is on disk is the
1164
+ // exact gap the observational net exists to close.
1165
+ //
1166
+ // The gates below (`mutation === undefined` / `!filePath` /
1167
+ // `isExternalOrVendorFile`) are deliberately NOT consulted for the SKIP:
1168
+ // once the bridge has recorded the edit, re-running the classified chain
1169
+ // double-records it regardless of how the tool classifies. They are
1170
+ // consulted for what this block can still usefully DO.
1171
+ if (filePath && !isExternalOrVendorFile(filePath, workspaceRoot)) {
1172
+ // On call ONE the tool is not classified at all, so there is no
1173
+ // `mutation.kind` to read. The observation itself is the authority and
1174
+ // it only ever records "edit" (`settleObservedMutation` hard-codes
1175
+ // `kind: "edit"`, and #2430 tier 4 only ever classifies a learned tool
1176
+ // as "edit"), so the two agree wherever both exist.
1177
+ const observedKind = mutation?.kind ?? "edit";
1178
+ const observedStateHash = preSettleStateHash ?? getFileStateHash(filePath);
1179
+ const observedAppliedPairs = recordNativeAppliedPairs({
1180
+ runtime,
1181
+ input: event.input,
1182
+ kind: observedKind,
1183
+ filePath,
1184
+ stateHash: observedStateHash,
1185
+ });
1186
+ const receiptOutcome = runtime.recordMutationToolReceipt?.call(runtime, filePath, observedKind);
1187
+ refreshCachedExports(runtime, filePath);
1188
+ // Same fallback formula the classified chain uses below, minus its
1189
+ // `_bypassDebounce` branch (#2464 review round 2, S3): that branch was
1190
+ // unreachable here. Both callers that set `_bypassDebounce` arrive with
1191
+ // no pending baseline — `scheduleDebounced`'s re-entry because THIS
1192
+ // call already consumed it at the settle above, and the bash-derived
1193
+ // synthetic write because `runtime-tool-call.ts` excludes `bash` from
1194
+ // arming outright (`toolName !== "bash"`). `observedKind` is always
1195
+ // "edit", so the un-receipted default is "deferred" either way.
1196
+ const observedAutofixMode = receiptOutcome?.autofixMode ??
1197
+ (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))) {
1206
+ const observedReadGuardCorrelationId = getReadGuardCorrelationId(event);
1207
+ // #2464 review round 3 (F1): the SAME two pre-conditions the
1208
+ // classified site consults, through the same shared claim. Round 2
1209
+ // dispatched here with neither, so two concurrent observed
1210
+ // `tool_result`s for one file+hash both registered and both ran
1211
+ // `runPipeline` — two autofix/format writers racing on one file — the
1212
+ // second overwrote the first's registry entry, and the first's
1213
+ // release then evicted the outer entry a live, unrelated classified
1214
+ // 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;
1225
+ }
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
+ }
1265
+ 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
+ }
1267
+ 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`);
1269
+ }
1270
+ }
1271
+ return syntheticWriteContent.length > 0
1272
+ ? { content: [...event.content, ...syntheticWriteContent] }
1273
+ : undefined;
1274
+ }
1275
+ if (mutation === undefined) {
1276
+ dbg(`tool_result: skipped turn tracking - toolName="${event.toolName}" is not a classified mutation`);
530
1277
  return syntheticWriteContent.length > 0
531
1278
  ? { content: [...event.content, ...syntheticWriteContent] }
532
1279
  : undefined;
@@ -539,11 +1286,15 @@ export async function handleToolResult(deps) {
539
1286
  dbg(`tool_result: skipped pipeline - file outside project root or in node_modules: ${filePath}`);
540
1287
  return;
541
1288
  }
1289
+ // #2430: a classified mutation is accounted for by the chain below, so the
1290
+ // `agent_settled` sweep must re-baseline this file rather than report the
1291
+ // same bytes as unexplained drift.
1292
+ noteMutationHandled(filePath);
542
1293
  const readGuardCorrelationId = getReadGuardCorrelationId(event);
543
1294
  const resultDetails = (event.details ?? {});
544
1295
  const isPartialApplyResult = resultDetails.piLensPartialApply === true;
545
- const requestedEditIndexes = getRequestedEditIndexes(event);
546
- const requestedEditTotal = getRequestedEditCount(event);
1296
+ const requestedEditIndexes = getRequestedEditIndexes(event, mutation.kind);
1297
+ const requestedEditTotal = getRequestedEditCount(event, mutation.kind);
547
1298
  const participantIds = [
548
1299
  ...(deps._telemetryParticipantIds ?? []),
549
1300
  readGuardCorrelationId,
@@ -576,16 +1327,37 @@ export async function handleToolResult(deps) {
576
1327
  });
577
1328
  return { content: event.content, isError: true };
578
1329
  }
1330
+ // One post-result raw-byte hash, reused for the applied-edit records below and
1331
+ // for the pipeline dedup key (`initialStateHash`) further down (finding 3):
1332
+ // nothing between here and that point rewrites the file, so a second read
1333
+ // would only re-derive the same digest.
1334
+ //
1335
+ // `preSettleStateHash` is that same digest taken BEFORE the observational
1336
+ // settle's yield, on the only path that has one (#2449 round 3, T4). Reusing
1337
+ // it is not an optimization: re-reading here would read whatever a racing
1338
+ // tool_result wrote while this call was awaiting.
1339
+ const postWriteStateHash = preSettleStateHash ?? getFileStateHash(filePath);
1340
+ // #2402: a fully-applied native edit is also an applied record, so an
1341
+ // identical retry is recognized as already-applied instead of escalating
1342
+ // through the oldText-not-found ladder. `event.input` carries the EXECUTED
1343
+ // args (post-autopatch), the same text the preflight resolves against.
1344
+ const nativeAppliedPairs = recordNativeAppliedPairs({
1345
+ runtime,
1346
+ input: event.input,
1347
+ kind: mutation.kind,
1348
+ filePath,
1349
+ stateHash: postWriteStateHash,
1350
+ });
579
1351
  // Must happen before debounce admission: latestDeps intentionally retains only
580
1352
  // the latest event, but write -> edit is a sticky turn transition.
581
1353
  const receipt = runtime
582
1354
  .recordMutationToolReceipt;
583
1355
  const autofixMode = deps._bypassDebounce
584
1356
  ? (deps._autofixMode ??
585
- (event.toolName === "edit" ? "deferred" : "immediate"))
1357
+ (mutation.kind === "edit" ? "deferred" : "immediate"))
586
1358
  : receipt
587
- ? receipt.call(runtime, filePath, event.toolName).autofixMode
588
- : event.toolName === "edit"
1359
+ ? receipt.call(runtime, filePath, mutation.kind).autofixMode
1360
+ : mutation.kind === "edit"
589
1361
  ? "deferred"
590
1362
  : "immediate";
591
1363
  // Coalesce sequential edits to the same file into one pipeline run against
@@ -607,47 +1379,29 @@ export async function handleToolResult(deps) {
607
1379
  deps.readGuard?.recordWritten(filePath);
608
1380
  // Keep cachedExports in sync after each write/edit so the pre-write STOP
609
1381
  // check doesn't fire on names that were removed from this file this session.
610
- if (runtime.cachedExports.size > 0 && nodeFs.existsSync(filePath)) {
611
- const exportRe = /export\s+(?:async\s+)?(?:function|class|const|let|type|interface)\s+(\w+)/g;
612
- for (const [name, file] of runtime.cachedExports) {
613
- if (path.resolve(file) === path.resolve(filePath)) {
614
- runtime.cachedExports.delete(name);
615
- }
616
- }
617
- try {
618
- const freshContent = nodeFs.readFileSync(filePath, "utf-8");
619
- for (const match of freshContent.matchAll(exportRe)) {
620
- const name = match[1];
621
- if (!runtime.cachedExports.has(name)) {
622
- runtime.cachedExports.set(name, filePath);
623
- }
624
- }
625
- }
626
- catch {
627
- // Non-fatal — stale entry is worse than a missing one
628
- }
629
- }
630
- const initialStateHash = getFileStateHash(filePath);
631
- // Deduplicate concurrent calls for the same final file state (pi can fire one
632
- // tool_result per edit hunk). Do not dedupe by file alone: a distinct later
633
- // same-turn edit to this file must still be analyzed.
634
- const inFlight = inFlightPipelines.get(filePath)?.get(initialStateHash);
635
- if (inFlight) {
636
- dbg(`tool_result: skipping duplicate concurrent state for ${filePath}`);
637
- const duplicateId = readGuardCorrelationId;
638
- if (inFlight.participantIds.length < 100) {
639
- inFlight.participantIds.push(duplicateId);
1382
+ refreshCachedExports(runtime, filePath);
1383
+ // Reuse the hash taken right after the write landed (finding 3): the file is
1384
+ // unchanged between that read and here, so this is byte-identical.
1385
+ const initialStateHash = postWriteStateHash;
1386
+ // #2464 review round 3 (F1): the in-flight dedup and the already-analysed
1387
+ // latch moved into `claimPipelineDispatch`, shared verbatim with the observed
1388
+ // call site, which shipped round 2 with NEITHER check. The position is
1389
+ // unchanged — still ABOVE `addModifiedRange`/`recordProjectChange`, so a
1390
+ // duplicate state still writes no turn-state range and no change-log
1391
+ // receipt. Nothing may await between this claim and the dispatch below: the
1392
+ // claim is only atomic because `dispatchPipelineAnalysis` registers before
1393
+ // its own first await.
1394
+ const classifiedClaim = claimPipelineDispatch({
1395
+ filePath,
1396
+ stateHash: initialStateHash,
1397
+ turnIndex: runtime.turnIndex,
1398
+ participantId: readGuardCorrelationId,
1399
+ dbg,
1400
+ });
1401
+ if (!classifiedClaim.proceed) {
1402
+ if (classifiedClaim.joined) {
1403
+ await classifiedClaim.joined;
640
1404
  }
641
- inFlight.participantTotal += 1;
642
- await inFlight.promise;
643
- return;
644
- }
645
- // Deduplicate sequential duplicate events for the same post-write state in the
646
- // same turn while allowing later same-file edits whose content changed.
647
- const lastAnalyzed = lastAnalyzedStateByFile.get(filePath);
648
- if (lastAnalyzed?.turnIndex === runtime.turnIndex &&
649
- lastAnalyzed.stateHash === initialStateHash) {
650
- dbg(`tool_result: skipping already-analyzed file state this turn for ${filePath}`);
651
1405
  return;
652
1406
  }
653
1407
  const sessionFileTime = createFileTime("default");
@@ -658,7 +1412,9 @@ export async function handleToolResult(deps) {
658
1412
  const readGuard = runtime.readGuard;
659
1413
  readGuard?.recordWritten?.(filePath);
660
1414
  }
661
- const toolResultStart = Date.now();
1415
+ // `toolResultStart` is hoisted above both provenance exits (#2464 review
1416
+ // round 2, S5) so the two paths' durations are comparable — see its
1417
+ // declaration right after the observational settle.
662
1418
  dbg(`tool_result: tracking turn state for ${event.toolName} on ${filePath}`);
663
1419
  if (isPathIgnoredByProject(filePath, workspaceRoot, false)) {
664
1420
  dbg(`tool_result: skipping gitignored file ${filePath}`);
@@ -678,6 +1434,14 @@ export async function handleToolResult(deps) {
678
1434
  // see the `telemetry:` block handed to `runPipeline` below.
679
1435
  const writeIndex = runtime.nextWriteIndex();
680
1436
  let modifiedRanges;
1437
+ // #2423: ranges a shape adapter resolved from the tool's own input. Only a
1438
+ // classified non-native edit shape sets these — a plain host `edit` carries
1439
+ // its ranges in `details.diff` and is handled by the branch below.
1440
+ const adapterRanges = mutation.editRanges && mutation.editRanges.length > 0
1441
+ ? mutation.editRanges.map(([start, end]) => ({ start, end }))
1442
+ : mutation.touchedLines
1443
+ ? [{ start: mutation.touchedLines[0], end: mutation.touchedLines[1] }]
1444
+ : undefined;
681
1445
  try {
682
1446
  // #1334 S6: the host DECLARES this payload (`EditToolDetails`, a
683
1447
  // type-only export), so use it instead of re-declaring `{ diff?: string }`
@@ -688,7 +1452,7 @@ export async function handleToolResult(deps) {
688
1452
  // has always relied on.
689
1453
  const details = event.details;
690
1454
  dbg(`tool_result: details.diff=${details?.diff ? "present" : "missing"}, details keys: ${Object.keys(event.details || {}).join(", ")}`);
691
- if (event.toolName === "edit" && details?.diff) {
1455
+ if (mutation.kind === "edit" && details?.diff) {
692
1456
  const diff = details.diff;
693
1457
  dbg(`tool_result: diff content (first 500 chars): ${diff.substring(0, 500)}`);
694
1458
  const ranges = parseDiffRanges(diff);
@@ -701,7 +1465,37 @@ export async function handleToolResult(deps) {
701
1465
  }
702
1466
  dbg(`tool_result: turn state after add: ${JSON.stringify(cacheManager.readTurnState(turnStateCwd))}`);
703
1467
  }
704
- else if (event.toolName === "write" && nodeFs.existsSync(filePath)) {
1468
+ else if (mutation.kind === "edit" &&
1469
+ adapterRanges &&
1470
+ nodeFs.existsSync(filePath)) {
1471
+ // #2423: a third-party edit tool ships no host `details.diff`, so the
1472
+ // diff branch above never fires and the file used to leave `files: {}`
1473
+ // empty in turn-state.json. The shape adapter already resolved which
1474
+ // lines the call touched — use those.
1475
+ const content = nodeFs.readFileSync(filePath, "utf-8");
1476
+ const importsChanged = /^import\s/m.test(content);
1477
+ modifiedRanges = adapterRanges;
1478
+ for (const range of adapterRanges) {
1479
+ cacheManager.addModifiedRange(filePath, range, importsChanged, turnStateCwd, runtime.telemetrySessionId);
1480
+ }
1481
+ }
1482
+ else if (mutation.kind === "edit" &&
1483
+ mutation.provenance === "declared" &&
1484
+ nodeFs.existsSync(filePath)) {
1485
+ // #2423 review round 1 (F1): a third-party edit whose anchors did not
1486
+ // resolve — a stale anchor, or the read-guard preflight never ran to
1487
+ // resolve them — still CHANGED the file. Recording the whole file is a
1488
+ // deliberate superset: turn state exists to say "this file changed,
1489
+ // format and attribute it", and an empty `files` map is the exact
1490
+ // symptom the issue reports. A host `edit` keeps its old behavior,
1491
+ // because its ranges come from `details.diff` above.
1492
+ const content = nodeFs.readFileSync(filePath, "utf-8");
1493
+ const lineCount = content.split("\n").length;
1494
+ const importsChanged = /^import\s/m.test(content);
1495
+ modifiedRanges = [{ start: 1, end: lineCount }];
1496
+ cacheManager.addModifiedRange(filePath, { start: 1, end: lineCount }, importsChanged, turnStateCwd, runtime.telemetrySessionId);
1497
+ }
1498
+ else if (mutation.kind === "write" && nodeFs.existsSync(filePath)) {
705
1499
  const content = nodeFs.readFileSync(filePath, "utf-8");
706
1500
  const lineCount = content.split("\n").length;
707
1501
  const hasImports = /^import\s/m.test(content);
@@ -718,7 +1512,7 @@ export async function handleToolResult(deps) {
718
1512
  cwd: turnStateCwd,
719
1513
  filePath,
720
1514
  source: deps._mutationSourceOverride ??
721
- sourceForToolName(event.toolName, event.details),
1515
+ sourceForMutation(mutation, event.details),
722
1516
  changedRange: singleRange(modifiedRanges),
723
1517
  dbg,
724
1518
  });
@@ -731,187 +1525,39 @@ export async function handleToolResult(deps) {
731
1525
  durationMs: turnStateMs,
732
1526
  });
733
1527
  dbg(`tool_result fired for: ${filePath} (turn_state: ${turnStateMs}ms)`);
734
- let result;
735
- const pipelinePromise = runPipeline({
1528
+ // #2464: the pipeline dispatch itself now lives in `dispatchPipelineAnalysis`
1529
+ // (defined above `handleToolResult`), shared with the observed-mutation
1530
+ // early return. This call site is otherwise unchanged — same arguments, same
1531
+ // crash-then-return / success-then-continue shape as before the split.
1532
+ const dispatchOutcome = await dispatchPipelineAnalysis({
1533
+ deps,
1534
+ runtime,
736
1535
  filePath,
737
- cwd: dispatchCwd,
738
- projectRoot: turnStateCwd,
739
- toolName: event.toolName,
1536
+ dispatchCwd,
1537
+ turnStateCwd,
740
1538
  autofixMode,
741
1539
  modifiedRanges,
742
- telemetry: {
743
- model: runtime.telemetryModel,
744
- sessionId: runtime.telemetrySessionId,
745
- turnIndex: runtime.turnIndex,
746
- writeIndex,
747
- modelId: runtime.telemetryModelId,
748
- provider: runtime.telemetryProviderId,
749
- },
750
- getFlag,
751
- getFlagSource,
752
- dbg,
753
- // #451: hand the deferred cascade live sequence accessors so the
754
- // review-graph builder can skip its per-build O(project) sweep when
755
- // only pi-observed edits happened. projectSeq is a function because the
756
- // cascade runs after this returns (#450) — read current, not captured.
757
- seqState: {
758
- projectSeq: () => runtime.projectSeq,
759
- getFilesChangedSince: (seq) => runtime.getFilesChangedSince(seq),
760
- },
761
- // The settle clock is live because the deferred cascade may reach its
762
- // budget derivation before or after turn_end starts waiting.
763
- turnEndCascadeSettleStart: () => runtime.getTurnEndCascadeSettleStart(),
764
- // #348 phase 2: live reference so the deferred cascade can update the
765
- // warm word index in place at the same seam as the graph rebuild.
766
- // `runtime.wordIndex` is read fresh (not captured) via this closure-free
767
- // property access being re-evaluated at object-literal construction
768
- // time here — that's fine because runPipeline reads `ctx.wordIndex`
769
- // synchronously into computeCascadeForFile's options before returning
770
- // (the deferred part is the cascade's OWN execution, not this handoff).
771
- wordIndex: runtime.wordIndex,
772
- onWordIndexUpdated: (index) => {
773
- scheduleWordIndexPersist(dispatchCwd, index, dbg);
774
- },
775
- }, {
776
- biomeClient,
777
- ruffClient,
778
- metricsClient,
779
- getFormatService,
780
- fixedThisTurn: runtime.fixedThisTurn,
781
- });
782
- const pipelineTelemetry = {
783
- promise: pipelinePromise,
784
- participantIds: [...new Set(participantIds)].slice(0, 100),
1540
+ writeIndex,
1541
+ initialStateHash,
1542
+ readGuardCorrelationId,
1543
+ requestedEditIndexes,
1544
+ requestedEditTotal,
1545
+ isPartialApplyResult,
1546
+ participantIds,
785
1547
  participantTotal,
786
- };
787
- let filePipelines = inFlightPipelines.get(filePath);
788
- if (!filePipelines) {
789
- filePipelines = new Map();
790
- inFlightPipelines.set(filePath, filePipelines);
791
- }
792
- filePipelines.set(initialStateHash, pipelineTelemetry);
793
- try {
794
- result = await pipelinePromise;
795
- }
796
- catch (pipelineErr) {
797
- if (getFlag("lens-guard")) {
798
- runtime.markGitGuardCacheUnknown("pipeline_crash");
799
- }
800
- dbg(`runPipeline crashed: ${pipelineErr}`);
801
- logReadGuardEvent({
802
- event: "edit_post_edit_pipeline_failed",
803
- correlationId: readGuardCorrelationId,
804
- filePath,
805
- metadata: {
806
- tool: event.toolName,
807
- commitStatus: "committed",
808
- reasonCode: "pipeline_failed",
809
- },
810
- });
811
- logReadGuardEvent({
812
- event: "edit_batch_summary",
813
- correlationId: readGuardCorrelationId,
814
- filePath,
815
- metadata: {
816
- tool: event.toolName,
817
- editBatchSummary: createReadGuardEditBatchSummary({
818
- requestedIndexes: requestedEditIndexes,
819
- requestedTotal: requestedEditTotal,
820
- resolvedIndexes: requestedEditIndexes,
821
- resolvedTotal: requestedEditTotal,
822
- appliedIndexes: requestedEditIndexes,
823
- appliedTotal: requestedEditTotal,
824
- participantIds: pipelineTelemetry.participantIds,
825
- participantTotal: pipelineTelemetry.participantTotal,
826
- commitStatus: "committed",
827
- postEditStatus: "failed",
828
- terminalStatus: "failed",
829
- durationMs: Date.now() - toolResultStart,
830
- }),
831
- },
832
- });
833
- dbg(`runPipeline crash stack: ${pipelineErr.stack}`);
834
- if (!getFlag("no-lsp")) {
835
- resetLSPService({ fast: true, reason: "pipeline_crash" });
836
- }
837
- logLatency({
838
- type: "tool_result",
839
- toolName: event.toolName,
840
- filePath,
841
- durationMs: Date.now() - toolResultStart,
842
- result: "pipeline_crash",
843
- });
844
- const notice = runtime.formatPipelineCrashNotice(filePath, pipelineErr);
845
- return {
846
- content: notice
847
- ? [...event.content, { type: "text", text: notice }]
848
- : event.content,
849
- isError: true,
850
- };
851
- }
852
- finally {
853
- // Prune the per-file inner map once it's empty so a file touched once
854
- // this session doesn't leave a permanent empty entry in the outer map.
855
- filePipelines.delete(initialStateHash);
856
- if (filePipelines.size === 0) {
857
- inFlightPipelines.delete(filePath);
858
- }
859
- }
860
- if (!isPartialApplyResult) {
861
- const postEditStatus = result.isError ? "failed" : "succeeded";
862
- if (result.isError) {
863
- logReadGuardEvent({
864
- event: "edit_post_edit_pipeline_failed",
865
- correlationId: readGuardCorrelationId,
866
- filePath,
867
- metadata: {
868
- tool: event.toolName,
869
- commitStatus: "committed",
870
- reasonCode: "pipeline_failed",
871
- },
872
- });
873
- }
874
- logReadGuardEvent({
875
- event: "edit_batch_summary",
876
- correlationId: readGuardCorrelationId,
877
- filePath,
878
- metadata: {
879
- tool: event.toolName,
880
- editBatchSummary: createReadGuardEditBatchSummary({
881
- requestedIndexes: requestedEditIndexes,
882
- requestedTotal: requestedEditTotal,
883
- resolvedIndexes: requestedEditIndexes,
884
- resolvedTotal: requestedEditTotal,
885
- appliedIndexes: requestedEditIndexes,
886
- appliedTotal: requestedEditTotal,
887
- participantIds: pipelineTelemetry.participantIds,
888
- participantTotal: pipelineTelemetry.participantTotal,
889
- commitStatus: "committed",
890
- postEditStatus,
891
- terminalStatus: postEditStatus === "failed" ? "failed" : "success",
892
- durationMs: Date.now() - toolResultStart,
893
- }),
894
- },
895
- });
896
- }
897
- lastAnalyzedStateByFile.set(filePath, {
898
- turnIndex: runtime.turnIndex,
899
- stateHash: getFileStateHash(filePath),
1548
+ toolResultStart,
1549
+ nativeAppliedPairs,
900
1550
  });
901
- // The model's write/edit and pi-lens' own immediate format/autofix are now
902
- // reflected on disk. Refresh read-guard staleness stamps so a follow-up edit
903
- // is judged by read-range coverage, not by our own previous write.
904
- if (!getFlag("no-read-guard")) {
905
- const changedForReadGuard = new Set([
906
- path.resolve(filePath),
907
- ...(result.changedFiles ?? []).map((changedFile) => path.resolve(changedFile)),
908
- ]);
909
- for (const changedFile of changedForReadGuard) {
910
- if (nodeFs.existsSync(changedFile)) {
911
- deps.readGuard?.recordWritten(changedFile);
912
- }
913
- }
1551
+ if (dispatchOutcome.crashed) {
1552
+ return dispatchOutcome.response;
914
1553
  }
1554
+ // #2464 review round 2 (S1): the three post-pipeline post-conditions that
1555
+ // used to be written out here — the already-analysed latch, the #2402
1556
+ // after-write hash stamp, and the read-guard staleness re-stamp over
1557
+ // `result.changedFiles` — now live INSIDE `dispatchPipelineAnalysis`, so the
1558
+ // observed path gets them by construction rather than by a second author
1559
+ // remembering to copy them.
1560
+ const result = dispatchOutcome.result;
915
1561
  let autofixNewlyQueued = false;
916
1562
  if (!result.isError &&
917
1563
  autofixMode === "deferred" &&
@@ -942,7 +1588,12 @@ export async function handleToolResult(deps) {
942
1588
  publishFormatQueued({
943
1589
  filePath,
944
1590
  cwd: dispatchCwd,
945
- tool: event.toolName,
1591
+ // The v1 `pilens:format:queued` payload declares
1592
+ // `tool: "write" | "edit"`, so a third-party tool publishes under
1593
+ // its KIND rather than its name. Widening the published field is a
1594
+ // public-API decision tracked in #2421; until then the kind is the
1595
+ // honest projection.
1596
+ tool: mutation.kind,
946
1597
  dbg,
947
1598
  kinds: autofixMode === "deferred" ? ["autofix", "format"] : ["format"],
948
1599
  });
@@ -952,13 +1603,15 @@ export async function handleToolResult(deps) {
952
1603
  publishFormatQueued({
953
1604
  filePath,
954
1605
  cwd: dispatchCwd,
955
- tool: event.toolName,
1606
+ // Same #2421 projection as the queued-with-format publish above.
1607
+ tool: mutation.kind,
956
1608
  kinds: ["autofix"],
957
1609
  dbg,
958
1610
  });
959
1611
  }
960
1612
  for (const changedFile of result.changedFiles ?? []) {
961
1613
  const resolvedChanged = path.resolve(changedFile);
1614
+ invalidateFormatterCacheForPath(resolvedChanged);
962
1615
  if (!nodeFs.existsSync(resolvedChanged))
963
1616
  continue;
964
1617
  recordProjectChange({