@herbertgao/pi-extensions 2026.9.0 → 2026.9.2

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 (360) hide show
  1. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +13 -1
  2. package/node_modules/@czottmann/pi-automode/README.md +20 -0
  3. package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +1 -1
  4. package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +2 -1
  5. package/node_modules/@czottmann/pi-automode/docs/defaults.md +3 -2
  6. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +38 -2
  7. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +5 -3
  8. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +11 -3
  9. package/node_modules/@czottmann/pi-automode/package.json +1 -1
  10. package/node_modules/@narumitw/pi-btw/README.md +57 -141
  11. package/node_modules/@narumitw/pi-btw/dist/index.ts +466 -41
  12. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  13. package/node_modules/@narumitw/pi-btw/docs/workflows.md +74 -0
  14. package/node_modules/@narumitw/pi-btw/package.json +2 -1
  15. package/node_modules/@narumitw/pi-btw/src/btw.ts +15 -1
  16. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +32 -2
  17. package/node_modules/@narumitw/pi-btw/src/keybindings.ts +330 -0
  18. package/node_modules/@narumitw/pi-btw/src/menu.ts +136 -19
  19. package/node_modules/@narumitw/pi-btw/src/settings.ts +32 -0
  20. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +57 -5
  21. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +52 -30
  22. package/node_modules/@pi-plugins/fast-mode/README.md +5 -3
  23. package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts.map +1 -1
  24. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +20 -55
  25. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  26. package/node_modules/@pi-plugins/fast-mode/package.json +1 -1
  27. package/node_modules/pi-lens/CHANGELOG.md +1072 -0
  28. package/node_modules/pi-lens/README.md +3 -0
  29. package/node_modules/pi-lens/config/biome/core.jsonc +11 -2
  30. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +13 -1
  31. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +2 -9
  32. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +927 -80
  33. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +1 -1
  34. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +13 -4
  35. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +123 -3
  36. package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +60 -5
  37. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +2 -9
  38. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +2 -0
  39. package/node_modules/pi-lens/dist/clients/bash-file-access.js +133 -3
  40. package/node_modules/pi-lens/dist/clients/biome-client.js +9 -2
  41. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +14 -6
  42. package/node_modules/pi-lens/dist/clients/bootstrap.js +509 -73
  43. package/node_modules/pi-lens/dist/clients/bounded-cache.js +152 -12
  44. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +1 -1
  45. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +59 -9
  46. package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +113 -0
  47. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +3 -11
  48. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +31 -4
  49. package/node_modules/pi-lens/dist/clients/cache-manager.js +105 -6
  50. package/node_modules/pi-lens/dist/clients/cache-observability.js +40 -19
  51. package/node_modules/pi-lens/dist/clients/cargo-manifest.js +422 -0
  52. package/node_modules/pi-lens/dist/clients/cascade-logger.js +2 -9
  53. package/node_modules/pi-lens/dist/clients/child-unref.js +1 -19
  54. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +13 -3
  55. package/node_modules/pi-lens/dist/clients/complexity-client.js +16 -5
  56. package/node_modules/pi-lens/dist/clients/config-core/deny.js +221 -0
  57. package/node_modules/pi-lens/dist/clients/config-core/index.js +50 -0
  58. package/node_modules/pi-lens/dist/clients/config-core/merge.js +357 -0
  59. package/node_modules/pi-lens/dist/clients/config-core/normalize.js +340 -0
  60. package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +248 -0
  61. package/node_modules/pi-lens/dist/clients/config-core/provenance.js +164 -0
  62. package/node_modules/pi-lens/dist/clients/config-core/records.js +218 -0
  63. package/node_modules/pi-lens/dist/clients/config-core/resolve.js +125 -0
  64. package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +78 -0
  65. package/node_modules/pi-lens/dist/clients/config-core/schema.js +165 -0
  66. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +281 -0
  67. package/node_modules/pi-lens/dist/clients/config-locations.js +160 -0
  68. package/node_modules/pi-lens/dist/clients/config-resolve.js +789 -0
  69. package/node_modules/pi-lens/dist/clients/config-schema.js +234 -0
  70. package/node_modules/pi-lens/dist/clients/config-warn.js +407 -0
  71. package/node_modules/pi-lens/dist/clients/dead-code-client.js +19 -14
  72. package/node_modules/pi-lens/dist/clients/dead-code-logger.js +2 -6
  73. package/node_modules/pi-lens/dist/clients/deadline-utils.js +178 -0
  74. package/node_modules/pi-lens/dist/clients/debug-handles.js +2 -2
  75. package/node_modules/pi-lens/dist/clients/debug-heap.js +3 -3
  76. package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +106 -0
  77. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +113 -7
  78. package/node_modules/pi-lens/dist/clients/dependency-checker.js +8 -19
  79. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +1 -1
  80. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +12 -9
  81. package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +15 -5
  82. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +0 -4
  83. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +162 -44
  84. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +97 -0
  85. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +27 -8
  86. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +0 -6
  87. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +1 -1
  88. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +0 -3
  89. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-complexity.js +2 -2
  90. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-fan-out.js +1 -1
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -1
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +383 -27
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -1
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +4 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -1
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -1
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -1
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -1
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -1
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -1
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -1
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +1 -3
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -1
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -1
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +1 -2
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -1
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -1
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -1
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -1
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -1
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -1
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +14 -18
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -1
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -1
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +6 -3
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +7 -4
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -1
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +1 -2
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +1 -3
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +1 -2
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -1
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +4 -3
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -1
  129. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  130. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -1
  131. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -1
  132. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -1
  133. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -1
  134. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -1
  135. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  136. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -1
  137. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  138. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -67
  139. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +194 -80
  140. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +84 -15
  141. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils.js +0 -21
  142. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -1
  143. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +2 -20
  144. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +1 -1
  145. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -1
  146. package/node_modules/pi-lens/dist/clients/dispatch/utils/format-utils.js +2 -2
  147. package/node_modules/pi-lens/dist/clients/disposition-logger.js +3 -10
  148. package/node_modules/pi-lens/dist/clients/effective-config.js +403 -0
  149. package/node_modules/pi-lens/dist/clients/error-class.js +23 -0
  150. package/node_modules/pi-lens/dist/clients/event-loop-hold.js +274 -0
  151. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +2 -2
  152. package/node_modules/pi-lens/dist/clients/extension-log.js +2 -2
  153. package/node_modules/pi-lens/dist/clients/feature-hints.js +2 -1
  154. package/node_modules/pi-lens/dist/clients/file-kinds.js +1 -38
  155. package/node_modules/pi-lens/dist/clients/file-time.js +7 -2
  156. package/node_modules/pi-lens/dist/clients/file-utils.js +117 -10
  157. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +50 -13
  158. package/node_modules/pi-lens/dist/clients/format-service.js +6 -2
  159. package/node_modules/pi-lens/dist/clients/formatters.js +385 -104
  160. package/node_modules/pi-lens/dist/clients/freshness-cadence.js +17 -0
  161. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +116 -22
  162. package/node_modules/pi-lens/dist/clients/generation-guard.js +4 -9
  163. package/node_modules/pi-lens/dist/clients/git-guard.js +6 -1
  164. package/node_modules/pi-lens/dist/clients/go-client.js +39 -0
  165. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +252 -0
  166. package/node_modules/pi-lens/dist/clients/grammar-source.js +3 -3
  167. package/node_modules/pi-lens/dist/clients/hashline-anchor.js +424 -0
  168. package/node_modules/pi-lens/dist/clients/hook-budgets.js +76 -0
  169. package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +5 -2
  170. package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
  171. package/node_modules/pi-lens/dist/clients/installer/index.js +289 -59
  172. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +84 -18
  173. package/node_modules/pi-lens/dist/clients/instance-reaper.js +110 -236
  174. package/node_modules/pi-lens/dist/clients/instance-registry.js +6 -3
  175. package/node_modules/pi-lens/dist/clients/language-profile.js +0 -9
  176. package/node_modules/pi-lens/dist/clients/language-registry.js +599 -0
  177. package/node_modules/pi-lens/dist/clients/latency-logger.js +165 -9
  178. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +34 -1
  179. package/node_modules/pi-lens/dist/clients/lens-config.js +160 -30
  180. package/node_modules/pi-lens/dist/clients/lens-engine.js +33 -41
  181. package/node_modules/pi-lens/dist/clients/lens-events.js +1 -1
  182. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +77 -6
  183. package/node_modules/pi-lens/dist/clients/lens-map.js +1 -1
  184. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -38
  185. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +3 -1
  186. package/node_modules/pi-lens/dist/clients/lsp/client.js +172 -33
  187. package/node_modules/pi-lens/dist/clients/lsp/config.js +484 -158
  188. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +1 -1
  189. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +1 -1
  190. package/node_modules/pi-lens/dist/clients/lsp/edits.js +18 -5
  191. package/node_modules/pi-lens/dist/clients/lsp/index.js +892 -96
  192. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +1 -1
  193. package/node_modules/pi-lens/dist/clients/lsp/language.js +18 -177
  194. package/node_modules/pi-lens/dist/clients/lsp/launch.js +2 -1
  195. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  196. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +5 -18
  197. package/node_modules/pi-lens/dist/clients/lsp/server.js +284 -147
  198. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +116 -23
  199. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +2 -7
  200. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +0 -1
  201. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +14 -7
  202. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +6 -0
  203. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +2 -1
  204. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +1 -1
  205. package/node_modules/pi-lens/dist/clients/lsp-document-symbols.js +1 -1
  206. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +163 -21
  207. package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +36 -0
  208. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +33 -4
  209. package/node_modules/pi-lens/dist/clients/mcp/session.js +5 -16
  210. package/node_modules/pi-lens/dist/clients/memory-sampler.js +8 -3
  211. package/node_modules/pi-lens/dist/clients/metrics-history.js +28 -107
  212. package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +3 -5
  213. package/node_modules/pi-lens/dist/clients/module-report.js +21 -46
  214. package/node_modules/pi-lens/dist/clients/mutating-tool.js +651 -0
  215. package/node_modules/pi-lens/dist/clients/mutation-attribution.js +368 -0
  216. package/node_modules/pi-lens/dist/clients/mutation-bridge.js +240 -0
  217. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +247 -24
  218. package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +101 -0
  219. package/node_modules/pi-lens/dist/clients/observed-mutation.js +1215 -0
  220. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +70 -28
  221. package/node_modules/pi-lens/dist/clients/opengrep-config.js +5 -1
  222. package/node_modules/pi-lens/dist/clients/package-manager.js +195 -26
  223. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +359 -80
  224. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +23 -7
  225. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +21 -2
  226. package/node_modules/pi-lens/dist/clients/path-utils.js +451 -15
  227. package/node_modules/pi-lens/dist/clients/performance-report.js +2 -2
  228. package/node_modules/pi-lens/dist/clients/persist-debounce.js +8 -1
  229. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +114 -0
  230. package/node_modules/pi-lens/dist/clients/pipeline.js +79 -18
  231. package/node_modules/pi-lens/dist/clients/probe-home-state.js +228 -0
  232. package/node_modules/pi-lens/dist/clients/process-bridge.js +66 -0
  233. package/node_modules/pi-lens/dist/clients/process-snapshot.js +68 -0
  234. package/node_modules/pi-lens/dist/clients/project-changes.js +1 -1
  235. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +1 -1
  236. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  237. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/jscpd.js +1 -1
  238. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/knip.js +1 -1
  239. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +1 -1
  240. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/opengrep.js +1 -1
  241. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +28 -4
  242. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +51 -20
  243. package/node_modules/pi-lens/dist/clients/project-lens-config.js +438 -122
  244. package/node_modules/pi-lens/dist/clients/project-scan-policy.js +3 -51
  245. package/node_modules/pi-lens/dist/clients/project-snapshot.js +52 -24
  246. package/node_modules/pi-lens/dist/clients/python-environment.js +217 -0
  247. package/node_modules/pi-lens/dist/clients/python-provenance.js +639 -0
  248. package/node_modules/pi-lens/dist/clients/quiet-window.js +6 -1
  249. package/node_modules/pi-lens/dist/clients/read-bridge.js +13 -21
  250. package/node_modules/pi-lens/dist/clients/read-expansion.js +9 -46
  251. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +16 -5
  252. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +364 -212
  253. package/node_modules/pi-lens/dist/clients/read-guard.js +26 -2
  254. package/node_modules/pi-lens/dist/clients/recent-touches.js +2 -2
  255. package/node_modules/pi-lens/dist/clients/resource-sampler.js +195 -96
  256. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +155 -114
  257. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +1 -1
  258. package/node_modules/pi-lens/dist/clients/review-graph/service.js +55 -3
  259. package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  260. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +10 -74
  261. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +9 -3
  262. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +19 -3
  263. package/node_modules/pi-lens/dist/clients/runtime-context.js +49 -16
  264. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +25 -6
  265. package/node_modules/pi-lens/dist/clients/runtime-session.js +184 -32
  266. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +179 -36
  267. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +797 -243
  268. package/node_modules/pi-lens/dist/clients/runtime-turn.js +1028 -53
  269. package/node_modules/pi-lens/dist/clients/rust-client.js +31 -0
  270. package/node_modules/pi-lens/dist/clients/safe-spawn.js +33 -10
  271. package/node_modules/pi-lens/dist/clients/sanitize.js +7 -12
  272. package/node_modules/pi-lens/dist/clients/scan-utils.js +1 -56
  273. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +1 -3
  274. package/node_modules/pi-lens/dist/clients/security-scan-client.js +3 -0
  275. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +1 -1
  276. package/node_modules/pi-lens/dist/clients/session-start-observability.js +79 -0
  277. package/node_modules/pi-lens/dist/clients/session-summary.js +0 -24
  278. package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +12 -3
  279. package/node_modules/pi-lens/dist/clients/sgconfig.js +7 -6
  280. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +1 -1
  281. package/node_modules/pi-lens/dist/clients/skills-resolver.js +105 -0
  282. package/node_modules/pi-lens/dist/clients/smells-rollup.js +4 -4
  283. package/node_modules/pi-lens/dist/clients/startup-scan.js +2 -10
  284. package/node_modules/pi-lens/dist/clients/string-utils.js +13 -0
  285. package/node_modules/pi-lens/dist/clients/subagent-mode.js +17 -4
  286. package/node_modules/pi-lens/dist/clients/test-runner-client.js +176 -39
  287. package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +244 -0
  288. package/node_modules/pi-lens/dist/clients/tool-definition.js +41 -1
  289. package/node_modules/pi-lens/dist/clients/tool-policy.js +92 -22
  290. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +40 -40
  291. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +87 -100
  292. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +2 -2
  293. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +81 -1
  294. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +51 -46
  295. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  296. package/node_modules/pi-lens/dist/clients/tui-fit.js +0 -4
  297. package/node_modules/pi-lens/dist/clients/typos-config.js +5 -0
  298. package/node_modules/pi-lens/dist/clients/user-notify.js +6 -6
  299. package/node_modules/pi-lens/dist/clients/widget-state.js +191 -26
  300. package/node_modules/pi-lens/dist/clients/word-index-logger.js +2 -9
  301. package/node_modules/pi-lens/dist/clients/word-index-store.js +23 -12
  302. package/node_modules/pi-lens/dist/clients/word-index.js +252 -47
  303. package/node_modules/pi-lens/dist/clients/workspace-topology.js +8 -1
  304. package/node_modules/pi-lens/dist/clients/zizmor-config.js +4 -1
  305. package/node_modules/pi-lens/dist/index.js +65899 -55340
  306. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +3 -1
  307. package/node_modules/pi-lens/dist/mcp/build-staleness.js +1 -1
  308. package/node_modules/pi-lens/dist/mcp/server.js +127 -5
  309. package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +583 -0
  310. package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +129 -0
  311. package/node_modules/pi-lens/dist/tools/effective-config.js +89 -0
  312. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +96 -25
  313. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +104 -86
  314. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +37 -16
  315. package/node_modules/pi-lens/dist/tools/lsp-structured-output.js +3 -3
  316. package/node_modules/pi-lens/dist/tools/render-compact.js +1 -1
  317. package/node_modules/pi-lens/dist/tools/shared.js +0 -1
  318. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -2
  319. package/node_modules/pi-lens/docs/agent-guide.md +64 -1
  320. package/node_modules/pi-lens/docs/configuration.md +222 -0
  321. package/node_modules/pi-lens/docs/dependencies.md +3 -3
  322. package/node_modules/pi-lens/docs/features.md +44 -5
  323. package/node_modules/pi-lens/docs/globalconfig.md +20 -0
  324. package/node_modules/pi-lens/docs/language-coverage.md +2 -2
  325. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +9 -1
  326. package/node_modules/pi-lens/docs/pi-lens-fixer.md +25 -0
  327. package/node_modules/pi-lens/docs/pi-lens-investigator.md +25 -0
  328. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +33 -0
  329. package/node_modules/pi-lens/docs/pi-lens-subagent.md +49 -0
  330. package/node_modules/pi-lens/docs/pi-lens-warden.md +55 -0
  331. package/node_modules/pi-lens/docs/public-api-stability.md +359 -0
  332. package/node_modules/pi-lens/docs/release-qa-baseline.md +190 -0
  333. package/node_modules/pi-lens/docs/servercapabilities.md +7 -3
  334. package/node_modules/pi-lens/docs/subagent-compat.md +110 -30
  335. package/node_modules/pi-lens/docs/tree-sitter_rules_catalog.md +2 -2
  336. package/node_modules/pi-lens/docs/word-index.md +1 -1
  337. package/node_modules/pi-lens/package.json +18 -35
  338. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  339. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  340. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  341. package/node_modules/pi-lens/rules/typos/_typos.toml +4 -2
  342. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +192 -1
  343. package/node_modules/pi-lens/scripts/install-selftest.mjs +58 -2
  344. package/node_modules/pi-lens/scripts/lib/skills-predicate.mjs +129 -0
  345. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +1 -1
  346. package/node_modules/pi-lens/scripts/rpc-load-check.mjs +3 -1
  347. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +4 -2
  348. package/node_modules/pi-web-access/CHANGELOG.md +28 -0
  349. package/node_modules/pi-web-access/README.md +62 -15
  350. package/node_modules/pi-web-access/curator-page.ts +3 -1
  351. package/node_modules/pi-web-access/curator-server.ts +5 -1
  352. package/node_modules/pi-web-access/gemini-search.ts +8 -4
  353. package/node_modules/pi-web-access/github-extract.ts +242 -1
  354. package/node_modules/pi-web-access/index.ts +118 -64
  355. package/node_modules/pi-web-access/mistral-search.ts +281 -0
  356. package/node_modules/pi-web-access/package.json +2 -2
  357. package/node_modules/pi-web-access/perplexity.ts +14 -1
  358. package/node_modules/pi-web-access/utils.ts +23 -6
  359. package/node_modules/pi-web-access/xai-search.ts +96 -33
  360. package/package.json +6 -6
@@ -9,11 +9,16 @@ 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
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";
@@ -131,6 +136,102 @@ const lastAnalyzedStateByFile = new PathKeyedMap(normalizeEphemeralMapKey);
131
136
  export function clearLastAnalyzedStateCache() {
132
137
  lastAnalyzedStateByFile.clear();
133
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
+ }
134
235
  const debouncedPipelines = new PathKeyedMap(normalizeEphemeralMapKey);
135
236
  const DEFAULT_DEBOUNCE_MS = 0;
136
237
  const MAX_DEBOUNCE_MS = 1000;
@@ -232,21 +333,32 @@ function getFileStateHash(filePath) {
232
333
  return `unreadable:${code}`;
233
334
  }
234
335
  }
235
- function getRequestedEditCount(event) {
236
- if (event.toolName === "write")
336
+ function getRequestedEditCount(event, kind) {
337
+ if (kind === "write")
237
338
  return 1;
238
339
  const edits = event.input?.edits;
239
340
  return Array.isArray(edits) && edits.length > 0 ? edits.length : 1;
240
341
  }
241
- function getRequestedEditIndexes(event) {
242
- return boundedIndexesForCount(getRequestedEditCount(event));
342
+ function getRequestedEditIndexes(event, kind) {
343
+ return boundedIndexesForCount(getRequestedEditCount(event, kind));
243
344
  }
244
- 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) {
245
353
  if (details
246
354
  ?.piLensPartialApply) {
247
355
  return "partial-apply";
248
356
  }
249
- 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}`;
250
362
  }
251
363
  function singleRange(ranges) {
252
364
  return ranges?.length === 1 ? ranges[0] : undefined;
@@ -263,8 +375,299 @@ function recordProjectChange(args) {
263
375
  onAppendError: (err) => args.dbg(`project change log append failed for ${args.filePath}: ${err}`),
264
376
  });
265
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
+ }
266
669
  export async function handleToolResult(deps) {
267
- const { event, getFlag, getFlagSource, dbg, runtime, cacheManager, biomeClient, ruffClient, metricsClient, resetLSPService, agentBehaviorRecord, formatBehaviorWarnings, } = deps;
670
+ const { event, getFlag, dbg, runtime, cacheManager, agentBehaviorRecord, formatBehaviorWarnings, } = deps;
268
671
  const rawFilePath = event.input.path;
269
672
  const workspaceRoot = runtime.projectRoot || process.cwd();
270
673
  // #1642: a gitignored worktree edit got re-attributed onto a
@@ -334,8 +737,10 @@ export async function handleToolResult(deps) {
334
737
  ? rawFilePath
335
738
  : path.resolve(resolutionBasis, rawFilePath)
336
739
  : rawFilePath;
337
- if (filePath)
740
+ if (filePath) {
338
741
  invalidateProjectIgnoreMatcherForPath(filePath);
742
+ invalidateFormatterCacheForPath(filePath);
743
+ }
339
744
  // Purely diagnostic: tool_call's call-time verdict (computed on ITS OWN
340
745
  // resolved path, which may since have been superseded) disagreed with
341
746
  // what actually executed. This does NOT gate anything by itself — the
@@ -519,6 +924,10 @@ export async function handleToolResult(deps) {
519
924
  toolName: "write",
520
925
  input: { path: wp },
521
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",
522
931
  };
523
932
  const syntheticResult = await handleToolResult({
524
933
  ...deps,
@@ -617,8 +1026,254 @@ export async function handleToolResult(deps) {
617
1026
  });
618
1027
  }
619
1028
  }
620
- if (event.toolName !== "write" && event.toolName !== "edit") {
621
- 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`);
622
1277
  return syntheticWriteContent.length > 0
623
1278
  ? { content: [...event.content, ...syntheticWriteContent] }
624
1279
  : undefined;
@@ -631,11 +1286,15 @@ export async function handleToolResult(deps) {
631
1286
  dbg(`tool_result: skipped pipeline - file outside project root or in node_modules: ${filePath}`);
632
1287
  return;
633
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);
634
1293
  const readGuardCorrelationId = getReadGuardCorrelationId(event);
635
1294
  const resultDetails = (event.details ?? {});
636
1295
  const isPartialApplyResult = resultDetails.piLensPartialApply === true;
637
- const requestedEditIndexes = getRequestedEditIndexes(event);
638
- const requestedEditTotal = getRequestedEditCount(event);
1296
+ const requestedEditIndexes = getRequestedEditIndexes(event, mutation.kind);
1297
+ const requestedEditTotal = getRequestedEditCount(event, mutation.kind);
639
1298
  const participantIds = [
640
1299
  ...(deps._telemetryParticipantIds ?? []),
641
1300
  readGuardCorrelationId,
@@ -668,16 +1327,37 @@ export async function handleToolResult(deps) {
668
1327
  });
669
1328
  return { content: event.content, isError: true };
670
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
+ });
671
1351
  // Must happen before debounce admission: latestDeps intentionally retains only
672
1352
  // the latest event, but write -> edit is a sticky turn transition.
673
1353
  const receipt = runtime
674
1354
  .recordMutationToolReceipt;
675
1355
  const autofixMode = deps._bypassDebounce
676
1356
  ? (deps._autofixMode ??
677
- (event.toolName === "edit" ? "deferred" : "immediate"))
1357
+ (mutation.kind === "edit" ? "deferred" : "immediate"))
678
1358
  : receipt
679
- ? receipt.call(runtime, filePath, event.toolName).autofixMode
680
- : event.toolName === "edit"
1359
+ ? receipt.call(runtime, filePath, mutation.kind).autofixMode
1360
+ : mutation.kind === "edit"
681
1361
  ? "deferred"
682
1362
  : "immediate";
683
1363
  // Coalesce sequential edits to the same file into one pipeline run against
@@ -699,47 +1379,29 @@ export async function handleToolResult(deps) {
699
1379
  deps.readGuard?.recordWritten(filePath);
700
1380
  // Keep cachedExports in sync after each write/edit so the pre-write STOP
701
1381
  // check doesn't fire on names that were removed from this file this session.
702
- if (runtime.cachedExports.size > 0 && nodeFs.existsSync(filePath)) {
703
- const exportRe = /export\s+(?:async\s+)?(?:function|class|const|let|type|interface)\s+(\w+)/g;
704
- for (const [name, file] of runtime.cachedExports) {
705
- if (path.resolve(file) === path.resolve(filePath)) {
706
- runtime.cachedExports.delete(name);
707
- }
708
- }
709
- try {
710
- const freshContent = nodeFs.readFileSync(filePath, "utf-8");
711
- for (const match of freshContent.matchAll(exportRe)) {
712
- const name = match[1];
713
- if (!runtime.cachedExports.has(name)) {
714
- runtime.cachedExports.set(name, filePath);
715
- }
716
- }
717
- }
718
- catch {
719
- // Non-fatal — stale entry is worse than a missing one
720
- }
721
- }
722
- const initialStateHash = getFileStateHash(filePath);
723
- // Deduplicate concurrent calls for the same final file state (pi can fire one
724
- // tool_result per edit hunk). Do not dedupe by file alone: a distinct later
725
- // same-turn edit to this file must still be analyzed.
726
- const inFlight = inFlightPipelines.get(filePath)?.get(initialStateHash);
727
- if (inFlight) {
728
- dbg(`tool_result: skipping duplicate concurrent state for ${filePath}`);
729
- const duplicateId = readGuardCorrelationId;
730
- if (inFlight.participantIds.length < 100) {
731
- 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;
732
1404
  }
733
- inFlight.participantTotal += 1;
734
- await inFlight.promise;
735
- return;
736
- }
737
- // Deduplicate sequential duplicate events for the same post-write state in the
738
- // same turn while allowing later same-file edits whose content changed.
739
- const lastAnalyzed = lastAnalyzedStateByFile.get(filePath);
740
- if (lastAnalyzed?.turnIndex === runtime.turnIndex &&
741
- lastAnalyzed.stateHash === initialStateHash) {
742
- dbg(`tool_result: skipping already-analyzed file state this turn for ${filePath}`);
743
1405
  return;
744
1406
  }
745
1407
  const sessionFileTime = createFileTime("default");
@@ -750,7 +1412,9 @@ export async function handleToolResult(deps) {
750
1412
  const readGuard = runtime.readGuard;
751
1413
  readGuard?.recordWritten?.(filePath);
752
1414
  }
753
- 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.
754
1418
  dbg(`tool_result: tracking turn state for ${event.toolName} on ${filePath}`);
755
1419
  if (isPathIgnoredByProject(filePath, workspaceRoot, false)) {
756
1420
  dbg(`tool_result: skipping gitignored file ${filePath}`);
@@ -770,6 +1434,14 @@ export async function handleToolResult(deps) {
770
1434
  // see the `telemetry:` block handed to `runPipeline` below.
771
1435
  const writeIndex = runtime.nextWriteIndex();
772
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;
773
1445
  try {
774
1446
  // #1334 S6: the host DECLARES this payload (`EditToolDetails`, a
775
1447
  // type-only export), so use it instead of re-declaring `{ diff?: string }`
@@ -780,7 +1452,7 @@ export async function handleToolResult(deps) {
780
1452
  // has always relied on.
781
1453
  const details = event.details;
782
1454
  dbg(`tool_result: details.diff=${details?.diff ? "present" : "missing"}, details keys: ${Object.keys(event.details || {}).join(", ")}`);
783
- if (event.toolName === "edit" && details?.diff) {
1455
+ if (mutation.kind === "edit" && details?.diff) {
784
1456
  const diff = details.diff;
785
1457
  dbg(`tool_result: diff content (first 500 chars): ${diff.substring(0, 500)}`);
786
1458
  const ranges = parseDiffRanges(diff);
@@ -793,7 +1465,37 @@ export async function handleToolResult(deps) {
793
1465
  }
794
1466
  dbg(`tool_result: turn state after add: ${JSON.stringify(cacheManager.readTurnState(turnStateCwd))}`);
795
1467
  }
796
- 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)) {
797
1499
  const content = nodeFs.readFileSync(filePath, "utf-8");
798
1500
  const lineCount = content.split("\n").length;
799
1501
  const hasImports = /^import\s/m.test(content);
@@ -810,7 +1512,7 @@ export async function handleToolResult(deps) {
810
1512
  cwd: turnStateCwd,
811
1513
  filePath,
812
1514
  source: deps._mutationSourceOverride ??
813
- sourceForToolName(event.toolName, event.details),
1515
+ sourceForMutation(mutation, event.details),
814
1516
  changedRange: singleRange(modifiedRanges),
815
1517
  dbg,
816
1518
  });
@@ -823,194 +1525,39 @@ export async function handleToolResult(deps) {
823
1525
  durationMs: turnStateMs,
824
1526
  });
825
1527
  dbg(`tool_result fired for: ${filePath} (turn_state: ${turnStateMs}ms)`);
826
- let result;
827
- 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,
828
1535
  filePath,
829
- cwd: dispatchCwd,
830
- projectRoot: turnStateCwd,
831
- toolName: event.toolName,
1536
+ dispatchCwd,
1537
+ turnStateCwd,
832
1538
  autofixMode,
833
1539
  modifiedRanges,
834
- telemetry: {
835
- model: runtime.telemetryModel,
836
- sessionId: runtime.telemetrySessionId,
837
- turnIndex: runtime.turnIndex,
838
- writeIndex,
839
- modelId: runtime.telemetryModelId,
840
- provider: runtime.telemetryProviderId,
841
- },
842
- getFlag,
843
- getFlagSource,
844
- dbg,
845
- // #451: hand the deferred cascade live sequence accessors so the
846
- // review-graph builder can skip its per-build O(project) sweep when
847
- // only pi-observed edits happened. projectSeq is a function because the
848
- // cascade runs after this returns (#450) — read current, not captured.
849
- seqState: {
850
- projectSeq: () => runtime.projectSeq,
851
- getFilesChangedSince: (seq) => runtime.getFilesChangedSince(seq),
852
- },
853
- // The settle clock is live because the deferred cascade may reach its
854
- // budget derivation before or after turn_end starts waiting.
855
- turnEndCascadeSettleStart: () => runtime.getTurnEndCascadeSettleStart(),
856
- // #348 phase 2: live reference so the deferred cascade can update the
857
- // warm word index in place at the same seam as the graph rebuild.
858
- // `runtime.wordIndex` is read fresh (not captured) via this closure-free
859
- // property access being re-evaluated at object-literal construction
860
- // time here — that's fine because runPipeline reads `ctx.wordIndex`
861
- // synchronously into computeCascadeForFile's options before returning
862
- // (the deferred part is the cascade's OWN execution, not this handoff).
863
- wordIndex: runtime.wordIndex,
864
- onWordIndexUpdated: (index) => {
865
- scheduleWordIndexPersist(dispatchCwd, index, dbg);
866
- },
867
- }, {
868
- biomeClient,
869
- ruffClient,
870
- metricsClient,
871
- getFormatService,
872
- fixedThisTurn: runtime.fixedThisTurn,
873
- });
874
- const pipelineTelemetry = {
875
- promise: pipelinePromise,
876
- participantIds: [...new Set(participantIds)].slice(0, 100),
1540
+ writeIndex,
1541
+ initialStateHash,
1542
+ readGuardCorrelationId,
1543
+ requestedEditIndexes,
1544
+ requestedEditTotal,
1545
+ isPartialApplyResult,
1546
+ participantIds,
877
1547
  participantTotal,
878
- };
879
- let filePipelines = inFlightPipelines.get(filePath);
880
- if (!filePipelines) {
881
- filePipelines = new Map();
882
- inFlightPipelines.set(filePath, filePipelines);
883
- }
884
- filePipelines.set(initialStateHash, pipelineTelemetry);
885
- try {
886
- result = await pipelinePromise;
887
- }
888
- catch (pipelineErr) {
889
- if (getFlag("lens-guard")) {
890
- runtime.markGitGuardCacheUnknown("pipeline_crash");
891
- }
892
- dbg(`runPipeline crashed: ${pipelineErr}`);
893
- logReadGuardEvent({
894
- event: "edit_post_edit_pipeline_failed",
895
- correlationId: readGuardCorrelationId,
896
- filePath,
897
- metadata: {
898
- tool: event.toolName,
899
- commitStatus: "committed",
900
- reasonCode: "pipeline_failed",
901
- },
902
- });
903
- logReadGuardEvent({
904
- event: "edit_batch_summary",
905
- correlationId: readGuardCorrelationId,
906
- filePath,
907
- metadata: {
908
- tool: event.toolName,
909
- editBatchSummary: createReadGuardEditBatchSummary({
910
- requestedIndexes: requestedEditIndexes,
911
- requestedTotal: requestedEditTotal,
912
- resolvedIndexes: requestedEditIndexes,
913
- resolvedTotal: requestedEditTotal,
914
- appliedIndexes: requestedEditIndexes,
915
- appliedTotal: requestedEditTotal,
916
- participantIds: pipelineTelemetry.participantIds,
917
- participantTotal: pipelineTelemetry.participantTotal,
918
- commitStatus: "committed",
919
- postEditStatus: "failed",
920
- terminalStatus: "failed",
921
- durationMs: Date.now() - toolResultStart,
922
- }),
923
- },
924
- });
925
- dbg(`runPipeline crash stack: ${pipelineErr.stack}`);
926
- // The LSP fleet is process-wide, but a pipeline crash belongs to one
927
- // evaluation. A registered primary owns the fleet; a known secondary
928
- // must not tear it down. Keep the historical reset when no registration
929
- // exists because synthetic callers and early startup have no role evidence.
930
- const activePrimarySessionId = getActiveSessionId();
931
- const crashBelongsToPrimary = activePrimarySessionId === undefined ||
932
- activePrimarySessionId === runtime.telemetrySessionId;
933
- if (!getFlag("no-lsp") && crashBelongsToPrimary) {
934
- resetLSPService({ fast: true, reason: "pipeline_crash" });
935
- }
936
- logLatency({
937
- type: "tool_result",
938
- toolName: event.toolName,
939
- filePath,
940
- durationMs: Date.now() - toolResultStart,
941
- result: "pipeline_crash",
942
- });
943
- const notice = runtime.formatPipelineCrashNotice(filePath, pipelineErr);
944
- return {
945
- content: notice
946
- ? [...event.content, { type: "text", text: notice }]
947
- : event.content,
948
- isError: true,
949
- };
950
- }
951
- finally {
952
- // Prune the per-file inner map once it's empty so a file touched once
953
- // this session doesn't leave a permanent empty entry in the outer map.
954
- filePipelines.delete(initialStateHash);
955
- if (filePipelines.size === 0) {
956
- inFlightPipelines.delete(filePath);
957
- }
958
- }
959
- if (!isPartialApplyResult) {
960
- const postEditStatus = result.isError ? "failed" : "succeeded";
961
- if (result.isError) {
962
- logReadGuardEvent({
963
- event: "edit_post_edit_pipeline_failed",
964
- correlationId: readGuardCorrelationId,
965
- filePath,
966
- metadata: {
967
- tool: event.toolName,
968
- commitStatus: "committed",
969
- reasonCode: "pipeline_failed",
970
- },
971
- });
972
- }
973
- logReadGuardEvent({
974
- event: "edit_batch_summary",
975
- correlationId: readGuardCorrelationId,
976
- filePath,
977
- metadata: {
978
- tool: event.toolName,
979
- editBatchSummary: createReadGuardEditBatchSummary({
980
- requestedIndexes: requestedEditIndexes,
981
- requestedTotal: requestedEditTotal,
982
- resolvedIndexes: requestedEditIndexes,
983
- resolvedTotal: requestedEditTotal,
984
- appliedIndexes: requestedEditIndexes,
985
- appliedTotal: requestedEditTotal,
986
- participantIds: pipelineTelemetry.participantIds,
987
- participantTotal: pipelineTelemetry.participantTotal,
988
- commitStatus: "committed",
989
- postEditStatus,
990
- terminalStatus: postEditStatus === "failed" ? "failed" : "success",
991
- durationMs: Date.now() - toolResultStart,
992
- }),
993
- },
994
- });
995
- }
996
- lastAnalyzedStateByFile.set(filePath, {
997
- turnIndex: runtime.turnIndex,
998
- stateHash: getFileStateHash(filePath),
1548
+ toolResultStart,
1549
+ nativeAppliedPairs,
999
1550
  });
1000
- // The model's write/edit and pi-lens' own immediate format/autofix are now
1001
- // reflected on disk. Refresh read-guard staleness stamps so a follow-up edit
1002
- // is judged by read-range coverage, not by our own previous write.
1003
- if (!getFlag("no-read-guard")) {
1004
- const changedForReadGuard = new Set([
1005
- path.resolve(filePath),
1006
- ...(result.changedFiles ?? []).map((changedFile) => path.resolve(changedFile)),
1007
- ]);
1008
- for (const changedFile of changedForReadGuard) {
1009
- if (nodeFs.existsSync(changedFile)) {
1010
- deps.readGuard?.recordWritten(changedFile);
1011
- }
1012
- }
1551
+ if (dispatchOutcome.crashed) {
1552
+ return dispatchOutcome.response;
1013
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;
1014
1561
  let autofixNewlyQueued = false;
1015
1562
  if (!result.isError &&
1016
1563
  autofixMode === "deferred" &&
@@ -1041,7 +1588,12 @@ export async function handleToolResult(deps) {
1041
1588
  publishFormatQueued({
1042
1589
  filePath,
1043
1590
  cwd: dispatchCwd,
1044
- 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,
1045
1597
  dbg,
1046
1598
  kinds: autofixMode === "deferred" ? ["autofix", "format"] : ["format"],
1047
1599
  });
@@ -1051,13 +1603,15 @@ export async function handleToolResult(deps) {
1051
1603
  publishFormatQueued({
1052
1604
  filePath,
1053
1605
  cwd: dispatchCwd,
1054
- tool: event.toolName,
1606
+ // Same #2421 projection as the queued-with-format publish above.
1607
+ tool: mutation.kind,
1055
1608
  kinds: ["autofix"],
1056
1609
  dbg,
1057
1610
  });
1058
1611
  }
1059
1612
  for (const changedFile of result.changedFiles ?? []) {
1060
1613
  const resolvedChanged = path.resolve(changedFile);
1614
+ invalidateFormatterCacheForPath(resolvedChanged);
1061
1615
  if (!nodeFs.existsSync(resolvedChanged))
1062
1616
  continue;
1063
1617
  recordProjectChange({