@herbertgao/pi-extensions 2026.9.0 → 2026.9.1

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 (293) 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/@pi-plugins/fast-mode/README.md +5 -3
  11. package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts.map +1 -1
  12. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +20 -55
  13. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  14. package/node_modules/@pi-plugins/fast-mode/package.json +1 -1
  15. package/node_modules/pi-lens/CHANGELOG.md +988 -0
  16. package/node_modules/pi-lens/README.md +3 -0
  17. package/node_modules/pi-lens/config/biome/core.jsonc +11 -2
  18. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +13 -1
  19. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +2 -2
  20. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +927 -80
  21. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +13 -4
  22. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +123 -3
  23. package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +60 -5
  24. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +2 -2
  25. package/node_modules/pi-lens/dist/clients/bash-file-access.js +1 -3
  26. package/node_modules/pi-lens/dist/clients/biome-client.js +9 -2
  27. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +14 -0
  28. package/node_modules/pi-lens/dist/clients/bootstrap.js +509 -73
  29. package/node_modules/pi-lens/dist/clients/bounded-cache.js +152 -12
  30. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +59 -4
  31. package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +113 -0
  32. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +2 -2
  33. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +31 -4
  34. package/node_modules/pi-lens/dist/clients/cache-manager.js +105 -6
  35. package/node_modules/pi-lens/dist/clients/cache-observability.js +44 -18
  36. package/node_modules/pi-lens/dist/clients/cargo-manifest.js +422 -0
  37. package/node_modules/pi-lens/dist/clients/cascade-logger.js +2 -2
  38. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +13 -3
  39. package/node_modules/pi-lens/dist/clients/complexity-client.js +16 -5
  40. package/node_modules/pi-lens/dist/clients/config-core/deny.js +221 -0
  41. package/node_modules/pi-lens/dist/clients/config-core/index.js +47 -0
  42. package/node_modules/pi-lens/dist/clients/config-core/merge.js +357 -0
  43. package/node_modules/pi-lens/dist/clients/config-core/normalize.js +340 -0
  44. package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +248 -0
  45. package/node_modules/pi-lens/dist/clients/config-core/provenance.js +171 -0
  46. package/node_modules/pi-lens/dist/clients/config-core/records.js +218 -0
  47. package/node_modules/pi-lens/dist/clients/config-core/resolve.js +125 -0
  48. package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +78 -0
  49. package/node_modules/pi-lens/dist/clients/config-core/schema.js +167 -0
  50. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +281 -0
  51. package/node_modules/pi-lens/dist/clients/config-locations.js +160 -0
  52. package/node_modules/pi-lens/dist/clients/config-resolve.js +789 -0
  53. package/node_modules/pi-lens/dist/clients/config-schema.js +197 -0
  54. package/node_modules/pi-lens/dist/clients/config-warn.js +407 -0
  55. package/node_modules/pi-lens/dist/clients/dead-code-client.js +19 -14
  56. package/node_modules/pi-lens/dist/clients/dead-code-logger.js +2 -2
  57. package/node_modules/pi-lens/dist/clients/deadline-utils.js +178 -0
  58. package/node_modules/pi-lens/dist/clients/debug-handles.js +2 -2
  59. package/node_modules/pi-lens/dist/clients/debug-heap.js +3 -3
  60. package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +106 -0
  61. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +113 -7
  62. package/node_modules/pi-lens/dist/clients/dependency-checker.js +8 -19
  63. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +12 -9
  64. package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +14 -4
  65. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +162 -18
  66. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +97 -0
  67. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +9 -7
  68. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -1
  69. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +380 -26
  70. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -1
  71. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +0 -1
  72. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -1
  73. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -1
  74. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  75. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -1
  76. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  77. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -1
  78. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -1
  79. package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -1
  80. package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -1
  81. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -1
  82. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +1 -3
  83. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  84. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -1
  85. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -1
  86. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +0 -1
  87. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -1
  88. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -1
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -1
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -1
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -1
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -1
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +13 -18
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -1
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -1
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +7 -4
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -1
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +0 -1
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +1 -3
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +0 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -1
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +0 -1
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -1
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -1
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -1
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -1
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -1
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -1
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -1
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +194 -80
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +84 -15
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -1
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +0 -1
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -1
  121. package/node_modules/pi-lens/dist/clients/disposition-logger.js +3 -3
  122. package/node_modules/pi-lens/dist/clients/effective-config.js +403 -0
  123. package/node_modules/pi-lens/dist/clients/error-class.js +23 -0
  124. package/node_modules/pi-lens/dist/clients/event-loop-hold.js +274 -0
  125. package/node_modules/pi-lens/dist/clients/extension-log.js +2 -2
  126. package/node_modules/pi-lens/dist/clients/feature-hints.js +2 -1
  127. package/node_modules/pi-lens/dist/clients/file-time.js +6 -1
  128. package/node_modules/pi-lens/dist/clients/file-utils.js +112 -9
  129. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +50 -13
  130. package/node_modules/pi-lens/dist/clients/format-service.js +6 -1
  131. package/node_modules/pi-lens/dist/clients/formatters.js +276 -102
  132. package/node_modules/pi-lens/dist/clients/freshness-cadence.js +17 -0
  133. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +114 -19
  134. package/node_modules/pi-lens/dist/clients/generation-guard.js +4 -9
  135. package/node_modules/pi-lens/dist/clients/git-guard.js +1 -1
  136. package/node_modules/pi-lens/dist/clients/go-client.js +39 -0
  137. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +252 -0
  138. package/node_modules/pi-lens/dist/clients/hashline-anchor.js +424 -0
  139. package/node_modules/pi-lens/dist/clients/hook-budgets.js +76 -0
  140. package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +5 -2
  141. package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
  142. package/node_modules/pi-lens/dist/clients/installer/index.js +71 -52
  143. package/node_modules/pi-lens/dist/clients/instance-reaper.js +104 -230
  144. package/node_modules/pi-lens/dist/clients/instance-registry.js +6 -3
  145. package/node_modules/pi-lens/dist/clients/language-registry.js +595 -0
  146. package/node_modules/pi-lens/dist/clients/latency-logger.js +160 -2
  147. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +13 -0
  148. package/node_modules/pi-lens/dist/clients/lens-config.js +141 -30
  149. package/node_modules/pi-lens/dist/clients/lens-engine.js +30 -37
  150. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +59 -6
  151. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -2
  152. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +3 -1
  153. package/node_modules/pi-lens/dist/clients/lsp/client.js +168 -30
  154. package/node_modules/pi-lens/dist/clients/lsp/config.js +483 -158
  155. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +1 -1
  156. package/node_modules/pi-lens/dist/clients/lsp/edits.js +15 -2
  157. package/node_modules/pi-lens/dist/clients/lsp/index.js +676 -71
  158. package/node_modules/pi-lens/dist/clients/lsp/language.js +18 -169
  159. package/node_modules/pi-lens/dist/clients/lsp/launch.js +2 -1
  160. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +5 -16
  161. package/node_modules/pi-lens/dist/clients/lsp/server.js +284 -147
  162. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +116 -23
  163. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +2 -7
  164. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +9 -2
  165. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +1 -1
  166. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +163 -21
  167. package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +36 -0
  168. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +33 -4
  169. package/node_modules/pi-lens/dist/clients/mcp/session.js +5 -16
  170. package/node_modules/pi-lens/dist/clients/memory-sampler.js +8 -3
  171. package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +2 -4
  172. package/node_modules/pi-lens/dist/clients/module-report.js +20 -45
  173. package/node_modules/pi-lens/dist/clients/mutating-tool.js +651 -0
  174. package/node_modules/pi-lens/dist/clients/mutation-attribution.js +368 -0
  175. package/node_modules/pi-lens/dist/clients/mutation-bridge.js +240 -0
  176. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +247 -20
  177. package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +101 -0
  178. package/node_modules/pi-lens/dist/clients/observed-mutation.js +1215 -0
  179. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +68 -22
  180. package/node_modules/pi-lens/dist/clients/opengrep-config.js +4 -0
  181. package/node_modules/pi-lens/dist/clients/package-manager.js +195 -26
  182. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +359 -80
  183. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +23 -7
  184. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +21 -2
  185. package/node_modules/pi-lens/dist/clients/path-utils.js +428 -13
  186. package/node_modules/pi-lens/dist/clients/persist-debounce.js +8 -1
  187. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +114 -0
  188. package/node_modules/pi-lens/dist/clients/pipeline.js +77 -16
  189. package/node_modules/pi-lens/dist/clients/probe-home-state.js +228 -0
  190. package/node_modules/pi-lens/dist/clients/process-bridge.js +66 -0
  191. package/node_modules/pi-lens/dist/clients/process-snapshot.js +68 -0
  192. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  193. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
  194. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +51 -20
  195. package/node_modules/pi-lens/dist/clients/project-lens-config.js +393 -122
  196. package/node_modules/pi-lens/dist/clients/project-snapshot.js +52 -24
  197. package/node_modules/pi-lens/dist/clients/python-environment.js +217 -0
  198. package/node_modules/pi-lens/dist/clients/python-provenance.js +639 -0
  199. package/node_modules/pi-lens/dist/clients/quiet-window.js +6 -1
  200. package/node_modules/pi-lens/dist/clients/read-bridge.js +13 -21
  201. package/node_modules/pi-lens/dist/clients/read-expansion.js +9 -46
  202. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +15 -4
  203. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +364 -212
  204. package/node_modules/pi-lens/dist/clients/read-guard.js +21 -0
  205. package/node_modules/pi-lens/dist/clients/resource-sampler.js +195 -96
  206. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +152 -109
  207. package/node_modules/pi-lens/dist/clients/review-graph/service.js +55 -3
  208. package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  209. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +10 -74
  210. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +9 -3
  211. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +18 -2
  212. package/node_modules/pi-lens/dist/clients/runtime-context.js +49 -16
  213. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +25 -6
  214. package/node_modules/pi-lens/dist/clients/runtime-session.js +139 -22
  215. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +168 -35
  216. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +797 -243
  217. package/node_modules/pi-lens/dist/clients/runtime-turn.js +1028 -53
  218. package/node_modules/pi-lens/dist/clients/rust-client.js +31 -0
  219. package/node_modules/pi-lens/dist/clients/safe-spawn.js +33 -10
  220. package/node_modules/pi-lens/dist/clients/sanitize.js +7 -1
  221. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +1 -3
  222. package/node_modules/pi-lens/dist/clients/security-scan-client.js +3 -0
  223. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +1 -1
  224. package/node_modules/pi-lens/dist/clients/session-start-observability.js +79 -0
  225. package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +12 -3
  226. package/node_modules/pi-lens/dist/clients/sgconfig.js +6 -1
  227. package/node_modules/pi-lens/dist/clients/skills-resolver.js +105 -0
  228. package/node_modules/pi-lens/dist/clients/smells-rollup.js +2 -2
  229. package/node_modules/pi-lens/dist/clients/string-utils.js +13 -0
  230. package/node_modules/pi-lens/dist/clients/subagent-mode.js +17 -4
  231. package/node_modules/pi-lens/dist/clients/test-runner-client.js +176 -39
  232. package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +239 -0
  233. package/node_modules/pi-lens/dist/clients/tool-definition.js +41 -1
  234. package/node_modules/pi-lens/dist/clients/tool-policy.js +91 -21
  235. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +40 -40
  236. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +86 -48
  237. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +2 -2
  238. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +81 -1
  239. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +51 -46
  240. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  241. package/node_modules/pi-lens/dist/clients/typos-config.js +5 -0
  242. package/node_modules/pi-lens/dist/clients/user-notify.js +6 -2
  243. package/node_modules/pi-lens/dist/clients/widget-state.js +191 -26
  244. package/node_modules/pi-lens/dist/clients/word-index-logger.js +2 -2
  245. package/node_modules/pi-lens/dist/clients/word-index-store.js +20 -5
  246. package/node_modules/pi-lens/dist/clients/word-index.js +243 -31
  247. package/node_modules/pi-lens/dist/clients/workspace-topology.js +8 -1
  248. package/node_modules/pi-lens/dist/clients/zizmor-config.js +3 -0
  249. package/node_modules/pi-lens/dist/index.js +64335 -54300
  250. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +3 -1
  251. package/node_modules/pi-lens/dist/mcp/server.js +127 -5
  252. package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +583 -0
  253. package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +129 -0
  254. package/node_modules/pi-lens/dist/tools/effective-config.js +89 -0
  255. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +95 -24
  256. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +67 -80
  257. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +37 -16
  258. package/node_modules/pi-lens/dist/tools/shared.js +0 -1
  259. package/node_modules/pi-lens/docs/agent-guide.md +64 -1
  260. package/node_modules/pi-lens/docs/configuration.md +222 -0
  261. package/node_modules/pi-lens/docs/dependencies.md +3 -3
  262. package/node_modules/pi-lens/docs/features.md +44 -5
  263. package/node_modules/pi-lens/docs/language-coverage.md +2 -2
  264. package/node_modules/pi-lens/docs/pi-lens-fixer.md +25 -0
  265. package/node_modules/pi-lens/docs/pi-lens-investigator.md +25 -0
  266. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +27 -0
  267. package/node_modules/pi-lens/docs/pi-lens-subagent.md +38 -0
  268. package/node_modules/pi-lens/docs/pi-lens-warden.md +55 -0
  269. package/node_modules/pi-lens/docs/public-api-stability.md +359 -0
  270. package/node_modules/pi-lens/docs/release-qa-baseline.md +182 -0
  271. package/node_modules/pi-lens/docs/subagent-compat.md +110 -30
  272. package/node_modules/pi-lens/docs/tree-sitter_rules_catalog.md +2 -2
  273. package/node_modules/pi-lens/package.json +15 -23
  274. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  275. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  276. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  277. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +192 -1
  278. package/node_modules/pi-lens/scripts/install-selftest.mjs +58 -2
  279. package/node_modules/pi-lens/scripts/lib/skills-predicate.mjs +129 -0
  280. package/node_modules/pi-lens/scripts/rpc-load-check.mjs +3 -1
  281. package/node_modules/pi-web-access/CHANGELOG.md +28 -0
  282. package/node_modules/pi-web-access/README.md +62 -15
  283. package/node_modules/pi-web-access/curator-page.ts +3 -1
  284. package/node_modules/pi-web-access/curator-server.ts +5 -1
  285. package/node_modules/pi-web-access/gemini-search.ts +8 -4
  286. package/node_modules/pi-web-access/github-extract.ts +242 -1
  287. package/node_modules/pi-web-access/index.ts +118 -64
  288. package/node_modules/pi-web-access/mistral-search.ts +281 -0
  289. package/node_modules/pi-web-access/package.json +2 -2
  290. package/node_modules/pi-web-access/perplexity.ts +14 -1
  291. package/node_modules/pi-web-access/utils.ts +23 -6
  292. package/node_modules/pi-web-access/xai-search.ts +96 -33
  293. package/package.json +5 -5
@@ -4,11 +4,14 @@ import * as path from "node:path";
4
4
  import { hashText, normalizeMessage, stableFindingId, } from "./finding-identity.js";
5
5
  import { applyWorkspaceEdit } from "./lsp/edits.js";
6
6
  import { getLSPService } from "./lsp/index.js";
7
- import { normalizeMapKey } from "./path-utils.js";
7
+ import { isUnderDir, normalizeMapKey } from "./path-utils.js";
8
+ import { bounded, combineAbortSignals, withDeadline, } from "./deadline-utils.js";
9
+ import { armDeferredLspWork, awaitDeferredLspWork, registerDeferredLspWork, } from "./deferred-lsp-work.js";
10
+ import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
8
11
  import { recordLspMutationBatch, } from "./lsp-mutation.js";
9
12
  import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
10
13
  import { logActionableWarningsEvent } from "./actionable-warnings-logger.js";
11
- import { getProjectDataDir } from "./file-utils.js";
14
+ import { displayProjectDataPath, getProjectDataDir } from "./file-utils.js";
12
15
  import { commitDurableStore } from "./durable-store.js";
13
16
  let beforeWarningStateLockForTests = null;
14
17
  /** Test seam for a sibling process commit immediately before lock acquisition. */
@@ -305,10 +308,161 @@ function mergeWarnings(records) {
305
308
  return [...byId.values()].sort((a, b) => a.displayPath.localeCompare(b.displayPath) ||
306
309
  (a.line ?? 0) - (b.line ?? 0));
307
310
  }
311
+ /**
312
+ * #2504 — bounds on the LSP enrichment loop.
313
+ *
314
+ * This function runs on the AWAITED turn_end hook. With `includeLspCodeActions`
315
+ * on and a cold LSP cache it opened every file it was handed and pulled fresh
316
+ * per-file diagnostics serially at ~880 ms each: 147 files, 187 891 ms, for
317
+ * `warnings: 0`. Three bounds, plus a project-root filter (it had opened
318
+ * `~/.claude/plans/*.md` in an LSP client), plus a deferral: when the turn
319
+ * primed NO cache, every file would be a fresh pull, so the whole loop moves
320
+ * off the hook and delivers through the cached channel instead.
321
+ */
322
+ export const ACTIONABLE_WARNINGS_LSP_FILE_CAP = 25;
323
+ export const ACTIONABLE_WARNINGS_LSP_BUDGET_MS = 2_500;
324
+ export const ACTIONABLE_WARNINGS_DEFERRED_BUDGET_MS = 60_000;
325
+ /**
326
+ * #2504 review round 2 (F3): the per-round-trip bound. The batch deadlines
327
+ * above are checked only BETWEEN files, so a single wedged `getDiagnostics`
328
+ * (or `openFile`, or `codeAction`) was unbounded no matter how small the
329
+ * batch budget was. Generous relative to the ~880 ms a real cold pull costs —
330
+ * this is a wedge detector, not a latency target.
331
+ */
332
+ export const ACTIONABLE_WARNINGS_LSP_PULL_TIMEOUT_MS = 10_000;
333
+ /**
334
+ * #2504 review round 3 (S-2): the third bound, per FILE.
335
+ *
336
+ * The wall budgets above are re-checked BETWEEN files only — deliberately, so
337
+ * that a file already opened is finished rather than half-enriched — and the
338
+ * per-round-trip timeout bounds one call. Neither bounds the COUNT of calls
339
+ * one file can demand: a generated or vendored file with hundreds of warnings
340
+ * on modified lines costs one `codeAction` round trip each, all inside a
341
+ * single between-files interval. This caps that fan-out. The abort signal
342
+ * still escapes promptly — `boundedLspCall` checks it on every trip.
343
+ */
344
+ export const ACTIONABLE_WARNINGS_MAX_CODE_ACTIONS_PER_FILE = 25;
345
+ /**
346
+ * The in-flight deferred fresh-pull, if any. Exposed for tests only: the
347
+ * deferral is fire-and-forget by design, and a test asserting that the work
348
+ * still happens off-hook needs a handle to await.
349
+ *
350
+ * #2504 review round 2 (F3): the handle no longer lives here as a bare
351
+ * module-level `let` that a second deferral silently overwrote (leaving the
352
+ * first loop running, untracked and unstoppable) and that nothing ever reset.
353
+ * `clients/deferred-lsp-work.ts` owns the single slot and its abort signal.
354
+ */
355
+ export function _awaitDeferredLspPullForTest() {
356
+ return awaitDeferredLspWork();
357
+ }
358
+ /**
359
+ * Both bounds on ONE LSP round trip (#2504 review round 2, F3), as AGENTS.md
360
+ * requires of any async step in a sweep loop: a per-call timeout AND the abort
361
+ * signal. Resolves `undefined` when either bound wins; the caller reads that
362
+ * as "this file was not checked", never as "this file is clean".
363
+ *
364
+ * #2523 slice 2 folded this onto `bounded()`. It used to be `withDeadline`
365
+ * (deadline only) raced against a hand-built `abortRace` leg carried on the
366
+ * deps — the fifth private spelling of "deadline AND signal".
367
+ *
368
+ * What the fold did NOT fix, stated because the first draft of this PR claimed
369
+ * it did (#2557 review F5): the old shape did not silently degrade any wait.
370
+ * Both construction sites derived the leg from the signal in the same
371
+ * expression (`args.signal ? makeAbortRace(args.signal) : undefined`), so
372
+ * `abortRace` was absent exactly when `signal` was, and the behaviour before
373
+ * and after this fold is identical on that axis. What the fold actually buys
374
+ * is that the two fields which had to AGREE are now one — a pairing invariant
375
+ * that lived in prose is gone rather than enforced — plus the abandonment now
376
+ * reaching the ledger, and the leg's listener now being released per call
377
+ * instead of living as long as the loop's signal does.
378
+ *
379
+ * ## The loop's residual budget is a THIRD bound, and it must not reach the
380
+ * ledger (#2557 review F-A)
381
+ *
382
+ * Round 1 clamped `bounded()`'s own timer to
383
+ * `Math.min(pullTimeoutMs, remainingMs)`, so whenever the LOOP's own wall
384
+ * budget was already low, `bounded()` armed at the shrunken value and — on
385
+ * firing — recorded `hook-await-exceeded` naming THAT value as "the budget".
386
+ * A healthy pull that simply started late in a big batch was reported as
387
+ * exceeding a budget that exists in no configuration (measured: 400 ms
388
+ * pulls, 12 files, "exceeded 65ms budget after 77ms"), and because
389
+ * `recordDegradationOnce` is rising-edge, that benign row could silence a
390
+ * genuinely wedged server later in the same session.
391
+ *
392
+ * `bounded()`'s OWN timer is now always armed at the full `pullTimeoutMs`, so
393
+ * it only ever fires — and only ever records — when a call outlives its own
394
+ * configured per-trip budget (`metadata.budgetMs === pullTimeoutMs`, always).
395
+ * The loop's shrinking residual is enforced SEPARATELY, by racing `bounded()`
396
+ * against `withDeadline(…, { onTimeout: "undefined" })` keyed to the loop's
397
+ * own `deadlineAt` — a deadline-only wait with no ledger connection of its
398
+ * own, the same primitive `bounded()` itself is built to replace when a
399
+ * caller needs BOTH bounds. The still-running `bounded()` call is not
400
+ * cancelled (nothing here can cancel already-started work); if it later
401
+ * genuinely outlives `pullTimeoutMs`, it still records, correctly, on its own
402
+ * clock.
403
+ */
404
+ async function boundedLspCall(call, deps) {
405
+ if (deps.signal?.aborted)
406
+ return undefined;
407
+ // #2504 review round 4 (F2): the loop's wall budget bounds THIS trip too. A
408
+ // round trip that starts with 5 ms of budget left may not run for 10 s just
409
+ // because the per-call timeout says so -- that is how one file held the
410
+ // awaited hook for minutes past a spent batch budget.
411
+ const remainingMs = deps.deadlineAt !== undefined ? deps.deadlineAt - Date.now() : undefined;
412
+ // Returned BEFORE `bounded()` rather than handed to it as a zero budget: a
413
+ // trip that never started did not exceed anything, and recording it as an
414
+ // exceedance would attribute the loop's spent budget to whichever call
415
+ // happened to be next.
416
+ if (remainingMs !== undefined && remainingMs <= 0)
417
+ return undefined;
418
+ const inner = bounded(call().then((value) => ({ value })), {
419
+ // Always the FULL per-trip budget -- never clamped to the loop's own
420
+ // residual. The residual is enforced below, off the ledger.
421
+ ms: deps.pullTimeoutMs,
422
+ // Optional for the in-band caller; `bounded()` reads a missing signal
423
+ // as one that never aborts, so the deadline half stays live.
424
+ signal: deps.signal,
425
+ // From the deps, never a literal: the in-band and deferred loops must
426
+ // key separately (#2557 review F3).
427
+ hook: deps.site.hook,
428
+ label: deps.site.label,
429
+ });
430
+ const boxed = deps.deadlineAt !== undefined
431
+ ? await withDeadline(inner, {
432
+ deadlineAt: deps.deadlineAt,
433
+ onTimeout: "undefined",
434
+ })
435
+ : await inner;
436
+ // Boxed because an LSP pull legitimately resolves `undefined` (no
437
+ // diagnostics for this file), which must stay distinguishable from a bound
438
+ // firing even though both currently mean "not checked" to the caller.
439
+ return boxed?.value;
440
+ }
441
+ /** Positive finite bound, else the default. Guards NaN from env/config. */
442
+ function boundedNumber(value, fallback) {
443
+ return value !== undefined && Number.isFinite(value) && value > 0
444
+ ? value
445
+ : fallback;
446
+ }
308
447
  export async function buildActionableWarningsReport(args) {
309
448
  const cwd = path.resolve(args.cwd);
310
449
  const records = [...args.dispatchWarnings];
311
450
  const lspService = getLSPService();
451
+ // #2504 review round 5 (F2): when each file's LSP observation FINISHED,
452
+ // keyed by normalized path. assembleReport used to stamp every entry with
453
+ // one `new Date()` taken at ASSEMBLY -- which for the deferred loop is the
454
+ // loop's END, so a file read 60 s and 24 files ago claimed to be
455
+ // milliseconds old. applyDeltaFreshnessGate compares that stamp against the
456
+ // file's mtime to catch an out-of-band edit made after the observation, and
457
+ // a stamp that late hands it a window which has already closed. Shared with
458
+ // the deferred closure below, so the in-band entries it carries over keep
459
+ // the stamps their own pass gave them.
460
+ const observedAtByPath = new Map();
461
+ // The conservative stand-in for an entry no LSP pull ever touched (a
462
+ // dispatch-origin warning): the moment this build began. Never LATER than
463
+ // the observation it stands in for, which is the direction that matters --
464
+ // an over-old stamp costs a line number, an over-new one leaks a stale one.
465
+ const buildStartedAt = new Date().toISOString();
312
466
  logActionableWarningsEvent({
313
467
  event: "report_started",
314
468
  sessionId: args.sessionId,
@@ -321,8 +475,27 @@ export async function buildActionableWarningsReport(args) {
321
475
  },
322
476
  });
323
477
  if (args.includeLspCodeActions) {
478
+ const fileCap = Math.floor(boundedNumber(args.lspFileCap, ACTIONABLE_WARNINGS_LSP_FILE_CAP));
479
+ const budgetMs = boundedNumber(args.lspBudgetMs, ACTIONABLE_WARNINGS_LSP_BUDGET_MS);
480
+ // #2504 (1) project-root filter. The worklist this loop is handed had
481
+ // accumulated paths from two other agents' scratchpads, `~/.claude/plans`
482
+ // and `~/.plegma/work` — none of which belong to an LSP client rooted at
483
+ // this project. Rejected before any file read.
484
+ const eligible = [];
485
+ let outsideRoot = 0;
324
486
  for (const file of args.files) {
325
487
  const filePath = path.resolve(cwd, file);
488
+ if (normalizeMapKey(filePath) === normalizeMapKey(cwd) ||
489
+ !isUnderDir(filePath, cwd)) {
490
+ outsideRoot++;
491
+ logActionableWarningsEvent({
492
+ event: "lsp_file_skipped",
493
+ sessionId: args.sessionId,
494
+ filePath,
495
+ metadata: { reason: "outside_project_root" },
496
+ });
497
+ continue;
498
+ }
326
499
  if (!lspService.supportsLSP(filePath)) {
327
500
  logActionableWarningsEvent({
328
501
  event: "lsp_file_skipped",
@@ -332,88 +505,400 @@ export async function buildActionableWarningsReport(args) {
332
505
  });
333
506
  continue;
334
507
  }
335
- // Reuse the cache primed by the dispatch pipeline's touchFile earlier in
336
- // this turn — but only when it is verified current. A second open+wait
337
- // here costs ~1 s/file with the LSP cold, so we pass the hash of the
338
- // current file bytes: getLastKnownDiagnostics returns the entry only if
339
- // it was primed for the SAME content, so a previous turn's diagnostics
340
- // are never served as current. On any miss (no entry, content drift, or
341
- // an entry written without content) we fall through to a fresh read.
342
- let diags;
343
- let lspSource = "cache";
344
- const currentContent = fs.existsSync(filePath)
508
+ eligible.push(filePath);
509
+ }
510
+ if (outsideRoot > 0) {
511
+ args.dbg?.(`actionable_warnings: skipped ${outsideRoot} file(s) outside the project root`);
512
+ }
513
+ // #2504 (2) file cap.
514
+ const capped = eligible.slice(0, fileCap);
515
+ if (capped.length < eligible.length) {
516
+ recordDegradationOnce({
517
+ kind: "actionable-warnings-cap",
518
+ subject: `${cwd}:file-cap`,
519
+ reason: `LSP enrichment capped at ${fileCap} file(s); ${eligible.length - capped.length} modified file(s) were not checked for code actions this turn`,
520
+ });
521
+ }
522
+ // Resolve every file's cache state BEFORE doing any LSP work. This is a
523
+ // read + a sha256 per file (sub-millisecond) and it is what tells us
524
+ // whether the turn primed the cache at all — the difference between a
525
+ // loop that costs nothing and one that costs ~880 ms per file.
526
+ const primed = [];
527
+ const cold = [];
528
+ for (const filePath of capped) {
529
+ // Reuse the cache primed by the dispatch pipeline's touchFile earlier
530
+ // in this turn — but only when it is verified current. A second
531
+ // open+wait here costs ~1 s/file with the LSP cold, so we pass the
532
+ // hash of the current file bytes: getLastKnownDiagnostics returns the
533
+ // entry only if it was primed for the SAME content, so a previous
534
+ // turn's diagnostics are never served as current. On any miss (no
535
+ // entry, content drift, or an entry written without content) the file
536
+ // needs a fresh read.
537
+ const content = fs.existsSync(filePath)
345
538
  ? fs.readFileSync(filePath, "utf-8")
346
539
  : undefined;
347
- const contentHash = currentContent !== undefined
348
- ? createHash("sha256").update(currentContent).digest("hex")
540
+ const contentHash = content !== undefined
541
+ ? createHash("sha256").update(content).digest("hex")
349
542
  : undefined;
350
543
  const cached = contentHash !== undefined
351
544
  ? lspService.getLastKnownDiagnostics(filePath, contentHash)
352
545
  : undefined;
353
- if (cached !== undefined) {
354
- diags = cached;
546
+ (cached !== undefined ? primed : cold).push({
547
+ filePath,
548
+ content,
549
+ cached,
550
+ });
551
+ }
552
+ // #2504 (3) wall budget, raced against the turn's abort signal. Both
553
+ // bounds, per AGENTS.md: neither a cap nor a deadline alone stops a
554
+ // cancelled turn from paying for work nobody is waiting for.
555
+ const deadline = Date.now() + budgetMs;
556
+ const exhausted = () => args.signal?.aborted === true || Date.now() >= deadline;
557
+ const pullTimeoutMs = boundedNumber(args.lspPullTimeoutMs, ACTIONABLE_WARNINGS_LSP_PULL_TIMEOUT_MS);
558
+ // #2504 review round 2 (F3): the in-band loop gets the same per-call
559
+ // bound and the same once-resolved service as the deferred one — the
560
+ // deferral must not be the only path that is bounded.
561
+ const inBandDeps = {
562
+ lspService,
563
+ pullTimeoutMs,
564
+ // #2504 review round 4 (F2). This is the AWAITED hook: the batch
565
+ // budget has to bound what happens INSIDE a file, not only how many
566
+ // files get started.
567
+ deadlineAt: deadline,
568
+ signal: args.signal,
569
+ // This loop IS on the awaited hook, so its exceedances are turn_end's.
570
+ site: { hook: "turn_end", label: "lspEnrichmentRoundTrip" },
571
+ };
572
+ let unchecked = 0;
573
+ const runInBand = async (targets) => {
574
+ for (const target of targets) {
575
+ if (exhausted()) {
576
+ unchecked += targets.length - targets.indexOf(target);
577
+ break;
578
+ }
579
+ records.push(...(await enrichFileFromLsp(cwd, args, target, inBandDeps)));
580
+ // #2504 review round 5 (F2): stamped when THIS file's pull
581
+ // returned, not when the report is assembled.
582
+ observedAtByPath.set(normalizeMapKey(target.filePath), new Date().toISOString());
583
+ }
584
+ };
585
+ // Cached files first: they are free, and whether ANY of them exist is
586
+ // what decides the cold set's fate below.
587
+ await runInBand(primed);
588
+ // #2504 (4) cold-cache deferral. When the turn primed nothing, every
589
+ // remaining file is a full open + diagnostic wait; that whole loop is
590
+ // what held the terminal for 187 s. It still runs — off the awaited
591
+ // hook — and lands in the same `actionable-warnings` cache the in-band
592
+ // report goes to, so the findings reach the agent by the same channel,
593
+ // one turn later at worst.
594
+ if (cold.length > 0 && primed.length === 0) {
595
+ // #2504 review round 2 (F3). The pre-fix loop captured
596
+ // `args.signal` — the COMPLETED turn's `ctx.signal`, which
597
+ // `index.ts` clears from the ambient slot in its `finally`, so it
598
+ // could never fire. `armDeferredLspWork` returns the module slot's
599
+ // live signal; `resetLSPService` fires it, which is how
600
+ // session_shutdown, session_start and the idle reset all reach this
601
+ // loop. The turn's own signal is still folded in so a genuine
602
+ // mid-turn abort counts.
603
+ //
604
+ // #2504 review round 3 (F-A(d)): it returns `undefined` when an
605
+ // EARLIER deferral still holds the slot. The incumbent wins — see
606
+ // `armDeferredLspWork` — and this turn states its loss instead of
607
+ // cancelling a loop that is about to publish.
608
+ const deferredSignal = armDeferredLspWork();
609
+ if (deferredSignal === undefined) {
610
+ incrementDegradationCount({
611
+ kind: "actionable-warnings-cap",
612
+ subject: `${cwd}:deferral-declined`,
613
+ // #2504 review round 4 (F1): the old wording ended "rather than
614
+ // cancel it", which asserted a preservation that did not
615
+ // happen -- the incumbent's report was then discarded whole by
616
+ // the persisted-newer guard. With the per-file merge below it
617
+ // is true, so it now says exactly WHAT survives and what does
618
+ // not.
619
+ reason: `an earlier deferred LSP pull is still running; ${cold.length} file(s) went unchecked for code actions this turn. What IS preserved is the incumbent loop's work: when it lands, its per-file entries are merged into whatever report is persisted then, for every file whose fileSeq has not advanced. This turn's own cold files are the loss, and nothing re-derives them -- the next report is a delta over a different file set`,
620
+ });
621
+ logActionableWarningsEvent({
622
+ event: "lsp_pull_deferral_declined",
623
+ sessionId: args.sessionId,
624
+ metadata: { turnIndex: args.turnIndex, files: cold.length },
625
+ });
626
+ args.dbg?.(`actionable_warnings: an earlier deferred LSP pull still holds the slot — skipping ${cold.length} fresh pull(s) this turn`);
355
627
  }
356
628
  else {
357
- try {
358
- if (currentContent)
359
- await lspService.openFile(filePath, currentContent);
360
- diags = await lspService.getDiagnostics(filePath);
361
- lspSource = "fresh";
362
- }
363
- catch (err) {
364
- args.dbg?.(`actionable_warnings: LSP diagnostics failed for ${filePath}: ${err}`);
629
+ const carried = [...records];
630
+ const deferredArgs = args;
631
+ const loopSignal = combineAbortSignals(args.signal, deferredSignal) ?? deferredSignal;
632
+ const deferredDeps = {
633
+ lspService,
634
+ pullTimeoutMs,
635
+ signal: loopSignal,
636
+ // OFF the hook by construction: this loop is the answer to
637
+ // turn_end's budget, not a spender of it, and it runs on its
638
+ // own ACTIONABLE_WARNINGS_DEFERRED_BUDGET_MS deadline. Charging
639
+ // its exceedances to `turn_end` would make the hook look over
640
+ // budget for having correctly deferred the work (#2557 F3).
641
+ site: {
642
+ hook: "off_hook",
643
+ label: "deferredLspEnrichmentRoundTrip",
644
+ },
645
+ };
646
+ const deferredWork = (async () => {
647
+ // Yield a full macrotask first. Without this the loop would run
648
+ // its first open+pull inside the awaited call's own microtask
649
+ // drain — "deferred" only on paper, and still on the hook.
650
+ await new Promise((resolve) => setTimeout(resolve, 0));
651
+ const deferredRecords = [...carried];
652
+ const deferredDeadline = Date.now() + ACTIONABLE_WARNINGS_DEFERRED_BUDGET_MS;
653
+ // #2504 review round 4 (F2): set here, not at construction --
654
+ // the deferred loop's budget starts when the loop does, one
655
+ // macrotask after these deps were built.
656
+ deferredDeps.deadlineAt = deferredDeadline;
657
+ let deferredUnchecked = 0;
658
+ let abortedMidLoop = false;
659
+ for (const target of cold) {
660
+ if (loopSignal.aborted || Date.now() >= deferredDeadline) {
661
+ abortedMidLoop = loopSignal.aborted;
662
+ deferredUnchecked += cold.length - cold.indexOf(target);
663
+ break;
664
+ }
665
+ deferredRecords.push(...(await enrichFileFromLsp(cwd, deferredArgs, target, deferredDeps)));
666
+ // #2504 review round 5 (F2). This is the loop the defect
667
+ // was about: it can run for a minute, and one stamp taken
668
+ // at its end described all of it.
669
+ observedAtByPath.set(normalizeMapKey(target.filePath), new Date().toISOString());
670
+ }
671
+ if (deferredUnchecked > 0) {
672
+ recordDegradationOnce({
673
+ kind: "actionable-warnings-cap",
674
+ subject: `${cwd}:deferred-budget`,
675
+ reason: `deferred LSP enrichment stopped early; ${deferredUnchecked} file(s) were not checked for code actions`,
676
+ });
677
+ }
678
+ // An ABORTED loop delivers nothing (#2504 review round 2, F3).
679
+ // The service it was reading is gone — session_shutdown,
680
+ // session_start, or the idle reset retired it — so its partial
681
+ // record set describes nothing current, and publishing it would
682
+ // be exactly the stale-clobber F2 guards against on the other
683
+ // side.
684
+ if (abortedMidLoop || loopSignal.aborted) {
685
+ logActionableWarningsEvent({
686
+ event: "lsp_pull_aborted",
687
+ sessionId: deferredArgs.sessionId,
688
+ metadata: {
689
+ turnIndex: deferredArgs.turnIndex,
690
+ unchecked: deferredUnchecked,
691
+ },
692
+ });
693
+ return;
694
+ }
695
+ deferredArgs.onDeferredReport?.(assembleReport(cwd, deferredArgs, deferredRecords, {
696
+ observedAtByPath,
697
+ fallbackObservedAt: buildStartedAt,
698
+ }));
699
+ })().catch((err) => {
700
+ args.dbg?.(`actionable_warnings: deferred LSP pull failed: ${err}`);
701
+ });
702
+ registerDeferredLspWork(deferredSignal, deferredWork);
703
+ logActionableWarningsEvent({
704
+ event: "lsp_pull_deferred",
705
+ sessionId: args.sessionId,
706
+ metadata: { turnIndex: args.turnIndex, files: cold.length },
707
+ });
708
+ args.dbg?.(`actionable_warnings: no LSP cache primed this turn — deferring ${cold.length} fresh pull(s) off the turn_end hook`);
709
+ }
710
+ }
711
+ else {
712
+ await runInBand(cold);
713
+ }
714
+ if (unchecked > 0) {
715
+ recordDegradationOnce({
716
+ kind: "actionable-warnings-cap",
717
+ subject: `${cwd}:wall-budget`,
718
+ reason: `LSP enrichment hit its ${Math.round(budgetMs)}ms turn budget; ${unchecked} file(s) were not checked for code actions this turn`,
719
+ });
720
+ }
721
+ }
722
+ return assembleReport(cwd, args, records, {
723
+ observedAtByPath,
724
+ fallbackObservedAt: buildStartedAt,
725
+ });
726
+ }
727
+ /**
728
+ * Enrich one file's LSP warnings into records. Split out of
729
+ * `buildActionableWarningsReport` (#2504) so the in-band loop and the deferred
730
+ * off-hook loop run byte-identical logic — the deferral must not become a
731
+ * second, drifting copy of the enrichment.
732
+ */
733
+ async function enrichFileFromLsp(cwd, args, target, deps) {
734
+ const { lspService } = deps;
735
+ const { filePath } = target;
736
+ const out = [];
737
+ let diags;
738
+ let lspSource = "cache";
739
+ if (target.cached !== undefined) {
740
+ diags = target.cached;
741
+ }
742
+ else {
743
+ // #2504 review round 2 (F3): never OPEN a file once the loop has been
744
+ // signalled. The checks inside `boundedLspCall` cover the round trips;
745
+ // this one covers the decision to touch the file at all, which is what
746
+ // makes a session_shutdown landing mid-loop a no-op rather than one
747
+ // more document handed to a service that is being torn down.
748
+ if (deps.signal?.aborted) {
749
+ logActionableWarningsEvent({
750
+ event: "lsp_file_skipped",
751
+ sessionId: args.sessionId,
752
+ filePath,
753
+ metadata: { reason: "aborted" },
754
+ });
755
+ return out;
756
+ }
757
+ try {
758
+ if (target.content) {
759
+ // #2504 review round 3 (F-B), the #240 shape. `openFile` resolves
760
+ // `void`, so the bounded call cannot distinguish success from
761
+ // either bound winning unless the success path returns a value of
762
+ // its own. Round 2 discarded the result entirely: a 10 s timeout
763
+ // or an abort "succeeded" silently, and the pull below then asked
764
+ // the server about a document it had never received. The `[]` that
765
+ // answers means UNKNOWN, but it was recorded
766
+ // `lsp_file_checked lspSource:"fresh"` — a failed pull read as
767
+ // clean, which is precisely what the comment on the pull forbids.
768
+ const opened = await boundedLspCall(async () => {
769
+ await lspService.openFile(filePath, target.content);
770
+ return true;
771
+ }, deps);
772
+ if (opened === undefined) {
365
773
  logActionableWarningsEvent({
366
774
  event: "lsp_file_skipped",
367
775
  sessionId: args.sessionId,
368
776
  filePath,
369
- metadata: { reason: "lsp_error", error: String(err) },
777
+ metadata: {
778
+ reason: deps.signal?.aborted ? "aborted" : "open_timeout",
779
+ pullTimeoutMs: deps.pullTimeoutMs,
780
+ },
370
781
  });
371
- continue;
782
+ return out;
372
783
  }
373
784
  }
374
- const ranges = args.modifiedRangesByFile.get(normalizeMapKey(filePath)) ?? [];
375
- const diagsWarning = diags.filter((d) => d.severity === 2);
376
- let deltaFiltered = 0;
377
- let enriched = 0;
378
- for (const diag of diagsWarning) {
379
- const line = diag.range.start.line + 1;
380
- if (args.deltaOnly !== false && !lineInModifiedRanges(line, ranges)) {
381
- deltaFiltered++;
382
- continue;
383
- }
384
- const record = recordFromLspDiagnostic(diag, filePath, cwd);
385
- try {
386
- const actions = await lspService.codeAction(filePath, diag.range.start.line, diag.range.start.character, diag.range.end.line, diag.range.end.character);
387
- record.actions = actions.map(serializeAction).slice(0, 5);
388
- }
389
- catch (err) {
390
- args.dbg?.(`actionable_warnings: LSP codeAction failed for ${filePath}: ${err}`);
391
- }
392
- if (record.actions.length > 0) {
393
- records.push(record);
394
- enriched++;
395
- }
785
+ const pulled = await boundedLspCall(() => lspService.getDiagnostics(filePath), deps);
786
+ if (pulled === undefined) {
787
+ // Either bound won. A failed pull is NEVER read as clean (#240):
788
+ // the file is reported unchecked and contributes no records.
789
+ logActionableWarningsEvent({
790
+ event: "lsp_file_skipped",
791
+ sessionId: args.sessionId,
792
+ filePath,
793
+ metadata: {
794
+ reason: deps.signal?.aborted ? "aborted" : "pull_timeout",
795
+ pullTimeoutMs: deps.pullTimeoutMs,
796
+ },
797
+ });
798
+ return out;
396
799
  }
800
+ diags = pulled;
801
+ lspSource = "fresh";
802
+ }
803
+ catch (err) {
804
+ args.dbg?.(`actionable_warnings: LSP diagnostics failed for ${filePath}: ${err}`);
397
805
  logActionableWarningsEvent({
398
- event: "lsp_file_checked",
806
+ event: "lsp_file_skipped",
399
807
  sessionId: args.sessionId,
400
808
  filePath,
401
- metadata: {
402
- diagsTotal: diags.length,
403
- diagsWarning: diagsWarning.length,
404
- deltaFiltered,
405
- enriched,
406
- modifiedRangesCount: ranges.length,
407
- lspSource,
408
- },
809
+ metadata: { reason: "lsp_error", error: String(err) },
409
810
  });
811
+ return out;
410
812
  }
411
813
  }
814
+ const ranges = args.modifiedRangesByFile.get(normalizeMapKey(filePath)) ?? [];
815
+ const diagsWarning = diags.filter((d) => d.severity === 2);
816
+ let deltaFiltered = 0;
817
+ let enriched = 0;
818
+ let actionPulls = 0;
819
+ let actionCapped = 0;
820
+ let budgetStopped = 0;
821
+ for (const diag of diagsWarning) {
822
+ // #2504 review round 4 (F2). The wall budget used to be re-checked
823
+ // BETWEEN files only, so a single file could keep the AWAITED hook for
824
+ // openFile + getDiagnostics + up to 25 codeAction round trips long after
825
+ // the batch budget expired. The loop deadline is threaded through deps
826
+ // and re-read HERE, between round trips: a file already opened still
827
+ // finishes its cheap work, but it cannot buy more LSP time.
828
+ if (deps.deadlineAt !== undefined && Date.now() >= deps.deadlineAt) {
829
+ budgetStopped = diagsWarning.length - diagsWarning.indexOf(diag);
830
+ break;
831
+ }
832
+ const line = diag.range.start.line + 1;
833
+ if (args.deltaOnly !== false && !lineInModifiedRanges(line, ranges)) {
834
+ deltaFiltered++;
835
+ continue;
836
+ }
837
+ // #2504 review round 3 (S-2): bound the per-file codeAction fan-out.
838
+ if (actionPulls >= ACTIONABLE_WARNINGS_MAX_CODE_ACTIONS_PER_FILE) {
839
+ actionCapped++;
840
+ continue;
841
+ }
842
+ actionPulls++;
843
+ const record = recordFromLspDiagnostic(diag, filePath, cwd);
844
+ try {
845
+ const actions = await boundedLspCall(() => lspService.codeAction(filePath, diag.range.start.line, diag.range.start.character, diag.range.end.line, diag.range.end.character), deps);
846
+ record.actions = (actions ?? []).map(serializeAction).slice(0, 5);
847
+ }
848
+ catch (err) {
849
+ args.dbg?.(`actionable_warnings: LSP codeAction failed for ${filePath}: ${err}`);
850
+ }
851
+ if (record.actions.length > 0) {
852
+ out.push(record);
853
+ enriched++;
854
+ }
855
+ }
856
+ if (actionCapped > 0) {
857
+ incrementDegradationCount({
858
+ kind: "actionable-warnings-cap",
859
+ subject: `${cwd}:code-action-fanout`,
860
+ // #2504 review round 4 (S-2): "reported without fix actions" was
861
+ // wrong in both halves. A capped warning is skipped BEFORE its record
862
+ // is built, and a record with no action is dropped below anyway, so
863
+ // it is not reported at all -- it never reaches the agent on this
864
+ // channel.
865
+ reason: `a file exceeded the ${ACTIONABLE_WARNINGS_MAX_CODE_ACTIONS_PER_FILE}-warning code-action cap; ${actionCapped} warning(s) in ${toRunnerDisplayPath(cwd, filePath)} were NOT reported this turn`,
866
+ });
867
+ }
868
+ if (budgetStopped > 0) {
869
+ incrementDegradationCount({
870
+ kind: "actionable-warnings-cap",
871
+ subject: `${cwd}:in-file-budget`,
872
+ reason: `the LSP enrichment wall budget expired part-way through a file; ${budgetStopped} warning(s) in ${toRunnerDisplayPath(cwd, filePath)} were NOT checked for fix actions`,
873
+ });
874
+ }
875
+ logActionableWarningsEvent({
876
+ event: "lsp_file_checked",
877
+ sessionId: args.sessionId,
878
+ filePath,
879
+ metadata: {
880
+ diagsTotal: diags.length,
881
+ diagsWarning: diagsWarning.length,
882
+ deltaFiltered,
883
+ enriched,
884
+ actionCapped,
885
+ budgetStopped,
886
+ modifiedRangesCount: ranges.length,
887
+ lspSource,
888
+ },
889
+ });
890
+ return out;
891
+ }
892
+ /**
893
+ * Assemble the report from a record set. Called once for the in-band report
894
+ * and again, off-hook, when the deferred fresh pull completes (#2504).
895
+ */
896
+ function assembleReport(cwd, args, records, stamps) {
412
897
  const merged = mergeWarnings(records);
413
898
  updateWarningState(cwd, merged);
414
899
  // legacyId is #1816 migration bookkeeping for updateWarningState above —
415
900
  // strip it before the report leaves this function, so it never lands in
416
- // the `.pi-lens/cache/actionable-warnings.json` cache file or any
901
+ // the `<project-data-dir>/cache/actionable-warnings.json` cache file or any
417
902
  // agent-facing rendering of a warning record.
418
903
  const reportWarnings = merged.map(({ legacyId: _legacyId, ...rest }) => rest);
419
904
  const byFile = new Map();
@@ -422,36 +907,32 @@ export async function buildActionableWarningsReport(args) {
422
907
  arr.push(warning);
423
908
  byFile.set(warning.filePath, arr);
424
909
  }
910
+ // #2504 review round 4 (F1): every entry carries the moment it was
911
+ // assembled, so a report that later absorbs a deferred pull's entries can
912
+ // still age each half honestly. For a report built in one pass they are all
913
+ // the report stamp.
914
+ const generatedAt = new Date().toISOString();
425
915
  const files = [...byFile.entries()].map(([filePath, warnings]) => ({
426
916
  filePath,
427
917
  displayPath: toRunnerDisplayPath(cwd, filePath),
428
918
  fileSeq: args.fileSeqByPath?.get(normalizeMapKey(filePath)),
919
+ // #2504 review round 5 (F2): the moment THIS file was observed. The
920
+ // report-level stamp remains the assembly moment and is only the
921
+ // last-resort fallback, for a caller that supplied no observations
922
+ // at all.
923
+ generatedAt: stamps?.observedAtByPath.get(normalizeMapKey(filePath)) ??
924
+ stamps?.fallbackObservedAt ??
925
+ generatedAt,
429
926
  warnings,
430
927
  }));
431
- const allActions = merged.flatMap((warning) => warning.actions);
432
- const unsuppressed = merged.filter((warning) => !warning.suppressed);
433
- const countTier = (tier) => unsuppressed.filter((warning) => warning.severity === tier).length;
434
- const summary = {
435
- warnings: merged.length,
436
- unsuppressed: unsuppressed.length,
437
- byTier: {
438
- warning: countTier("warning"),
439
- info: countTier("info"),
440
- hint: countTier("hint"),
441
- },
442
- suppressed: merged.filter((warning) => warning.suppressed).length,
443
- files: files.length,
444
- actions: allActions.length,
445
- autoFixEligible: allActions.filter((action) => action.autoFixEligible)
446
- .length,
447
- };
928
+ const summary = summarizeReportFiles(files);
448
929
  logActionableWarningsEvent({
449
930
  event: "report_complete",
450
931
  sessionId: args.sessionId,
451
932
  metadata: { turnIndex: args.turnIndex, summary },
452
933
  });
453
934
  return {
454
- generatedAt: new Date().toISOString(),
935
+ generatedAt,
455
936
  scope: "turn_delta",
456
937
  sessionId: args.sessionId,
457
938
  turnIndex: args.turnIndex,
@@ -463,9 +944,356 @@ export async function buildActionableWarningsReport(args) {
463
944
  summary,
464
945
  };
465
946
  }
466
- export function writeActionableWarningsReport(cacheManager, cwd, report) {
947
+ /**
948
+ * The report summary, derived from the per-file entries.
949
+ *
950
+ * ONE derivation (#2504 review round 4, F1): a merged report's summary has to
951
+ * describe the merged file set, and a second hand-rolled tally beside
952
+ * assembleReport's would be the mirrored-registry defect AGENTS.md names.
953
+ * Every warning belongs to exactly one file entry, so summing over entries and
954
+ * summing over the flat record list give the same numbers.
955
+ */
956
+ function summarizeReportFiles(files) {
957
+ const warnings = files.flatMap((file) => file.warnings);
958
+ const unsuppressed = warnings.filter((warning) => !warning.suppressed);
959
+ const allActions = warnings.flatMap((warning) => warning.actions);
960
+ const countTier = (tier) => unsuppressed.filter((warning) => warning.severity === tier).length;
961
+ return {
962
+ warnings: warnings.length,
963
+ unsuppressed: unsuppressed.length,
964
+ byTier: {
965
+ warning: countTier("warning"),
966
+ info: countTier("info"),
967
+ hint: countTier("hint"),
968
+ },
969
+ suppressed: warnings.filter((warning) => warning.suppressed).length,
970
+ files: files.length,
971
+ actions: allActions.length,
972
+ autoFixEligible: allActions.filter((action) => action.autoFixEligible)
973
+ .length,
974
+ };
975
+ }
976
+ /**
977
+ * The ONE place an actionable-warnings report reaches disk.
978
+ *
979
+ * Deliberately NOT exported (#2504 review round 5, F1). Two writers used to
980
+ * publish to this key: the in-band turn_end report, which OVERWROTE blindly,
981
+ * and the deferred off-hook report, which read-modify-wrote. A deferred merge
982
+ * that landed anywhere inside turn N+1's handleTurnEnd -- the cascade settle,
983
+ * knip, madge, the test batch, the in-band LSP enrichment; a window seconds
984
+ * wide, measured at 607 ms in the reviewer's trace -- was erased by that
985
+ * turn's blind write, and the findings the whole deferral exists to deliver
986
+ * were gone with it. A blind writer and a merging writer on one key can only
987
+ * ever be a race. There is now one publisher and it always merges; the private
988
+ * scope is what keeps it that way.
989
+ */
990
+ function writeActionableWarningsReport(cacheManager, cwd, report) {
467
991
  cacheManager.writeCache("actionable-warnings", report, cwd);
468
992
  }
993
+ /**
994
+ * Merge a report about to be published into whatever is already persisted,
995
+ * PER FILE.
996
+ *
997
+ * #2504 review round 4 (F1) established the shape: a report is a MAP of
998
+ * per-file entries, each stamped with that file's fileSeq and its own
999
+ * observation time, so ordering belongs PER FILE and not to the report as a
1000
+ * whole. Rounds 2 and 3 ordered whole reports -- publish, or discard on a
1001
+ * newer turnIndex/projectSeqEnd -- and that composed with incumbent-wins into
1002
+ * "publish nothing".
1003
+ *
1004
+ * #2504 review round 5 (F1) generalized it to BOTH publishers, because the
1005
+ * in-band write was still blind and erased a deferred merge that landed
1006
+ * mid-turn. The two directions are asymmetric and the asymmetry is the whole
1007
+ * design:
1008
+ *
1009
+ * - A DEFERRED publish carries the OLDER observation. Everything persisted is
1010
+ * newer and is kept unconditionally; the deferred entries upsert into it
1011
+ * wherever their file has not moved.
1012
+ * - An IN-BAND publish carries the NEWER observation. It is this turn's own
1013
+ * report, and its identity (turnIndex, projectSeq window, deltaOnly) is
1014
+ * published untouched. From the persisted report it carries forward ONLY
1015
+ * entries a deferral produced -- the scope guard, see
1016
+ * {@link ActionableWarningsReportFile.origin}. Carrying everything would
1017
+ * accumulate every prior turn's findings into a report whose scope field
1018
+ * says "turn_delta"; carrying nothing is the F1 defect.
1019
+ *
1020
+ * Where both halves hold a file the warnings are UNIONED through mergeWarnings
1021
+ * (the same de-duplicating merge the dispatch/LSP union already uses), so a
1022
+ * newer entry is never REPLACED by an older one -- it only gains what the
1023
+ * older one found -- and the merged entry is aged by the OLDER of the two
1024
+ * stamps, because it now holds both observations.
1025
+ *
1026
+ * Exported because the merge, not the write, is what has to be pinned: a test
1027
+ * can hand it two reports and read the ordering decision directly.
1028
+ */
1029
+ export function mergeActionableWarningsReports(args) {
1030
+ const { persisted, incoming, origin } = args;
1031
+ const deferredPublish = origin === "deferred";
1032
+ // The newer half wins identity and fileSeq; the older half is folded in.
1033
+ // #2504 review round 6 (F1) removed a same-sessionId gate here, reasoning
1034
+ // that the fileSeq equality check below subsumed it: pi's telemetry
1035
+ // sessionId is STABLE across a quit->resume (setSessionLifecycle pins it,
1036
+ // runtime-coordinator.ts:677-682), but resetForSession clears the live
1037
+ // _fileSeq map for the new process, so a resumed process's getFileSeq
1038
+ // answers 0 for a file a PRE-restart deferral had recorded at a nonzero
1039
+ // seq -- the sessionId gate could not see that (same id, stale data), the
1040
+ // equality check could.
1041
+ // #2504 review round 7 (F4): that reasoning breaks at fileSeq 0. A file
1042
+ // THIS process has never touched also answers getFileSeq 0 (a fresh
1043
+ // RuntimeCoordinator's map starts empty), and an entry a DIFFERENT
1044
+ // process stamped at fileSeq 0 can reach the persisted report entirely
1045
+ // unrelated to a resume: `mcp/analyze.ts` and `mcp/session.ts` register
1046
+ // modified ranges through their OWN CacheManager, which never bumps the
1047
+ // extension runtime's `_fileSeq` map, so an MCP-touched file can persist
1048
+ // at seq 0 under a foreign sessionId. `0 !== 0` then reads FALSE and the
1049
+ // entry is carried as if it were this session's own unmoved file. The
1050
+ // sessionId gate is restored ALONGSIDE the equality check, not instead of
1051
+ // it: equality alone catches the resume-reset case (same session, seq
1052
+ // forcibly reset to 0), the sessionId gate alone catches the
1053
+ // foreign-session case (different session, seq coincidentally equal,
1054
+ // often both 0) -- neither subsumes the other.
1055
+ const sessionMismatch = !deferredPublish &&
1056
+ persisted?.sessionId !== undefined &&
1057
+ persisted.sessionId !== incoming.sessionId;
1058
+ const newerHalf = deferredPublish ? (persisted?.files ?? []) : incoming.files;
1059
+ const olderHalf = deferredPublish ? incoming.files : (persisted?.files ?? []);
1060
+ const newerReport = deferredPublish ? persisted : incoming;
1061
+ const olderReport = deferredPublish ? incoming : persisted;
1062
+ const byPath = new Map();
1063
+ for (const entry of newerHalf) {
1064
+ byPath.set(normalizeMapKey(entry.filePath), entry);
1065
+ }
1066
+ const droppedFiles = [];
1067
+ let mergedFiles = 0;
1068
+ for (const entry of olderHalf) {
1069
+ // The scope guard. An in-band publish exists to add THIS turn's
1070
+ // findings; the only thing it owes the persisted report is the deferred
1071
+ // work that would otherwise be erased between the read and the write.
1072
+ // #2504 review round 6 (b): the marker spend (below) means an entry
1073
+ // carried once and then dropped here on the NEXT in-band publish is
1074
+ // expected, not a fault -- traced to dbg (informational) rather than
1075
+ // the degradation ledger (which is for loss the agent didn't cause).
1076
+ if (!deferredPublish && entry.origin !== "deferred") {
1077
+ args.dbg?.(`actionable_warnings: in-band publish dropped ${entry.displayPath || entry.filePath} -- its carry-forward window (from an earlier deferred publish) already closed`);
1078
+ continue;
1079
+ }
1080
+ const key = normalizeMapKey(entry.filePath);
1081
+ const incumbent = byPath.get(key);
1082
+ const liveBaseline = args.getFileSeq !== undefined;
1083
+ const baselineSeq = args.getFileSeq?.(entry.filePath) ?? incumbent?.fileSeq;
1084
+ // #2504 review round 6 (F1): the in-band carry-forward path, when a
1085
+ // LIVE baseline is available, requires EXACT equality rather than
1086
+ // "baseline advanced past it". An unmoved file's live fileSeq always
1087
+ // equals its persisted entry's recorded fileSeq; any mismatch --
1088
+ // higher (edited since) OR lower (a resumed process whose fileSeq map
1089
+ // was cleared, #2504 r6 F1) -- means the entry no longer describes
1090
+ // what is on disk now. The deferred-publish path keeps the coarser
1091
+ // ">" check: a live process's own fileSeq only ever advances, so
1092
+ // "advanced past it" and "mismatched" agree there, and the deferred
1093
+ // loop's entries can legitimately equal a JUST-bumped incumbent seq
1094
+ // it upserts into.
1095
+ const stale = !deferredPublish && liveBaseline
1096
+ ? sessionMismatch ||
1097
+ (typeof entry.fileSeq === "number" && baselineSeq !== entry.fileSeq)
1098
+ : typeof baselineSeq === "number" &&
1099
+ typeof entry.fileSeq === "number" &&
1100
+ baselineSeq > entry.fileSeq;
1101
+ if (stale) {
1102
+ droppedFiles.push(entry.displayPath || entry.filePath);
1103
+ continue;
1104
+ }
1105
+ mergedFiles++;
1106
+ const merged = incumbent
1107
+ ? {
1108
+ ...incumbent,
1109
+ fileSeq: incumbent.fileSeq ?? entry.fileSeq,
1110
+ generatedAt: olderStamp(incumbent.generatedAt ?? newerReport?.generatedAt, entry.generatedAt ?? olderReport?.generatedAt),
1111
+ warnings: mergeWarnings([...incumbent.warnings, ...entry.warnings]),
1112
+ }
1113
+ : { ...entry };
1114
+ if (deferredPublish) {
1115
+ // Mark it, so the next in-band publish carries it forward across the
1116
+ // window in which it would otherwise be overwritten.
1117
+ merged.origin = "deferred";
1118
+ }
1119
+ else {
1120
+ // Carried forward exactly once: the marker is spent here.
1121
+ merged.origin = undefined;
1122
+ }
1123
+ byPath.set(key, merged);
1124
+ }
1125
+ const files = [...byPath.values()];
1126
+ if (!deferredPublish) {
1127
+ // This turn's report, with the rescued entries folded in. Its identity
1128
+ // is published untouched: a carried entry's provenance lives ON the
1129
+ // entry (its own fileSeq and generatedAt), which is exactly why round 4
1130
+ // moved those fields there, and widening turnIndex/projectSeqStart to
1131
+ // span the older half would make the report claim a window it never
1132
+ // observed.
1133
+ return {
1134
+ report: { ...incoming, files, summary: summarizeReportFiles(files) },
1135
+ mergedFiles,
1136
+ droppedFiles,
1137
+ // sessionMismatch is a single flag for the whole call, not per-entry:
1138
+ // once true, every stale check above is forced true by the `||`, so
1139
+ // EITHER every drop in this batch is a session-boundary drop, OR none
1140
+ // are (droppedFiles is then purely fileSeq-mismatch drops).
1141
+ droppedForSessionMismatch: sessionMismatch && droppedFiles.length > 0,
1142
+ };
1143
+ }
1144
+ const base = persisted ?? incoming;
1145
+ return {
1146
+ report: {
1147
+ ...base,
1148
+ // The report-level stamp is only the fallback for entries with none
1149
+ // of their own (a cache file from an older build), so it takes the
1150
+ // NEWER of the two; per-entry stamps carry the real ages.
1151
+ generatedAt: newerStamp(persisted?.generatedAt, incoming.generatedAt) ??
1152
+ base.generatedAt,
1153
+ turnIndex: Math.max(base.turnIndex, incoming.turnIndex),
1154
+ projectSeqStart: minDefined(persisted?.projectSeqStart, incoming.projectSeqStart),
1155
+ // max, so a merged report never claims to be fresher OR staler than
1156
+ // the newest part it holds. checkActionableWarningsReportFresh still
1157
+ // demands exact equality with the live projectSeq and re-checks every
1158
+ // entry's fileSeq, so this widens nothing.
1159
+ projectSeqEnd: maxDefined(persisted?.projectSeqEnd, incoming.projectSeqEnd),
1160
+ includeLspCodeActions: base.includeLspCodeActions || incoming.includeLspCodeActions,
1161
+ files,
1162
+ summary: summarizeReportFiles(files),
1163
+ },
1164
+ mergedFiles,
1165
+ droppedFiles,
1166
+ // The deferred-publish path never sets sessionMismatch (gated by
1167
+ // `!deferredPublish` above), so its drops are always fileSeq-mismatch.
1168
+ droppedForSessionMismatch: false,
1169
+ };
1170
+ }
1171
+ /**
1172
+ * Publish an actionable-warnings report: read what is persisted, merge per
1173
+ * file, write. THE choke point (#2504 review round 5, F1) -- every publisher
1174
+ * goes through it, so no writer can clobber another's entries.
1175
+ *
1176
+ * The read and the merge and the write are one synchronous block, which is
1177
+ * what makes this safe against the deferred callback: node runs no other
1178
+ * continuation between them, so the only interleaving left is publish-vs-
1179
+ * publish, and that is exactly what the merge resolves.
1180
+ */
1181
+ export function publishActionableWarningsReport(cacheManager, cwd, report, opts = {}) {
1182
+ const origin = opts.origin ?? "in-band";
1183
+ // No TTL: "what is already on disk" is a question about ORDERING, not
1184
+ // freshness, so an entry old enough to have expired for a CONSUMER still
1185
+ // has to be merged into rather than clobbered.
1186
+ const persisted = cacheManager.readCache("actionable-warnings", cwd, Number.MAX_SAFE_INTEGER)?.data;
1187
+ const merged = mergeActionableWarningsReports({
1188
+ persisted,
1189
+ incoming: report,
1190
+ origin,
1191
+ getFileSeq: opts.getFileSeq,
1192
+ dbg: opts.dbg,
1193
+ });
1194
+ writeActionableWarningsReport(cacheManager, cwd, merged.report);
1195
+ // The history records what THIS publish OBSERVED, not what it carried: a
1196
+ // merged-in entry is already on the NDJSON from the publish that produced
1197
+ // it, and appending the merged report would duplicate every carried row on
1198
+ // every subsequent turn.
1199
+ appendActionableWarningsHistory(cwd, report);
1200
+ if (merged.mergedFiles > 0) {
1201
+ opts.dbg?.(`actionable_warnings: ${origin} publish merged ${merged.mergedFiles} persisted file entry/entries`);
1202
+ }
1203
+ // #2504 review round 7 (F5): the deferred origin's own loss is already
1204
+ // recorded by {@link writeDeferredActionableWarningsReport}, which calls
1205
+ // through here first. The IN-BAND origin has the same shape of loss --
1206
+ // this turn's own publish carries a carried-forward deferred entry
1207
+ // forward only while its file has not moved, and a file that moved
1208
+ // between the deferral and this publish is dropped -- but nothing counted
1209
+ // or traced it: only the benign, expected scope-guard spend (an entry
1210
+ // whose carry-forward window already closed) got a dbg line. Same
1211
+ // accounting, mirrored for the origin that was silent.
1212
+ if (origin === "in-band" && merged.droppedFiles.length > 0) {
1213
+ // #2504 review round 8 (S2): "changed" is only true on the fileSeq-
1214
+ // mismatch path. A foreign sessionId (a resumed process, or a
1215
+ // different session's write) drops the same carried-forward entries
1216
+ // without any file having moved -- name the actual cause instead of
1217
+ // asserting a change that may not have happened.
1218
+ const cause = merged.droppedForSessionMismatch
1219
+ ? "the publish crossed a session boundary (a different session's write, or a resumed process) before this turn's in-band publish could keep them"
1220
+ : "changed before this turn's in-band publish could keep them";
1221
+ incrementDegradationCount({
1222
+ kind: "actionable-warnings-inband-superseded",
1223
+ subject: `${path.resolve(cwd)}:inband-carry-superseded`,
1224
+ reason: `${merged.droppedFiles.length} carried-forward deferred file entry/entries ${cause} (${merged.droppedFiles.slice(0, 3).join(", ")}${merged.droppedFiles.length > 3 ? ", ..." : ""}); their earlier findings are LOST on this channel rather than published against content that has since moved`,
1225
+ });
1226
+ opts.dbg?.(`actionable_warnings: in-band publish dropped ${merged.droppedFiles.length} superseded carried-forward file entry/entries (${merged.droppedFiles.slice(0, 3).join(", ")}${merged.droppedFiles.length > 3 ? ", ..." : ""})`);
1227
+ }
1228
+ return merged;
1229
+ }
1230
+ /** The earlier of two ISO stamps; either may be missing or unparseable. */
1231
+ function olderStamp(a, b) {
1232
+ if (a === undefined)
1233
+ return b;
1234
+ if (b === undefined)
1235
+ return a;
1236
+ const at = Date.parse(a);
1237
+ const bt = Date.parse(b);
1238
+ if (!Number.isFinite(at))
1239
+ return b;
1240
+ if (!Number.isFinite(bt))
1241
+ return a;
1242
+ return at <= bt ? a : b;
1243
+ }
1244
+ /** The later of two ISO stamps; either may be missing or unparseable. */
1245
+ function newerStamp(a, b) {
1246
+ const older = olderStamp(a, b);
1247
+ if (a === undefined)
1248
+ return b;
1249
+ if (b === undefined)
1250
+ return a;
1251
+ return older === a ? b : a;
1252
+ }
1253
+ function minDefined(a, b) {
1254
+ if (typeof a !== "number")
1255
+ return b;
1256
+ if (typeof b !== "number")
1257
+ return a;
1258
+ return Math.min(a, b);
1259
+ }
1260
+ function maxDefined(a, b) {
1261
+ if (typeof a !== "number")
1262
+ return b;
1263
+ if (typeof b !== "number")
1264
+ return a;
1265
+ return Math.max(a, b);
1266
+ }
1267
+ /**
1268
+ * Publish a DEFERRED off-hook report.
1269
+ *
1270
+ * The deferred fresh-pull loop is stamped with the ORIGINATING turn's
1271
+ * turnIndex/projectSeq and may run for up to
1272
+ * ACTIONABLE_WARNINGS_DEFERRED_BUDGET_MS -- many turns in a busy session. It
1273
+ * must therefore neither overwrite a newer report nor be thrown away because
1274
+ * one exists; see mergeActionableWarningsReports for why replace-or-discard
1275
+ * could only ever discard. Per-file loss is the only loss left, and it is
1276
+ * recorded rather than silent.
1277
+ *
1278
+ * A thin wrapper over the choke point since #2504 review round 5 (F1). It
1279
+ * exists only to own the degradation record for the per-file loss -- the
1280
+ * "declined the write" branches it used to carry are gone, because a publish
1281
+ * that merges has nothing to decline.
1282
+ */
1283
+ export function writeDeferredActionableWarningsReport(args) {
1284
+ const { mergedFiles, droppedFiles } = publishActionableWarningsReport(args.cacheManager, args.cwd, args.report, { origin: "deferred", getFileSeq: args.getFileSeq, dbg: args.dbg });
1285
+ if (droppedFiles.length > 0) {
1286
+ // Bounded and counted: one subject per project, a tally per occurrence.
1287
+ incrementDegradationCount({
1288
+ kind: "actionable-warnings-deferred-superseded",
1289
+ subject: `${path.resolve(args.cwd)}:deferred-file-superseded`,
1290
+ reason: `${droppedFiles.length} file(s) changed while the deferred LSP pull was reading them (${droppedFiles.slice(0, 3).join(", ")}${droppedFiles.length > 3 ? ", ..." : ""}); their warnings are LOST on this channel rather than published against content that has since moved. Every file that did NOT change was merged into the persisted report`,
1291
+ });
1292
+ args.dbg?.(`turn_end: deferred actionable-warnings dropped ${droppedFiles.length} superseded file entry/entries`);
1293
+ }
1294
+ args.dbg?.(`turn_end: deferred actionable-warnings merged ${mergedFiles} file entry/entries into the persisted report`);
1295
+ return { mergedFiles, droppedFiles: droppedFiles.length };
1296
+ }
469
1297
  export function getActionableWarningsHistoryPath(cwd) {
470
1298
  return path.join(getProjectDataDir(cwd), "actionable-warnings.jsonl");
471
1299
  }
@@ -677,7 +1505,18 @@ export async function applyConservativeActionableWarningFixes(args) {
677
1505
  }
678
1506
  return summary;
679
1507
  }
680
- export function formatActionableWarningsAdvisory(report) {
1508
+ /**
1509
+ * The turn-end advisory for this report.
1510
+ *
1511
+ * `cwd` is REQUIRED (#2521): the advisory names where the report lives, and
1512
+ * that location is `getProjectDataDir(cwd)`-resolved, not a constant. It was a
1513
+ * hardcoded `.pi-lens/cache/actionable-warnings.json` — correct only for a
1514
+ * project that already had a legacy `.pi-lens/` directory — so in every other
1515
+ * project the agent's `cat` of the advised path failed while the report sat
1516
+ * unread under `~/.pi-lens/projects/<slug>/cache/`. An OPTIONAL `cwd` would
1517
+ * have kept that bug alive behind a default, so callers must pass it.
1518
+ */
1519
+ export function formatActionableWarningsAdvisory(report, cwd) {
681
1520
  if (report.summary.unsuppressed === 0)
682
1521
  return undefined;
683
1522
  const files = report.files.filter((file) => file.warnings.some((warning) => !warning.suppressed));
@@ -697,12 +1536,20 @@ export function formatActionableWarningsAdvisory(report) {
697
1536
  const tierLine = quiet > 0
698
1537
  ? `${quiet} of those are hint/info tier — style opinions, worth fixing only while you are already in that code.`
699
1538
  : undefined;
1539
+ // #2521: lead with the TOOL route. `lens_diagnostics mode=delta` reads the
1540
+ // same cache this advisory describes, so an agent never needs to know the
1541
+ // store's layout — which is exactly the knowledge the old hardcoded path
1542
+ // got wrong. The resolved file stays as a named fallback for a human
1543
+ // reading the transcript, rendered through the one helper that cannot
1544
+ // disagree with the writer.
1545
+ const reportPath = displayProjectDataPath(cwd, "cache", "actionable-warnings.json");
700
1546
  return [
701
1547
  `🟡 Fixable warnings introduced this turn: ${report.summary.unsuppressed}.${safe}`,
702
1548
  tierLine,
703
- `Details written to .pi-lens/cache/actionable-warnings.json`,
1549
+ "Use lens_diagnostics with mode=delta to inspect these warnings.",
704
1550
  fileList ? `Files:\n${fileList}${more}` : undefined,
705
- "If continuing in these files, read that JSON and resolve warnings that are safe and relevant. Do not apply broad refactors unless requested.",
1551
+ "If continuing in these files, resolve warnings that are safe and relevant. Do not apply broad refactors unless requested.",
1552
+ `Raw report (only if you need the JSON): ${reportPath}`,
706
1553
  ]
707
1554
  .filter(Boolean)
708
1555
  .join("\n");