@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
@@ -0,0 +1,1215 @@
1
+ /**
2
+ * The observational mutation net (#2430).
3
+ *
4
+ * ## What it is
5
+ *
6
+ * `clients/mutating-tool.ts` classifies a mutation by NAME or by input SHAPE.
7
+ * Both tiers are finite and the population of third-party edit tools is not, so
8
+ * a tool pi-lens has never met is dropped before the first bookkeeping call —
9
+ * no read-guard stamp, no `turn-state.json` entry, no deferred format.
10
+ *
11
+ * This module makes detection OBSERVATIONAL. It watches a bounded file set
12
+ * around a call the seam could not classify, and if something changed it
13
+ * replays the change through the mutation bridge as a real `kind: "edit"`. The
14
+ * tool is then ATTRIBUTED (`clients/mutation-attribution.ts`), so a later
15
+ * session on the same project classifies it from disk with no snapshot at all.
16
+ *
17
+ * Three layers, cheapest first:
18
+ *
19
+ * 1. **Nothing at all** for a tool the seam already classifies. `arm` is never
20
+ * reached: `runtime-tool-call.ts` only calls in when `classifyMutatingTool`
21
+ * returned `undefined` or the attribution is still provisional, and the
22
+ * first thing `arm` does is a map lookup.
23
+ * 2. **Arm + diff** for an unclassified call whose input carries a path-shaped
24
+ * field, bounded to THAT PATH ALONE. Paid at most a handful of times per
25
+ * tool name per session (see `CLEAN_OBSERVATION_ARM_LIMIT` and
26
+ * `PERSIST_AFTER_OBSERVATIONS`).
27
+ * 3. **The settled sweep** at `agent_settled`, before the deferred drain, for
28
+ * tools with no path field at all. It stat-checks the tracked-file set
29
+ * incrementally and NEVER walks the workspace.
30
+ *
31
+ * ## The observation universe is the TARGET PATH (#2449 review round 2)
32
+ *
33
+ * The first cut snapshotted the target's whole DIRECTORY plus the tracked-file
34
+ * set. That was wrong in both directions and expensive in a third:
35
+ *
36
+ * - it attributed a SIBLING's change to the tool under observation, so a
37
+ * background write during a `read`-shaped call taught pi-lens that `read`
38
+ * mutates (round-2 finding F4);
39
+ * - it made the arm cost scale with directory size and tracked-set size
40
+ * (~44ms warm), for a verdict about ONE path;
41
+ * - it duplicated the settled sweep's job. The tracked set is the SWEEP's
42
+ * domain; the armed observation's domain is the path the tool named.
43
+ *
44
+ * So the universe is now: the path-shaped field's file, or — when that path is
45
+ * a DIRECTORY — that directory's own entries, non-recursively, capped at
46
+ * {@link OBSERVED_TARGET_DIR_MAX_ENTRIES}. Nothing else. A tool that changes a
47
+ * file it never named is the settled sweep's business, not the armed
48
+ * observation's, and the sweep says so honestly rather than guessing.
49
+ *
50
+ * When that cap BITES the observation is truncated, and a truncated
51
+ * observation is `unverifiable` — never clean (#2449 review round 3, S3). The
52
+ * first cut broke out of the readdir loop silently, so a codemod that rewrote
53
+ * the 84th entry of an 84-entry directory produced an empty diff, the empty
54
+ * diff advanced the clean latch, and two of those stopped pi-lens watching a
55
+ * tool that mutates on every call.
56
+ *
57
+ * ## What layer 3 cannot see, stated rather than hidden
58
+ *
59
+ * The sweep compares against a ledger seeded from files pi-lens has ALREADY
60
+ * seen. A file that was never read, never written, never diagnosed and never
61
+ * opened by a language server has no baseline, so its first drift only seeds
62
+ * the ledger and is not reported. That is the documented limitation in #2430's
63
+ * third acceptance criterion; the alternative is a workspace walk, which the
64
+ * issue rules out.
65
+ *
66
+ * The sweep is also INCREMENTAL (round-2 finding F3). A full hash of 400
67
+ * tracked files cannot finish inside a 200ms capture budget, so the first cut
68
+ * timed out at every realistic size and never ran at all. It now stats a
69
+ * bounded window per turn from a carried cursor, reads a file only when its
70
+ * size or mtime actually moved, and reports its own coverage
71
+ * (`scanned`/`remaining`/`cursor`) so a partial pass is never read as a clean
72
+ * one.
73
+ *
74
+ * ## Bounds (AGENTS.md async rule, both directions)
75
+ *
76
+ * Every ASYNC step here carries a TIMEOUT and an `AbortSignal` race, and every
77
+ * capture is additionally bounded by a file cap and a hash-byte budget. The
78
+ * ARM shares a per-turn wall-clock budget; exhausting it emits a bounded
79
+ * `observed_mutation_budget_exhausted` record and a degradation-ledger tally —
80
+ * it is never a silent skip (catalog shape 10).
81
+ *
82
+ * The SETTLE is ASYNC and deliberately NOT budget-gated. Round 2 made it
83
+ * synchronous to keep `handleToolResult` from yielding before it dispatches
84
+ * the pipeline (#1086); round 3 (T4) removed the sync filesystem work from the
85
+ * tool_result path instead and made the ORDER explicit at the call site: the
86
+ * classified chain reads everything it derives from the post-result bytes
87
+ * BEFORE the settle's yield, so a racing tool_result for the same path cannot
88
+ * make this call register under the other call's state hash. Only the
89
+ * pending-baseline PROBE (`hasPendingObservation`) stays synchronous, which is
90
+ * what keeps the cost on the overwhelmingly common no-baseline path at one map
91
+ * lookup.
92
+ *
93
+ * A settle clamped to a spent budget silently dropped real mutations (round-2
94
+ * findings F1 and F5), so the settle has its own deadline rather than the
95
+ * arm's leftovers. The snapshot already exists and the post-capture always
96
+ * runs its first entry, so it completes for the target path whatever the clock
97
+ * says; a directory target's remaining entries are the only part a deadline
98
+ * can cut, and a cut capture says so rather than scoring itself clean.
99
+ */
100
+ import * as fs from "node:fs";
101
+ import * as path from "node:path";
102
+ import { createHash } from "node:crypto";
103
+ import { BoundedFifoMap, BoundedSet } from "./bounded-cache.js";
104
+ import { emitBounded } from "./bounded-telemetry.js";
105
+ import { freshnessFromMtime } from "./freshness.js";
106
+ import { logLatency } from "./latency-logger.js";
107
+ import { noteObservedClean, noteObservedMutation, noteObservedUnverifiable, shouldArmObservationForTool, } from "./mutation-attribution.js";
108
+ import { captureFileStatsForPaths, diffFileStats, } from "./opaque-mutation-scan.js";
109
+ import { normalizeMapKey } from "./path-utils.js";
110
+ import { getProcessSingleton } from "./process-singletons.js";
111
+ import { bounded } from "./deadline-utils.js";
112
+ import { lineContentHash } from "./read-guard.js";
113
+ /**
114
+ * Wall-clock CEILING for the ARM capture — not its cost.
115
+ *
116
+ * Since the universe collapsed to the target path this is one `stat`, one
117
+ * optional `readdir`, and a bounded set of stat+hash pairs. The ceiling is
118
+ * generous on purpose: a slow filesystem (#462 measured 1.3ms per stat on 9p)
119
+ * must not turn every first observation into a timeout, which is the least
120
+ * useful possible outcome.
121
+ */
122
+ const OBSERVED_CAPTURE_BUDGET_MS = 200;
123
+ /**
124
+ * Cumulative ceiling for every ARMED capture in one turn. A turn that calls
125
+ * twenty unclassified tools pays this once, not twenty times. The SETTLE does
126
+ * not consult it — see the module header.
127
+ */
128
+ export const OBSERVED_TURN_BUDGET_MS = 600;
129
+ /**
130
+ * Per-entry deadline for the settle's re-capture.
131
+ *
132
+ * Tighter than {@link OBSERVED_CAPTURE_BUDGET_MS} because the settle sits on
133
+ * the `tool_result` path, between a tool finishing and pi-lens dispatching its
134
+ * pipeline: every millisecond here is latency the agent waits through. Measured
135
+ * on this repo: a file target settles in ~1.4ms and a full 42-entry DIRECTORY
136
+ * target in ~20ms, so this clears the realistic worst case and only bites on a
137
+ * filesystem far slower than the one it was measured on.
138
+ *
139
+ * It can never cut the TARGET itself — `captureFileStatsForPaths` always runs
140
+ * its first entry — so F5's "the settle always completes for the target path"
141
+ * holds regardless of the clock. What it CAN cut is a directory target's tail,
142
+ * and that is reported as `stoppedEarly` rather than scored as clean.
143
+ */
144
+ const OBSERVED_SETTLE_DEADLINE_MS = 50;
145
+ /**
146
+ * Entries taken from a DIRECTORY-shaped target path, non-recursively.
147
+ *
148
+ * A tool that names a directory is saying "I operate in here"; its own entries
149
+ * are the honest universe. Sixty-four is a blast-radius bound, not a guess
150
+ * about directory sizes: past it the observation degrades to the entries it
151
+ * did see, and the settled sweep remains the net for the rest.
152
+ */
153
+ export const OBSERVED_TARGET_DIR_MAX_ENTRIES = 64;
154
+ /** Tracked files (read-guard + widget + open LSP docs) the sweep may hold. */
155
+ export const OBSERVED_TRACKED_MAX_FILES = 400;
156
+ /**
157
+ * How long a file must have been QUIET before its ledger entry may be trusted
158
+ * for the sweep's stat short-circuit.
159
+ *
160
+ * Its own constant, not `OPAQUE_MTIME_TOLERANCE_MS` (#2449 review round 3,
161
+ * S1). That one answers "how far before a recorded start may an earlier write
162
+ * still be attributed to this call" — an attribution window. This one answers
163
+ * "was the file still being written when we took its baseline" — a settling
164
+ * window. They happen to share a number today; a change to either for its own
165
+ * reasons must not move the other.
166
+ *
167
+ * ## The direction it does NOT close, stated
168
+ *
169
+ * On a filesystem whose mtime granularity is COARSER than this window (FAT's
170
+ * two seconds, HFS+'s one), a baseline recorded 150ms or more into a tick and
171
+ * a same-size rewrite later in that SAME tick still short-circuit: the entry
172
+ * looks settled because the mtime it carries is old, and the rewrite does not
173
+ * move it. That residual window is the price of the short-circuit that makes
174
+ * the sweep affordable at all. The settled sweep's next pass over the file
175
+ * closes it as soon as anything moves the size or the tick, and the armed
176
+ * observation (which hashes the target unconditionally) never depended on it.
177
+ */
178
+ const OBSERVED_LEDGER_SETTLE_MS = 150;
179
+ /**
180
+ * Path keys the "pi-lens already recorded this" set may hold.
181
+ *
182
+ * Marks are normally retired per file by {@link refreshObservedMutationLedger},
183
+ * as it re-baselines each one. A refresh that keeps parking before it reaches
184
+ * the tail leaves the tail's marks standing indefinitely, so the set still needs
185
+ * a bound of its own — and, because dropping a mark is not free (it makes the
186
+ * next sweep read pi-lens's own bytes as third-party drift), the drop emits
187
+ * `observed_handled_evicted` naming the path (#2449 review round 4, S2).
188
+ */
189
+ export const OBSERVED_HANDLED_MAX = 1000;
190
+ /**
191
+ * Files the settled sweep STATS in one turn before parking its cursor.
192
+ *
193
+ * Smaller than {@link OBSERVED_TRACKED_MAX_FILES} on purpose: coverage of the
194
+ * whole tracked set is spread across turns rather than attempted (and timed
195
+ * out) in one. The read-guard's own set is ordered first by
196
+ * `collectTrackedPaths`, so the files the agent is actually working on are at
197
+ * the front of the rotation.
198
+ */
199
+ export const OBSERVED_SWEEP_STAT_WINDOW = 128;
200
+ /** Cumulative content-hash budget for ONE capture. */
201
+ const OBSERVED_HASH_BUDGET_BYTES = 2 * 1024 * 1024;
202
+ /** Cumulative bytes ONE settled sweep may read to seed or verify a hash. */
203
+ export const OBSERVED_SWEEP_HASH_BUDGET_BYTES = 2 * 1024 * 1024;
204
+ /** Cumulative bytes ONE settled sweep may read to derive edit RANGES. */
205
+ const OBSERVED_SWEEP_RANGE_BUDGET_BYTES = 1024 * 1024;
206
+ /** Largest file whose per-line hashes are captured for range derivation. */
207
+ const OBSERVED_LINE_HASH_MAX_BYTES = 512 * 1024;
208
+ /** Ranges reported per file before they collapse to one bounding box. */
209
+ const OBSERVED_MAX_EDIT_RANGES = 32;
210
+ /** Pending baselines held between `tool_call` and `tool_result`. */
211
+ const OBSERVED_PENDING_MAX = 32;
212
+ /** Files remembered by the settled-sweep content ledger. */
213
+ const OBSERVED_LEDGER_MAX = 1000;
214
+ const OBSERVED_FAMILY = "observed-mutation-net";
215
+ const OBSERVED_VERSION = 3;
216
+ function state() {
217
+ return getProcessSingleton(OBSERVED_FAMILY, OBSERVED_VERSION, () => ({
218
+ pending: new BoundedFifoMap(OBSERVED_PENDING_MAX),
219
+ ledger: new BoundedFifoMap(OBSERVED_LEDGER_MAX),
220
+ handled: new BoundedSet(OBSERVED_HANDLED_MAX),
221
+ turnIndex: -1,
222
+ turnSpentMs: 0,
223
+ sweepCursor: 0,
224
+ }));
225
+ }
226
+ /**
227
+ * Session boundary (#2430). Pending baselines are keyed by tool-call id and
228
+ * are unreachable once the session generation advances; the content ledger and
229
+ * the handled set describe a finished session's files. All of it must clear or
230
+ * a resumed session diffs against another session's world.
231
+ */
232
+ export function resetObservedMutationNet() {
233
+ const current = state();
234
+ current.pending.clear();
235
+ current.ledger.clear();
236
+ current.handled.clear();
237
+ current.turnIndex = -1;
238
+ current.turnSpentMs = 0;
239
+ current.sweepCursor = 0;
240
+ }
241
+ /** Test seam: the net's live state, as plain data. */
242
+ export function _observedMutationStateForTests() {
243
+ const current = state();
244
+ return {
245
+ pending: [...current.pending.keys()],
246
+ ledger: [...current.ledger.keys()],
247
+ handled: [...current.handled],
248
+ turnSpentMs: current.turnSpentMs,
249
+ sweepCursor: current.sweepCursor,
250
+ };
251
+ }
252
+ /**
253
+ * Remember that the normal pipeline already recorded this path this run, so the
254
+ * settled sweep refreshes its baseline instead of reporting the same bytes a
255
+ * second time. Called from the classified `tool_result` path and from
256
+ * `recordMutationThroughSeam`, which is every in-process producer.
257
+ */
258
+ export function noteMutationHandled(filePath) {
259
+ try {
260
+ const handled = state().handled;
261
+ const key = normalizeMapKey(path.resolve(filePath));
262
+ // `BoundedSet#add` (#2460) owns the FIFO eviction and hands back what it
263
+ // dropped, oldest first — `handled` is membership-only, so there is never
264
+ // more than one entry here, but the array shape keeps this call site
265
+ // identical to every other `BoundedFifoMap`/`BoundedSet` eviction
266
+ // consumer rather than assuming the cardinality.
267
+ const evicted = handled.add(key);
268
+ for (const oldest of evicted) {
269
+ // The drop is NOT silent (#2449 review round 4, S2). Dropping a mark
270
+ // reinstates exactly the defect round 3 (S5) fixed: the ledger still
271
+ // holds the PRE-drain bytes for this file while the only record that
272
+ // those bytes were pi-lens's own has just been thrown away, so the
273
+ // next settled sweep replays our own formatter output as third-party
274
+ // drift. Naming the victim makes that a traceable record rather than a
275
+ // mystery re-format (catalog shape 10).
276
+ emitBounded("observed_handled_evicted",
277
+ // Identity is the DROPPED PATH, not a constant label: the ledger
278
+ // entry is keyed by subject and survives the per-turn cap on the
279
+ // detailed record, so it is what still names WHICH file lost its
280
+ // mark after a turn that overflowed the set many times.
281
+ oldest, {
282
+ filePath: oldest,
283
+ durationMs: 0,
284
+ result: `cap:${OBSERVED_HANDLED_MAX}`,
285
+ }, {
286
+ ledgerKind: "observed-mutation-budget",
287
+ reason: `the handled set is full at ${OBSERVED_HANDLED_MAX}; the oldest pi-lens-authored file lost its mark, so its next drift is reported as third-party`,
288
+ capPerTurn: { limit: 2, turnIndex: state().turnIndex },
289
+ });
290
+ }
291
+ }
292
+ catch {
293
+ // A path that cannot be resolved cannot collide with a ledger key either.
294
+ }
295
+ }
296
+ /**
297
+ * Whether a `tool_result` has a baseline waiting for it.
298
+ *
299
+ * SYNCHRONOUS, and the first thing the `tool_result` seam asks (#2449 review
300
+ * round 2, F1). The previous cut awaited the settle unconditionally, so every
301
+ * tool_result — including the overwhelming majority that miss this map —
302
+ * yielded a microtask before `handleToolResult` reached its debounce and
303
+ * in-flight registration, breaking the #1086 ordering contract.
304
+ */
305
+ export function hasPendingObservation(toolCallId) {
306
+ return toolCallId !== undefined && state().pending.has(toolCallId);
307
+ }
308
+ function remainingTurnBudgetMs(turnIndex) {
309
+ const current = state();
310
+ if (current.turnIndex !== turnIndex) {
311
+ current.turnIndex = turnIndex;
312
+ current.turnSpentMs = 0;
313
+ }
314
+ return Math.max(0, OBSERVED_TURN_BUDGET_MS - current.turnSpentMs);
315
+ }
316
+ function chargeTurnBudget(turnIndex, spentMs) {
317
+ const current = state();
318
+ if (current.turnIndex !== turnIndex) {
319
+ current.turnIndex = turnIndex;
320
+ current.turnSpentMs = 0;
321
+ }
322
+ current.turnSpentMs += Math.max(0, spentMs);
323
+ }
324
+ /** Test seam: force the per-turn budget to a known state. */
325
+ export function _setObservedTurnBudgetForTests(turnIndex, spentMs) {
326
+ const current = state();
327
+ current.turnIndex = turnIndex;
328
+ current.turnSpentMs = spentMs;
329
+ }
330
+ /**
331
+ * Both bounds on one async step: a wall-clock timeout AND an abort race.
332
+ *
333
+ * #2523 slice 2 folded the hand-rolled timer race out of here and onto
334
+ * `bounded()`. What is left is the ADAPTER: this seam needs a three-way
335
+ * outcome (`timeout` / `aborted` / `failed`) that `bounded()` deliberately
336
+ * does not hand back, because a throw and a blown budget have different
337
+ * remedies and folding them together is catalog shape 10.
338
+ *
339
+ * A loser is DISCARDED, never awaited to completion — the underlying work is
340
+ * stat/read only, so letting it finish unobserved costs nothing, while awaiting
341
+ * it would defeat the bound this exists to enforce.
342
+ *
343
+ * `T extends object` is the load-bearing constraint, not decoration. `bounded()`
344
+ * spells "a bound fired" as a bare `undefined`, so a `work` that could itself
345
+ * resolve `undefined` would be indistinguishable from a timeout here. The first
346
+ * cut paid for that with a `.then((value) => ({ value }))` box — which was
347
+ * VACUOUS: all four call sites return object literals, so no mutation of the box
348
+ * could red a test, and the comment justifying it named `captureLineHashes`,
349
+ * which returns a record and not `undefined` (#2557 review F6). Constraining the
350
+ * type parameter makes the invariant the box was pretending to protect a COMPILE
351
+ * error at any future call site instead of a per-call allocation.
352
+ */
353
+ async function withBounds(work, timeoutMs, signal, site) {
354
+ // A function, not an inline read: the signal is LIVE, so the compiler's
355
+ // narrowing from the pre-flight check below must not be carried across the
356
+ // await into the post-settle classification (it would fold that branch to
357
+ // "timeout" and hide every mid-await abort).
358
+ const isAborted = () => signal !== undefined && signal.aborted;
359
+ if (isAborted())
360
+ return { ok: false, reason: "aborted" };
361
+ try {
362
+ const settled = await bounded(work(), {
363
+ ms: timeoutMs,
364
+ // The observational net runs on hook paths that may or may not carry
365
+ // a signal; the wall budget is the bound that is always there, and
366
+ // `bounded()` reads a missing signal as one that never aborts.
367
+ signal,
368
+ hook: site.hook,
369
+ label: site.label,
370
+ });
371
+ if (settled !== undefined)
372
+ return { ok: true, value: settled };
373
+ // `bounded()` applies the caller's signal FIRST, so reading it back here
374
+ // reproduces which bound fired without a second channel.
375
+ return { ok: false, reason: isAborted() ? "aborted" : "timeout" };
376
+ }
377
+ catch {
378
+ // A THROW gets its own reason. Folding it into `timeout` is exactly the
379
+ // misclassification catalog shape 10 warns about: a reader tuning the
380
+ // budget would be chasing a bug that has nothing to do with time.
381
+ return { ok: false, reason: "failed" };
382
+ }
383
+ }
384
+ /**
385
+ * Test-only alias so `tests/clients/hook-await-fold-bounds.test.ts` can pin
386
+ * `withBounds`'s `T extends object` constraint at compile time (#2557 review
387
+ * F-B). `withBounds` itself is module-private -- there is no other way for a
388
+ * test file to reference it in a `@ts-expect-error` probe. Never called
389
+ * outside that probe.
390
+ */
391
+ export const _withBoundsForTests = withBounds;
392
+ /** `splitLines` semantics from `read-guard.ts`, kept identical on purpose. */
393
+ function splitLines(text) {
394
+ return text.split(/\r?\n/);
395
+ }
396
+ async function captureLineHashes(filePath, budget) {
397
+ try {
398
+ const size = (await fs.promises.stat(filePath)).size;
399
+ if (size > OBSERVED_LINE_HASH_MAX_BYTES)
400
+ return undefined;
401
+ if (budget) {
402
+ if (budget.remainingBytes < size)
403
+ return undefined;
404
+ budget.remainingBytes -= size;
405
+ }
406
+ const lines = splitLines(await fs.promises.readFile(filePath, "utf-8"));
407
+ const hashes = new Map();
408
+ for (let index = 0; index < lines.length; index += 1) {
409
+ hashes.set(index + 1, lineContentHash(lines[index] ?? ""));
410
+ }
411
+ return hashes;
412
+ }
413
+ catch {
414
+ return undefined;
415
+ }
416
+ }
417
+ /**
418
+ * A baseline's per-line hashes as a dense 1..N array, or `undefined` when it
419
+ * does not cover the whole file.
420
+ *
421
+ * The read-guard stores hashes for the lines a read actually SHOWED, so a
422
+ * windowed read's map covers a slice (say lines 60..100) and says nothing at
423
+ * all about the rest. Treating that as a whole-file baseline is what made the
424
+ * first cut drop a real change at line 3 and report a fabricated 61..101 range
425
+ * (#2449 review round 2, F6). A baseline is usable only when its keys are
426
+ * exactly 1..N.
427
+ */
428
+ function denseLineBaseline(before) {
429
+ if (before === undefined)
430
+ return undefined;
431
+ const entries = before instanceof Map
432
+ ? [...before.entries()]
433
+ : Object.entries(before).map(([line, hash]) => [Number(line), hash]);
434
+ const count = entries.length;
435
+ if (count === 0)
436
+ return undefined;
437
+ const ordered = [];
438
+ ordered.length = count;
439
+ for (const [line, hash] of entries) {
440
+ if (!Number.isInteger(line) || line < 1 || line > count)
441
+ return undefined;
442
+ ordered[line - 1] = hash;
443
+ }
444
+ for (const value of ordered)
445
+ if (value === undefined)
446
+ return undefined;
447
+ return ordered;
448
+ }
449
+ /**
450
+ * Line ranges that actually differ, from the same FNV-1a whitespace-stripped
451
+ * per-line hash the read-guard stores for every read (#505). `before` is the
452
+ * pre-call capture when the net armed one, and otherwise the read-guard's own
453
+ * stored hashes for the file — the issue's "content diff against the
454
+ * read-guard's stored content".
455
+ *
456
+ * Returns `undefined` — meaning "no ranges, over-approximate to the whole
457
+ * file" — whenever a per-line comparison would be a fabrication rather than a
458
+ * measurement:
459
+ *
460
+ * - no baseline at all;
461
+ * - a PARTIAL (windowed) baseline, which knows nothing about the lines outside
462
+ * its window;
463
+ * - a changed LINE COUNT, where every line after an insert or delete shifts and
464
+ * comparing by line number reports the shift instead of the edit;
465
+ * - a file too large to hash, or a spent read budget.
466
+ *
467
+ * Every one of those is the safe direction: the bridge's `resolveChangedRange`
468
+ * then over-approximates to the whole file rather than naming lines that were
469
+ * never touched.
470
+ */
471
+ export async function deriveObservedEditRanges(filePath, before, budget) {
472
+ const baseline = denseLineBaseline(before);
473
+ if (baseline === undefined)
474
+ return undefined;
475
+ const after = await captureLineHashes(filePath, budget);
476
+ if (after === undefined)
477
+ return undefined;
478
+ if (after.size !== baseline.length)
479
+ return undefined;
480
+ const changed = [];
481
+ for (let line = 1; line <= baseline.length; line += 1) {
482
+ if (after.get(line) !== baseline[line - 1])
483
+ changed.push(line);
484
+ }
485
+ if (changed.length === 0)
486
+ return undefined;
487
+ const ranges = [];
488
+ let start = changed[0];
489
+ let end = changed[0];
490
+ for (const line of changed.slice(1)) {
491
+ if (line === end + 1) {
492
+ end = line;
493
+ continue;
494
+ }
495
+ ranges.push([start, end]);
496
+ start = line;
497
+ end = line;
498
+ }
499
+ ranges.push([start, end]);
500
+ if (ranges.length > OBSERVED_MAX_EDIT_RANGES) {
501
+ // A rewrite this scattered is a whole-file change in practice; one
502
+ // bounding box keeps the record bounded (AGENTS.md bounded-record rule).
503
+ return [[ranges[0][0], ranges[ranges.length - 1][1]]];
504
+ }
505
+ return ranges;
506
+ }
507
+ function boundingBox(ranges) {
508
+ return [
509
+ Math.min(...ranges.map(([start]) => start)),
510
+ Math.max(...ranges.map(([, end]) => end)),
511
+ ];
512
+ }
513
+ /**
514
+ * Both of these are one-liners over {@link BoundedFifoMap} (#2442, adopted in
515
+ * #2449 review round 4, B2). They stay as named functions because the CAP each
516
+ * map carries is part of this module's contract and the call sites read
517
+ * better naming the map than the container; the eviction block itself is the
518
+ * primitive's.
519
+ */
520
+ function putPending(key, value) {
521
+ state().pending.set(key, value);
522
+ }
523
+ function putLedger(key, value) {
524
+ state().ledger.set(key, value);
525
+ }
526
+ function seedLedger(snapshot) {
527
+ const seenAtMs = Date.now();
528
+ for (const [key, entry] of snapshot) {
529
+ putLedger(key, {
530
+ hash: entry.hash,
531
+ hashKind: entry.hash === undefined ? undefined : "content",
532
+ size: entry.size,
533
+ mtimeMs: entry.mtimeMs,
534
+ seenAtMs,
535
+ });
536
+ }
537
+ }
538
+ function linesDigest(ordered) {
539
+ return createHash("sha256").update(ordered.join("\n")).digest("hex");
540
+ }
541
+ function linesDigestOfContent(content) {
542
+ return linesDigest(splitLines(content).map((line) => lineContentHash(line)));
543
+ }
544
+ /**
545
+ * Take the pre-call baseline for an unclassified tool call.
546
+ *
547
+ * Cost on a call this does NOT arm is one `Map` lookup — the eligibility check
548
+ * runs before any filesystem work, so a classified tool never reaches here at
549
+ * all and a latched tool stops after the lookup.
550
+ */
551
+ export async function armObservedMutation(args) {
552
+ if (!shouldArmObservationForTool(args.toolName))
553
+ return { armed: false, reason: "not-eligible" };
554
+ if (!args.toolCallId)
555
+ return { armed: false, reason: "no-tool-call-id" };
556
+ const remaining = remainingTurnBudgetMs(args.turnIndex);
557
+ if (remaining <= 0) {
558
+ emitBounded("observed_mutation_budget_exhausted", args.toolName, {
559
+ filePath: args.targetPath,
560
+ durationMs: 0,
561
+ result: `turn-budget:${OBSERVED_TURN_BUDGET_MS}ms`,
562
+ }, {
563
+ ledgerKind: "observed-mutation-budget",
564
+ reason: "per-turn observational snapshot budget exhausted",
565
+ capPerTurn: { limit: 2, turnIndex: args.turnIndex },
566
+ });
567
+ return { armed: false, reason: "budget-exhausted" };
568
+ }
569
+ const started = Date.now();
570
+ const timeoutMs = Math.min(remaining, OBSERVED_CAPTURE_BUDGET_MS);
571
+ const outcome = await withBounds(async () => {
572
+ const universe = await collectObservationUniverse(args.targetPath);
573
+ const captured = await captureFileStatsForPaths(universe.paths, {
574
+ withHashes: true,
575
+ hashBudgetBytes: OBSERVED_HASH_BUDGET_BYTES,
576
+ });
577
+ // INSIDE the bounds, and therefore inside the turn charge below
578
+ // (#2449 review round 4, S3). This reads and per-line-hashes the target
579
+ // up to OBSERVED_LINE_HASH_MAX_BYTES — half a megabyte, and the
580
+ // dominant cost of arming a large target. The first cut awaited it
581
+ // AFTER `chargeTurnBudget` and outside `withBounds`, so the majority of
582
+ // the arm was charged to nobody and covered by neither the timeout nor
583
+ // the abort race — both halves of AGENTS.md's two-bounds rule missing on
584
+ // the single most expensive step.
585
+ const lineHashes = await captureLineHashes(args.targetPath);
586
+ return {
587
+ paths: universe.paths,
588
+ capped: universe.capped,
589
+ stats: captured.snapshot,
590
+ lineHashes,
591
+ };
592
+ }, timeoutMs, args.signal,
593
+ // Reached from `clients/runtime-tool-call.ts`, which #2523's contract
594
+ // gives no wall budget of its own — the ledger key still names it so a
595
+ // blown capture budget is attributable.
596
+ { hook: "tool_call", label: "armObservedMutation" });
597
+ chargeTurnBudget(args.turnIndex, Date.now() - started);
598
+ if (!outcome.ok) {
599
+ emitBounded("observed_mutation_budget_exhausted", args.toolName, {
600
+ filePath: args.targetPath,
601
+ durationMs: Date.now() - started,
602
+ result: outcome.reason,
603
+ }, {
604
+ ledgerKind: "observed-mutation-budget",
605
+ reason: `observational pre-snapshot ${outcome.reason}`,
606
+ capPerTurn: { limit: 2, turnIndex: args.turnIndex },
607
+ });
608
+ return { armed: false, reason: outcome.reason };
609
+ }
610
+ const targetKey = normalizeMapKey(path.resolve(args.targetPath));
611
+ seedLedger(outcome.value.stats);
612
+ if (outcome.value.capped) {
613
+ // A truncated universe is a real coverage gap and it is named here, at
614
+ // the moment it happens, so it is counted even for a call whose settle
615
+ // never arrives (catalog shape 10).
616
+ emitBounded("observed_target_dir_capped", args.toolName, {
617
+ filePath: args.targetPath,
618
+ durationMs: Date.now() - started,
619
+ result: `entries:${OBSERVED_TARGET_DIR_MAX_ENTRIES}`,
620
+ }, {
621
+ ledgerKind: "observed-mutation-dir-cap",
622
+ reason: `directory target has more than ${OBSERVED_TARGET_DIR_MAX_ENTRIES} entries; the observation covers only the first ${OBSERVED_TARGET_DIR_MAX_ENTRIES}`,
623
+ capPerTurn: { limit: 2, turnIndex: args.turnIndex },
624
+ });
625
+ }
626
+ putPending(args.toolCallId, {
627
+ toolName: args.toolName,
628
+ startedAt: started,
629
+ cwd: args.cwd,
630
+ sessionGeneration: args.sessionGeneration,
631
+ paths: outcome.value.paths,
632
+ stats: outcome.value.stats,
633
+ targetKey,
634
+ targetLineHashes: outcome.value.lineHashes,
635
+ targetDirCapped: outcome.value.capped,
636
+ });
637
+ const durationMs = Date.now() - started;
638
+ logLatency({
639
+ type: "phase",
640
+ toolName: args.toolName,
641
+ phase: "observed_mutation_prescan",
642
+ filePath: args.targetPath,
643
+ durationMs,
644
+ result: `scanned:${outcome.value.stats.size}`,
645
+ });
646
+ return { armed: true, scannedCount: outcome.value.stats.size, durationMs };
647
+ }
648
+ /**
649
+ * The snapshot universe: the TARGET PATH, and nothing else.
650
+ *
651
+ * A file target is itself. A DIRECTORY target is its own entries,
652
+ * non-recursively and capped — a tool naming a directory is claiming that
653
+ * directory as its working set. A path that does not exist yet is still
654
+ * watched as itself, so a call that CREATES it is observed.
655
+ *
656
+ * `capped` is the load-bearing half of the return (#2449 review round 3, S3):
657
+ * when the cap bites, the list below is a TRUNCATION of what the tool named,
658
+ * and the settle must not read an empty diff over it as evidence the tool
659
+ * changed nothing.
660
+ *
661
+ * See the module header for why the sibling walk and the tracked-set fold this
662
+ * replaces were both wrong.
663
+ */
664
+ async function collectObservationUniverse(targetPath) {
665
+ const target = path.resolve(targetPath);
666
+ try {
667
+ const stat = await fs.promises.stat(target);
668
+ if (stat.isDirectory()) {
669
+ const entries = await fs.promises.readdir(target, {
670
+ withFileTypes: true,
671
+ });
672
+ const files = [];
673
+ let capped = false;
674
+ for (const entry of entries) {
675
+ if (files.length >= OBSERVED_TARGET_DIR_MAX_ENTRIES) {
676
+ capped = true;
677
+ break;
678
+ }
679
+ if (entry.isFile())
680
+ files.push(path.join(target, entry.name));
681
+ }
682
+ return { paths: files, capped };
683
+ }
684
+ }
685
+ catch {
686
+ // Does not exist yet, or is unreadable. Watching the path itself is the
687
+ // right answer for both: a call that creates it shows up as an addition.
688
+ }
689
+ return { paths: [target], capped: false };
690
+ }
691
+ /**
692
+ * Diff the post-call state against the baseline and replay what changed.
693
+ *
694
+ * A change here is the FIRST-CALL coverage #2430's first acceptance criterion
695
+ * asks for: the tool is unknown, so nothing downstream would have recorded the
696
+ * file, and this replay is what puts it in `turn-state.json`.
697
+ *
698
+ * ASYNC since round 3 (T4). Round 2 made it synchronous because
699
+ * `handleToolResult` may not yield before dispatching the pipeline (#1086) —
700
+ * but the fix for that is to keep the PROBE synchronous ({@link
701
+ * hasPendingObservation}, which every call pays and almost every call fails)
702
+ * and to have the caller read what it derives from the post-result bytes
703
+ * BEFORE this yield. Blocking the event loop on a directory's worth of
704
+ * `readFileSync` to buy an ordering property the call site can state directly
705
+ * was the wrong trade. Reaching this function at all already means there is
706
+ * real work to do.
707
+ *
708
+ * NOT budget-gated. The previous cut clamped the post-capture to whatever was
709
+ * left of the per-turn arm budget, which on a busy turn is 1ms — long enough to
710
+ * stat nothing and report a timeout, dropping a mutation that had already been
711
+ * measured (round 2, F5). The baseline exists; re-capturing the target it was
712
+ * taken for is not optional.
713
+ */
714
+ export async function settleObservedMutation(args) {
715
+ const key = args.toolCallId;
716
+ if (!key)
717
+ return {
718
+ settled: false,
719
+ changedPaths: [],
720
+ replayed: 0,
721
+ scanned: 0,
722
+ stoppedEarly: false,
723
+ reason: "no-tool-call-id",
724
+ };
725
+ const pending = state().pending.get(key);
726
+ if (!pending) {
727
+ // A missing baseline is a real answer, not a no-op: the arm was evicted
728
+ // by the pending cap, or never ran. Saying so keeps "nothing changed"
729
+ // distinguishable from "nothing was watched" (catalog shape 10).
730
+ return {
731
+ settled: false,
732
+ changedPaths: [],
733
+ replayed: 0,
734
+ scanned: 0,
735
+ stoppedEarly: false,
736
+ reason: "no-pending-baseline",
737
+ };
738
+ }
739
+ state().pending.delete(key);
740
+ if (pending.sessionGeneration !== args.sessionGeneration) {
741
+ // Catalog shape 22: the baseline belongs to a session that has since
742
+ // ended. Diffing across that boundary would attribute another session's
743
+ // world to this call.
744
+ return {
745
+ settled: false,
746
+ changedPaths: [],
747
+ replayed: 0,
748
+ scanned: 0,
749
+ stoppedEarly: false,
750
+ reason: "session-generation-advanced",
751
+ };
752
+ }
753
+ const started = Date.now();
754
+ // The target is `paths[0]` for a file target and the whole (already capped)
755
+ // entry list for a directory one. The deadline can only ever cut a directory
756
+ // target's tail — `captureFileStatsForPaths` always runs its first entry.
757
+ // The outer race exists only to bound a single wedged `stat`, so its budget
758
+ // is deliberately slack compared with the per-entry deadline that does the
759
+ // real work.
760
+ const capture = await withBounds(() => captureFileStatsForPaths(pending.paths, {
761
+ withHashes: true,
762
+ hashBudgetBytes: OBSERVED_HASH_BUDGET_BYTES,
763
+ deadlineMs: started + OBSERVED_SETTLE_DEADLINE_MS,
764
+ signal: args.signal,
765
+ }), OBSERVED_SETTLE_DEADLINE_MS * 4, args.signal, { hook: "tool_result_edit", label: "settleObservedMutation" });
766
+ chargeTurnBudget(args.turnIndex, Date.now() - started);
767
+ if (!capture.ok) {
768
+ // A wedged filesystem call. There is no diff to report and, critically,
769
+ // no evidence the tool was clean — so the clean latch is not advanced.
770
+ noteObservedUnverifiable(args.toolName);
771
+ return {
772
+ settled: false,
773
+ changedPaths: [],
774
+ replayed: 0,
775
+ scanned: 0,
776
+ stoppedEarly: true,
777
+ reason: capture.reason,
778
+ };
779
+ }
780
+ const captured = capture.value;
781
+ seedLedger(captured.snapshot);
782
+ const changed = diffFileStats(pending.stats, captured.snapshot).filter((candidate) => args.isRecordable?.(candidate) !== false);
783
+ // Two different ways the observation can fall short of what the tool named:
784
+ // the ARM already truncated a wide directory (#2449 round 3, S3), or the
785
+ // re-capture's own deadline cut its tail. Both mean the same thing to the
786
+ // verdict below.
787
+ const truncated = pending.targetDirCapped || captured.stoppedEarly;
788
+ const cutReason = pending.targetDirCapped
789
+ ? "target-dir-cap-exceeded"
790
+ : captured.stoppedEarly
791
+ ? "capture-cut-short"
792
+ : undefined;
793
+ if (changed.length === 0) {
794
+ // An INCOMPLETE observation is not evidence of cleanliness — it is
795
+ // evidence we stopped looking. Advancing the clean latch on it would
796
+ // teach pi-lens to stop watching a tool it never finished watching, and
797
+ // with a directory wider than the cap that is every single call.
798
+ if (truncated)
799
+ noteObservedUnverifiable(args.toolName);
800
+ else
801
+ noteObservedClean(args.toolName);
802
+ return {
803
+ settled: true,
804
+ changedPaths: [],
805
+ replayed: 0,
806
+ scanned: captured.snapshot.size,
807
+ stoppedEarly: truncated,
808
+ reason: cutReason,
809
+ };
810
+ }
811
+ const rangeBudget = {
812
+ remainingBytes: OBSERVED_HASH_BUDGET_BYTES,
813
+ };
814
+ let replayed = 0;
815
+ for (const filePath of changed) {
816
+ const baseline = filePath === pending.targetKey
817
+ ? pending.targetLineHashes
818
+ : args.getStoredLineHashes?.(filePath);
819
+ const editRanges = await deriveObservedEditRanges(filePath, baseline, rangeBudget);
820
+ const accepted = args.record({
821
+ filePath,
822
+ kind: "edit",
823
+ touchedLines: editRanges ? boundingBox(editRanges) : undefined,
824
+ editRanges: editRanges && editRanges.length > 1 ? editRanges : undefined,
825
+ consumer: args.toolName,
826
+ provenance: "observed",
827
+ });
828
+ if (accepted) {
829
+ replayed += 1;
830
+ noteMutationHandled(filePath);
831
+ }
832
+ }
833
+ if (replayed > 0) {
834
+ // The universe IS the target path, so a replay here means the tool
835
+ // changed what it named — never a sibling that happened to move
836
+ // underneath it (#2449 round 2, F4).
837
+ const attribution = noteObservedMutation(args.toolName, pending.cwd);
838
+ logLatency({
839
+ type: "phase",
840
+ toolName: args.toolName,
841
+ phase: "observed_mutation_recovered",
842
+ filePath: changed.slice(0, 5).join(","),
843
+ durationMs: Date.now() - started,
844
+ result: `changed:${changed.length} observations:${attribution.observations}${attribution.persisted ? " persisted" : ""}`,
845
+ });
846
+ }
847
+ else {
848
+ // Every candidate was refused by the recorder (out of scope, or the
849
+ // bookkeeping failed). That is not evidence the tool is clean, so the
850
+ // clean latch is deliberately NOT advanced here.
851
+ logLatency({
852
+ type: "phase",
853
+ toolName: args.toolName,
854
+ phase: "observed_mutation_coverage_unknown",
855
+ filePath: changed.slice(0, 5).join(","),
856
+ durationMs: Date.now() - started,
857
+ result: `refused:${changed.length}`,
858
+ });
859
+ }
860
+ return {
861
+ settled: true,
862
+ changedPaths: changed,
863
+ replayed,
864
+ scanned: captured.snapshot.size,
865
+ stoppedEarly: truncated,
866
+ reason: cutReason,
867
+ };
868
+ }
869
+ async function readBytesSafe(filePath) {
870
+ try {
871
+ return await fs.promises.readFile(filePath);
872
+ }
873
+ catch {
874
+ return undefined;
875
+ }
876
+ }
877
+ /**
878
+ * One incremental pass over the tracked set (#2449 round 2, F3).
879
+ *
880
+ * The shape that makes this affordable: **stat first, read only on change.**
881
+ * A file whose size and mtime match the ledger costs one `stat` and nothing
882
+ * else, which is the steady state for almost every file on almost every turn.
883
+ * A file whose stat moved is the only one worth reading, and that read is what
884
+ * separates a real edit from a `touch`.
885
+ *
886
+ * A first sighting prefers the read-guard's stored per-line hashes over a file
887
+ * read: #505 already paid for those bytes, so seeding a file the agent has read
888
+ * costs no I/O beyond the stat it already did.
889
+ *
890
+ * `report: false` is the post-drain re-baseline. Its traversal is NOT the
891
+ * tracked set — see `refreshObservedMutationLedger` for why it walks
892
+ * `handled` instead — so `getTrackedPaths` is only ever consulted on the
893
+ * `report: true` (settled-sweep) path and is optional here.
894
+ */
895
+ async function scanTrackedIncrementally(args, options) {
896
+ const current = state();
897
+ let tracked;
898
+ if (options.report) {
899
+ try {
900
+ tracked = (args.getTrackedPaths?.() ?? []).slice(0, OBSERVED_TRACKED_MAX_FILES);
901
+ }
902
+ catch {
903
+ tracked = [];
904
+ }
905
+ }
906
+ else {
907
+ // The post-drain refresh's job is to re-baseline pi-lens's OWN drain
908
+ // output, and every file the drain wrote this run is already in
909
+ // `handled` (see `noteMutationHandled`) — so THAT is the traversal, not
910
+ // the full tracked set. See `refreshObservedMutationLedger` for the
911
+ // coverage argument.
912
+ tracked = [...current.handled];
913
+ }
914
+ const total = tracked.length;
915
+ if (total === 0) {
916
+ current.sweepCursor = 0;
917
+ return {
918
+ scanned: 0,
919
+ tracked: 0,
920
+ cursor: 0,
921
+ drifted: [],
922
+ unverifiable: [],
923
+ stoppedEarly: false,
924
+ };
925
+ }
926
+ const startCursor = options.report
927
+ ? ((current.sweepCursor % total) + total) % total
928
+ : 0;
929
+ const window = options.report
930
+ ? Math.min(OBSERVED_SWEEP_STAT_WINDOW, total)
931
+ : total;
932
+ let hashBytesLeft = OBSERVED_SWEEP_HASH_BUDGET_BYTES;
933
+ const drifted = [];
934
+ const unverifiable = [];
935
+ let scanned = 0;
936
+ let stoppedEarly = false;
937
+ let steps = 0;
938
+ /**
939
+ * Re-baseline one file, and on the POST-DRAIN pass (`report: false`) retire its
940
+ * `handled` mark at the same moment (#2449 review round 4, S2).
941
+ *
942
+ * Per FILE, not all-or-nothing. The mark exists to stop pi-lens's own drain
943
+ * output being read as third-party drift, and it is safe to drop exactly when
944
+ * the ledger has been moved onto the post-drain bytes for THAT file — which is
945
+ * here. The previous cut cleared the whole set after the fact and only when
946
+ * the pass completed, which was wrong in both directions: a parked pass kept
947
+ * marks for files it HAD re-baselined (suppressing a real third-party change
948
+ * to them until some later pass completed), and a completed pass dropped marks
949
+ * for files it had skipped over.
950
+ */
951
+ const rebaseline = (ledgerKey, entry) => {
952
+ putLedger(ledgerKey, entry);
953
+ if (!options.report)
954
+ current.handled.delete(ledgerKey);
955
+ };
956
+ for (; steps < window; steps += 1) {
957
+ if (args.signal?.aborted === true) {
958
+ stoppedEarly = true;
959
+ break;
960
+ }
961
+ if (Date.now() >= options.deadlineMs) {
962
+ stoppedEarly = true;
963
+ break;
964
+ }
965
+ const filePath = tracked[(startCursor + steps) % total];
966
+ let key;
967
+ try {
968
+ key = normalizeMapKey(path.resolve(filePath));
969
+ }
970
+ catch {
971
+ continue;
972
+ }
973
+ let stat;
974
+ try {
975
+ stat = await fs.promises.stat(filePath);
976
+ }
977
+ catch {
978
+ // Gone, or unreadable. Deletions are not this net's business.
979
+ scanned += 1;
980
+ continue;
981
+ }
982
+ if (!stat.isFile()) {
983
+ scanned += 1;
984
+ continue;
985
+ }
986
+ scanned += 1;
987
+ const previous = current.ledger.get(key);
988
+ const next = {
989
+ size: stat.size,
990
+ mtimeMs: stat.mtimeMs,
991
+ hash: previous?.hash,
992
+ hashKind: previous?.hashKind,
993
+ seenAtMs: Date.now(),
994
+ };
995
+ if (previous === undefined) {
996
+ const dense = denseLineBaseline(args.getStoredLineHashes?.(key));
997
+ if (dense) {
998
+ next.hash = linesDigest(dense);
999
+ next.hashKind = "lines";
1000
+ }
1001
+ else if (stat.size <= hashBytesLeft) {
1002
+ const content = await readBytesSafe(filePath);
1003
+ if (content !== undefined) {
1004
+ hashBytesLeft -= stat.size;
1005
+ next.hash = createHash("sha256").update(content).digest("hex");
1006
+ next.hashKind = "content";
1007
+ }
1008
+ }
1009
+ rebaseline(key, next);
1010
+ continue;
1011
+ }
1012
+ // The stat short-circuit — the reason this pass is affordable — but only
1013
+ // when the baseline is old enough to be trusted. See `LedgerEntry.seenAtMs`
1014
+ // for the same-tick same-size rewrite it would otherwise bake in forever.
1015
+ //
1016
+ // The reference instant is OBSERVED_LEDGER_SETTLE_MS *before* the entry
1017
+ // was recorded: a file whose mtime is later than that was still in
1018
+ // flight when we looked at it, so its stat cannot stand in for its
1019
+ // bytes. `fresh` therefore means "already settled when recorded".
1020
+ const baselineSettled = freshnessFromMtime({
1021
+ mtimeMs: stat.mtimeMs,
1022
+ referenceMs: previous.seenAtMs - OBSERVED_LEDGER_SETTLE_MS,
1023
+ toleranceMs: 0,
1024
+ }).verdict === "fresh";
1025
+ if (previous.size === stat.size &&
1026
+ previous.mtimeMs === stat.mtimeMs &&
1027
+ baselineSettled) {
1028
+ rebaseline(key, next);
1029
+ continue;
1030
+ }
1031
+ // A drift CANDIDATE. Size moving is proof on its own; mtime moving is
1032
+ // NOT (a `touch` bumps it without a byte moving), so mtime-only drift
1033
+ // has to be confirmed against content before anything is replayed
1034
+ // (#2449 round 2, F7).
1035
+ const sizeChanged = previous.size !== stat.size;
1036
+ let verdict = sizeChanged ? "drift" : "unverifiable";
1037
+ if (stat.size <= hashBytesLeft) {
1038
+ const content = await readBytesSafe(filePath);
1039
+ if (content !== undefined) {
1040
+ hashBytesLeft -= stat.size;
1041
+ const contentHash = createHash("sha256").update(content).digest("hex");
1042
+ if (previous.hash === undefined) {
1043
+ verdict = sizeChanged ? "drift" : "unverifiable";
1044
+ }
1045
+ else if (previous.hashKind === "lines") {
1046
+ verdict =
1047
+ linesDigestOfContent(content.toString("utf-8")) !== previous.hash
1048
+ ? "drift"
1049
+ : "clean";
1050
+ }
1051
+ else {
1052
+ verdict = contentHash !== previous.hash ? "drift" : "clean";
1053
+ }
1054
+ // Store the CONTENT hash either way, so the next change to this
1055
+ // file is verifiable even if this one was not.
1056
+ next.hash = contentHash;
1057
+ next.hashKind = "content";
1058
+ }
1059
+ }
1060
+ rebaseline(key, next);
1061
+ if (!options.report)
1062
+ continue;
1063
+ if (current.handled.has(key))
1064
+ continue;
1065
+ if (args.isRecordable?.(key) === false)
1066
+ continue;
1067
+ if (verdict === "drift")
1068
+ drifted.push(key);
1069
+ else if (verdict === "unverifiable")
1070
+ unverifiable.push(key);
1071
+ }
1072
+ if (options.report)
1073
+ current.sweepCursor = (startCursor + steps) % total;
1074
+ return {
1075
+ scanned,
1076
+ tracked: total,
1077
+ cursor: options.report ? current.sweepCursor : 0,
1078
+ drifted,
1079
+ unverifiable,
1080
+ stoppedEarly,
1081
+ };
1082
+ }
1083
+ /**
1084
+ * The turn-boundary net (#2430 item 3), run at `agent_settled` BEFORE the
1085
+ * deferred drain so anything it finds is formatted in the same settle.
1086
+ *
1087
+ * Files the pipeline already recorded this run refresh their baseline and are
1088
+ * never reported twice. Coverage is incremental and self-reported: see
1089
+ * {@link scanTrackedIncrementally} and {@link SettledSweepResult}.
1090
+ */
1091
+ export async function runObservedSettledSweep(args) {
1092
+ const started = Date.now();
1093
+ const outcome = await withBounds(() => scanTrackedIncrementally(args, {
1094
+ report: true,
1095
+ deadlineMs: started + OBSERVED_CAPTURE_BUDGET_MS,
1096
+ }),
1097
+ // The inner loop parks its own cursor at the deadline, so this outer
1098
+ // race exists only to bound a single wedged `stat` — hence the slack.
1099
+ OBSERVED_CAPTURE_BUDGET_MS * 2, args.signal, { hook: "agent_settled", label: "runObservedSettledSweep" });
1100
+ if (!outcome.ok) {
1101
+ emitBounded("observed_sweep_skipped_budget", "settled-sweep", { durationMs: Date.now() - started, result: outcome.reason }, {
1102
+ ledgerKind: "observed-mutation-budget",
1103
+ reason: `settled sweep ${outcome.reason}`,
1104
+ capPerTurn: { limit: 2, turnIndex: args.turnIndex },
1105
+ });
1106
+ return {
1107
+ scanned: 0,
1108
+ notReachedThisPass: 0,
1109
+ cursor: state().sweepCursor,
1110
+ drifted: [],
1111
+ unverifiable: [],
1112
+ replayed: 0,
1113
+ reason: outcome.reason,
1114
+ };
1115
+ }
1116
+ const scan = outcome.value;
1117
+ const rangeBudget = {
1118
+ remainingBytes: OBSERVED_SWEEP_RANGE_BUDGET_BYTES,
1119
+ };
1120
+ let replayed = 0;
1121
+ for (const filePath of scan.drifted) {
1122
+ if (args.signal?.aborted === true)
1123
+ break;
1124
+ const editRanges = await deriveObservedEditRanges(filePath, args.getStoredLineHashes?.(filePath), rangeBudget);
1125
+ const accepted = args.record({
1126
+ filePath,
1127
+ kind: "edit",
1128
+ touchedLines: editRanges ? boundingBox(editRanges) : undefined,
1129
+ editRanges: editRanges && editRanges.length > 1 ? editRanges : undefined,
1130
+ consumer: "settled-sweep",
1131
+ provenance: "settled-sweep",
1132
+ });
1133
+ if (accepted)
1134
+ replayed += 1;
1135
+ }
1136
+ const notReachedThisPass = Math.max(0, scan.tracked - scan.scanned);
1137
+ if (scan.drifted.length > 0 || scan.unverifiable.length > 0) {
1138
+ logLatency({
1139
+ type: "phase",
1140
+ phase: "observed_settled_sweep_drift",
1141
+ filePath: scan.drifted.slice(0, 5).join(","),
1142
+ durationMs: Date.now() - started,
1143
+ result: `drifted:${scan.drifted.length} replayed:${replayed} unverifiable:${scan.unverifiable.length} scanned:${scan.scanned}/${scan.tracked} cursor:${scan.cursor}`,
1144
+ });
1145
+ }
1146
+ if (scan.unverifiable.length > 0) {
1147
+ // Named, capped, and never replayed: a stat that moved without a
1148
+ // provable content change is a gap in coverage, and a gap that reports
1149
+ // itself is the only kind that gets fixed (catalog shape 10).
1150
+ emitBounded("observed_sweep_unverifiable", "settled-sweep", {
1151
+ durationMs: Date.now() - started,
1152
+ result: `unverifiable:${scan.unverifiable.length}`,
1153
+ filePath: scan.unverifiable.slice(0, 5).join(","),
1154
+ }, {
1155
+ ledgerKind: "observed-mutation-budget",
1156
+ reason: "tracked file's size/mtime moved but no hashed baseline could confirm a content change",
1157
+ capPerTurn: { limit: 2, turnIndex: args.turnIndex },
1158
+ });
1159
+ }
1160
+ return {
1161
+ scanned: scan.scanned,
1162
+ notReachedThisPass,
1163
+ cursor: scan.cursor,
1164
+ drifted: scan.drifted,
1165
+ unverifiable: scan.unverifiable,
1166
+ replayed,
1167
+ reason: scan.stoppedEarly ? "window-parked" : undefined,
1168
+ };
1169
+ }
1170
+ /**
1171
+ * Re-baseline pi-lens's OWN writes AFTER the deferred drain.
1172
+ *
1173
+ * The drain is pi-lens formatting and autofixing files it already knows about,
1174
+ * so those bytes are ours. Without this the next settle would read them as
1175
+ * third-party drift and requeue the same files forever.
1176
+ *
1177
+ * ## The traversal is `handled`, not the tracked set (#2449 review round 5, F2)
1178
+ *
1179
+ * Every file this function needs to re-baseline is, by construction, already
1180
+ * in `handled`: that set exists exactly to name "pi-lens wrote these bytes
1181
+ * this run" (see `noteMutationHandled`), and this function's only job is to
1182
+ * clear that claim once the ledger agrees. Walking `getTrackedPaths()`
1183
+ * instead — the earlier shape — coupled this function's cost and completion
1184
+ * to the size of the TRACKED set (up to `OBSERVED_TRACKED_MAX_FILES`, i.e.
1185
+ * 400) rather than to the size of the DRAIN (a handful of files), and on a
1186
+ * tracked set too large to finish inside `OBSERVED_CAPTURE_BUDGET_MS` the
1187
+ * pass parked at the same prefix every turn — `report: false` always starts
1188
+ * its cursor at 0 — so files in the tail never got re-baselined and their
1189
+ * `handled` marks never retired, permanently suppressing drift reports for
1190
+ * them (reviewer PROBE-B1).
1191
+ *
1192
+ * `handled` is bounded by `OBSERVED_HANDLED_MAX` and holds only files the
1193
+ * pipeline or the drain actually wrote this run, so iterating it is
1194
+ * O(handful) rather than O(tracked set) and — barring an aborted turn or a
1195
+ * genuinely pathological handled-set size — completes in one pass every time.
1196
+ * That is what lets every mark retire on every refresh instead of only the
1197
+ * marks a rotating cursor happened to reach.
1198
+ *
1199
+ * ## The retirement is still per FILE (#2449 review rounds 3 (S5) and 4 (S2))
1200
+ *
1201
+ * A mark is dropped exactly when the ledger has been moved onto the
1202
+ * POST-drain bytes for that same file — inside the scan, one file at a time
1203
+ * (see `rebaseline`) — rather than all-or-nothing on completion. An aborted
1204
+ * or truncated pass therefore still retires the marks for the files it DID
1205
+ * reach and correctly leaves the rest standing; it no longer needs a
1206
+ * dedicated "coverage gap" record to stay honest, because the traversal it
1207
+ * covers is the whole of what it was asked to do.
1208
+ */
1209
+ export async function refreshObservedMutationLedger(args) {
1210
+ const outcome = await withBounds(() => scanTrackedIncrementally(args, {
1211
+ report: false,
1212
+ deadlineMs: Date.now() + OBSERVED_CAPTURE_BUDGET_MS,
1213
+ }), OBSERVED_CAPTURE_BUDGET_MS * 2, args.signal, { hook: "agent_settled", label: "refreshObservedMutationLedger" });
1214
+ return outcome.ok ? outcome.value.scanned : 0;
1215
+ }