@herbertgao/pi-extensions 2026.8.4 → 2026.8.5

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 (288) hide show
  1. package/README.md +4 -4
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +10 -2
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +10 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +5 -8
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
  13. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  14. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
  15. package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
  16. package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
  17. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
  18. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +15 -2
  20. package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
  21. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
  22. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
  23. package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
  24. package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
  25. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
  30. package/node_modules/@narumitw/pi-btw/README.md +8 -7
  31. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  32. package/node_modules/@narumitw/pi-btw/src/menu.ts +1 -0
  33. package/node_modules/pi-lens/CHANGELOG.md +2440 -954
  34. package/node_modules/pi-lens/README.md +13 -0
  35. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
  36. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
  37. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
  38. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
  39. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
  40. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
  41. package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
  42. package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
  43. package/node_modules/pi-lens/dist/clients/biome-client.js +29 -25
  44. package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
  45. package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
  46. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
  47. package/node_modules/pi-lens/dist/clients/bus-publish.js +10 -3
  48. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
  49. package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
  50. package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
  51. package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
  52. package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
  53. package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
  54. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
  55. package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
  56. package/node_modules/pi-lens/dist/clients/console-guard-install.js +14 -0
  57. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
  58. package/node_modules/pi-lens/dist/clients/dead-code-client.js +2 -1
  59. package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
  60. package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
  61. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
  62. package/node_modules/pi-lens/dist/clients/dependency-checker.js +347 -75
  63. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +61 -11
  64. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  65. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +63 -20
  66. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
  67. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
  68. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
  69. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
  70. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
  71. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1085 -681
  72. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
  73. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
  74. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
  75. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
  76. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
  77. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
  78. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
  79. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  80. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
  81. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
  82. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
  83. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +14 -6
  84. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
  85. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
  86. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
  87. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
  88. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +277 -26
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  99. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  100. package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
  101. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
  102. package/node_modules/pi-lens/dist/clients/extension-log.js +139 -0
  103. package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
  104. package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
  105. package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
  106. package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
  107. package/node_modules/pi-lens/dist/clients/format-events-publish.js +16 -3
  108. package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
  109. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
  110. package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
  111. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
  112. package/node_modules/pi-lens/dist/clients/git-guard.js +681 -11
  113. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
  114. package/node_modules/pi-lens/dist/clients/go-client.js +2 -1
  115. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +8 -0
  116. package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
  117. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
  118. package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
  119. package/node_modules/pi-lens/dist/clients/installer/index.js +390 -99
  120. package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
  121. package/node_modules/pi-lens/dist/clients/jscpd-client.js +31 -54
  122. package/node_modules/pi-lens/dist/clients/knip-client.js +15 -36
  123. package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
  124. package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
  125. package/node_modules/pi-lens/dist/clients/latency-logger.js +23 -1
  126. package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
  127. package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
  128. package/node_modules/pi-lens/dist/clients/lens-events.js +27 -4
  129. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  130. package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
  131. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +30 -0
  132. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
  133. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +1 -1
  134. package/node_modules/pi-lens/dist/clients/lsp/client.js +493 -48
  135. package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
  136. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +86 -0
  137. package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
  138. package/node_modules/pi-lens/dist/clients/lsp/index.js +1807 -780
  139. package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
  140. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  141. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  142. package/node_modules/pi-lens/dist/clients/lsp/server.js +196 -32
  143. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  144. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
  145. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
  146. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +123 -32
  147. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
  148. package/node_modules/pi-lens/dist/clients/mcp/session.js +216 -8
  149. package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
  150. package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
  151. package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
  152. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
  153. package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
  154. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
  155. package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
  156. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
  157. package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
  158. package/node_modules/pi-lens/dist/clients/pipeline.js +47 -13
  159. package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
  160. package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
  161. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
  162. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
  163. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
  164. package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
  165. package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
  166. package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
  167. package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
  168. package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
  169. package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
  170. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
  171. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
  172. package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
  173. package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
  174. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
  175. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
  176. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
  177. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
  178. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
  179. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
  180. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
  181. package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
  182. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +107 -73
  183. package/node_modules/pi-lens/dist/clients/runtime-context.js +36 -12
  184. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +74 -6
  185. package/node_modules/pi-lens/dist/clients/runtime-session.js +405 -117
  186. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
  187. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +218 -23
  188. package/node_modules/pi-lens/dist/clients/runtime-turn.js +237 -64
  189. package/node_modules/pi-lens/dist/clients/rust-client.js +2 -1
  190. package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
  191. package/node_modules/pi-lens/dist/clients/security-scan-client.js +2 -1
  192. package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
  193. package/node_modules/pi-lens/dist/clients/sg-runner.js +284 -174
  194. package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
  195. package/node_modules/pi-lens/dist/clients/smells-rollup.js +224 -0
  196. package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
  197. package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
  198. package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
  199. package/node_modules/pi-lens/dist/clients/startup-timing.js +1 -1
  200. package/node_modules/pi-lens/dist/clients/test-runner-client.js +38 -8
  201. package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
  202. package/node_modules/pi-lens/dist/clients/tool-policy.js +388 -39
  203. package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
  204. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
  205. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
  206. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
  207. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
  208. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
  209. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
  210. package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
  211. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
  212. package/node_modules/pi-lens/dist/clients/warm-attach.js +33 -13
  213. package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
  214. package/node_modules/pi-lens/dist/clients/word-index.js +391 -64
  215. package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
  216. package/node_modules/pi-lens/dist/index.js +49361 -36888
  217. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
  218. package/node_modules/pi-lens/dist/mcp/server.js +211 -44
  219. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
  220. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +200 -36
  221. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +153 -40
  222. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
  223. package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
  224. package/node_modules/pi-lens/docs/agent-guide.md +2 -2
  225. package/node_modules/pi-lens/docs/agent-tools.md +21 -10
  226. package/node_modules/pi-lens/docs/analysisall.md +192 -0
  227. package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
  228. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +5 -2
  229. package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
  230. package/node_modules/pi-lens/docs/environment-variables.md +44 -0
  231. package/node_modules/pi-lens/docs/fable.md +27 -0
  232. package/node_modules/pi-lens/docs/features.md +31 -2
  233. package/node_modules/pi-lens/docs/globalconfig.md +45 -1
  234. package/node_modules/pi-lens/docs/language-coverage.md +4 -3
  235. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
  236. package/node_modules/pi-lens/docs/mcp.md +37 -4
  237. package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
  238. package/node_modules/pi-lens/docs/settings.md +12 -1
  239. package/node_modules/pi-lens/docs/tools.md +4 -1
  240. package/node_modules/pi-lens/docs/usage.md +18 -0
  241. package/node_modules/pi-lens/package.json +10 -10
  242. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  243. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  244. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  245. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  246. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  247. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  248. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  249. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  250. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  251. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  252. package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  253. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
  254. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +55 -2
  255. package/node_modules/pi-mcp-adapter/CHANGELOG.md +25 -0
  256. package/node_modules/pi-mcp-adapter/README.md +34 -3
  257. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
  258. package/node_modules/pi-mcp-adapter/commands.ts +15 -7
  259. package/node_modules/pi-mcp-adapter/direct-tools.ts +54 -7
  260. package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
  261. package/node_modules/pi-mcp-adapter/index.ts +8 -3
  262. package/node_modules/pi-mcp-adapter/init.ts +29 -5
  263. package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
  264. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  265. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  266. package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
  267. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
  268. package/node_modules/pi-mcp-adapter/metadata-cache.ts +27 -4
  269. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  270. package/node_modules/pi-mcp-adapter/package.json +3 -3
  271. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
  272. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  273. package/node_modules/pi-mcp-adapter/server-manager.ts +36 -7
  274. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  275. package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
  276. package/node_modules/pi-mcp-adapter/tool-metadata.ts +42 -4
  277. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
  278. package/node_modules/pi-mcp-adapter/types.ts +93 -27
  279. package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
  280. package/package.json +11 -12
  281. package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
  282. package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
  283. package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
  284. package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
  285. package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
  286. package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
  287. package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
  288. package/node_modules/pi-lens/dist/clients/types.js +0 -11
@@ -8,8 +8,1513 @@ All notable changes to pi-lens will be documented in this file.
8
8
 
9
9
  ### Changed
10
10
 
11
+ ### Deprecated
12
+
13
+ ### Removed
14
+
11
15
  ### Fixed
12
16
 
17
+ ### Security
18
+
19
+ ## [4.0.0] - 2026-08-14
20
+
21
+ ### Added
22
+
23
+ - **Bounded LSP pull-failure telemetry and lifecycle test seams (refs [#1292](https://github.com/apmantza/pi-lens/issues/1292), items 1 and 3)** — advertised diagnostic-pull failures retain the latest ten operational failures and surface through LSP status, while unsupported `-32601` responses remain unreported; focused coverage exercises acquisition races, descendant cleanup, shutdown protocol races, and honest push fallback. Strategy-gated `didSave` (item 2) remains out of scope.
24
+
25
+ - **Occupancy and CPU-budget tests stay in the timing-sensitive Vitest project (refs [#1230](https://github.com/apmantza/pi-lens/issues/1230), [#1081](https://github.com/apmantza/pi-lens/issues/1081))** — a meta-test derives the expected membership of `timingSensitiveInclude` from the tests themselves (importing `measureMaxSyncBlockMs` from the perf harness, or calling `process.cpuUsage`) and fails if such a test lands outside the project, so the quiet phased measurement window can't silently drift. It reads the real config object rather than its source text (a commented-out entry is a removal, not a match), and rejects include entries whose file no longer exists so a rename can't leave a dead glob behind. The six sampler tests that had already drifted out are phased back in.
26
+
27
+ - **Claude Code `Stop` hook drives the per-turn pass ([#538](https://github.com/apmantza/pi-lens/issues/538))**. `pi-lens-analyze` gains a warm-server-only turn-end mode (`--turn-end`, or a `Stop` payload on stdin) that runs incremental knip/madge, dependency checks, tests, and actionable-warning aggregation without an explicit `pilens_turn_end` call. PostToolUse records edited files in workspace turn-state and clears inherited pi session stamps before Stop consumes it. The tagged request shares the one-shot workspace IPC endpoint and build-staleness gate with warm analysis. A server-side FIFO orders every analyze and turn-end request, so a timed-out hook cannot let Stop overtake analysis still running in the server or race another turn-end. Missing or incompatible warm servers produce one reason-specific line on stdout and stderr and exit 0 (see the turn-end residuals entry below). `SubagentStop` stays excluded because its edits already feed the shared worklist.
28
+ - **Generic read-recording bridge for co-process extensions ([#1265](https://github.com/apmantza/pi-lens/issues/1265))** — `clients/read-bridge.ts` mounts a `ReadBridge` object at `globalThis[Symbol.for("pi-lens:read-bridge")]` after the runtime initialises. Any co-process Pi extension whose custom tool performs file reads outside pi-lens's normal tracking can call `bridge?.recordRead({ filePath, requestedOffset, requestedLimit })` so that a subsequent `edit` on the same file is not blocked by the read-before-edit guard. The bridge is an advisory, trust-based protocol — it stamps the timestamp itself (`Date.now()`) matching the internal read path, and validates that `filePath` is a non-empty string and offsets/limits are finite positive integers. The bridge respects `no-read-guard`, gitignored paths, and external/vendor paths (consistent with the bash read-guard gate). Registration is a factory-scoped singleton (`_readBridgeRegistered` guard, same pattern as `_turnSummaryEmitRegistered`); `_readBridgeGetFlag` is refreshed on every factory activation so flag changes take effect immediately. Absent bridge is safe — producers guard with `bridge?.recordRead()`.
29
+
30
+ - **tflint respects the project's linter policy (refs #1117)** — every other
31
+ dispatch runner gates on `getLinterPolicyForCwd` before spawning; tflint was
32
+ the one that never consulted it, so a project that elected a different
33
+ terraform linter got tflint's findings on top of its own. It now skips when
34
+ policy does not prefer it, matching golangci-lint and terragrunt. A project
35
+ `.tflint.hcl` is also an explicit opt-in, so `hasTflintConfig` promotes the
36
+ `.tf`/`.tfvars` policy from `smart-default` to `config-first` the same way
37
+ `.golangci.yml` does for Go. tflint ships built-in rules and still runs
38
+ unconfigured, so the no-config default is unchanged. `hasTflintConfig` takes
39
+ the edited file's directory rather than the project cwd, so it agrees with
40
+ what the runner hands tflint: keyed off cwd it would miss every `.tflint.hcl`
41
+ living in a terraform subdirectory, which is the common monorepo layout.
42
+ - **tflint honors a repo-root `.tflint.hcl` (refs #1117)** — tflint resolves
43
+ `.tflint.hcl` from its own working directory and never walks parents (its only
44
+ fallback is `~/.tflint.hcl`), and the runner spawns it from the edited file's
45
+ directory. A config at the repo root therefore governed nothing beneath the
46
+ root: every module under `modules/` or `envs/` was linted with tflint's
47
+ built-in defaults, silently. The runner now walks up for the nearest
48
+ `.tflint.hcl` (via `findNearestDirWithAnyBasename`, so the same home-guarded,
49
+ depth-capped, mtime-invalidated walk every other marker lookup uses) and
50
+ passes it as `--config`. Skipped when `TFLINT_CONFIG_FILE` is set, since
51
+ `--config` outranks the env var in tflint's own precedence and would override
52
+ a deliberate choice. A `.tflint.hcl` that declares an uninitialized plugin
53
+ makes tflint exit non-zero with empty stdout, which the guard fix above turns
54
+ into a SKIPPED rather than a false clean.
55
+ - **Terragrunt linting and formatting** — `terragrunt.hcl` and `root.hcl` now
56
+ get a dedicated `terragrunt` file kind (filename-detected only; a plain
57
+ `.hcl` file stays unmapped). New `terragrunt` dispatch runner wraps
58
+ `terragrunt hcl validate --json`, and a `terragrunt-hcl` formatter wraps
59
+ `terragrunt hcl fmt`. Terragrunt has no LSP, so it's runner/formatter only.
60
+ The `terragrunt` binary installs the same way as tflint (GitHub release,
61
+ bare per-platform binaries). Also added `.tf` (Terraform) to the
62
+ `trivy-config` runner's `appliesTo`, gated behind the existing
63
+ `trivy.enabled` opt-in — terragrunt itself stays excluded from trivy, since
64
+ trivy evaluates the Terraform language, not terragrunt config. The two
65
+ entrypoint filenames live in one exported `TERRAGRUNT_FILENAMES` constant
66
+ (`clients/file-kinds.ts`, same single-source-of-truth pattern as the .NET
67
+ root markers), consumed by kind detection, both tool policies, the formatter,
68
+ and the project/root markers; `tests/clients/terragrunt-filenames.test.ts`
69
+ derives its cases from that list, so a call site that hand-copies it and
70
+ misses a name fails CI. Diagnostic ids carry line, column, and a normalized
71
+ message slug (the actionlint shape), because `hcl validate` reports no rule
72
+ code: a line-only id collapses two findings at one position in the
73
+ dispatcher's dedupe (keyed `filePath:line:column:defectClass:rule||id`) and
74
+ hides a changed finding from delta mode, which keys on `id` alone.
75
+ Diagnostics are attributed by resolving `range.filename` against the unit
76
+ directory and comparing through `pathsEqual`, not by basename: a unit that
77
+ pulls in its parent via `find_in_parent_folders()` gets diagnostics from a
78
+ parent `terragrunt.hcl` whose basename matches the edited file's but whose
79
+ line numbers belong to another file.
80
+ - **Smells self-surfacing (refs #1123 item 3)** — `scripts/analyze-pi-lens-logs.mjs`
81
+ (`npm run logs:smells`) already catalogues a wide set of operational smells,
82
+ but it's MANUAL: the #1123 investigation found 20 stale-ctx `emit_failed`
83
+ rows and 37 opengrep respawns sitting unread in logs for days until an audit
84
+ went looking. `clients/smells-rollup.ts` adds a small ALWAYS-ON rollup
85
+ covering the two smells the issue named, without touching every producer of
86
+ the underlying logs and without re-scanning the size-rotated (up to ~10MB,
87
+ `clients/log-cleanup.ts`) `~/.pi-lens/*.log` files on the `session_start`
88
+ hot path. **Design + cost bound:** a BOUNDED TAIL READ — at most 64KB from
89
+ the end of `bus-events.log` and `latency.log` each (~128KB total I/O per
90
+ check, enforced by construction via a single sized `fs.readSync` at a
91
+ computed offset in `tailReadText`, never a full-file scan) — counts
92
+ stale-ctx `emit_failed` rows (`outcome === "emit_failed"` with an `error`
93
+ containing the SDK's `"stale after session replacement"` fragment,
94
+ `session-lifecycle.ts`'s documented benign-but-worth-watching class) and
95
+ opengrep respawns (`phase === "lsp_server_respawn"` with
96
+ `metadata.serverId === "opengrep"`, `clients/lsp/index.ts`'s existing,
97
+ unmodified respawn log point). Because both source logs are append-only
98
+ NDJSON, the tail is always the most recently written activity, so the same
99
+ bounded read serves as both the cross-session glance and a live/this-session
100
+ proxy — no separate write-time counters were added at the producer call
101
+ sites (kept the change to one new module + three call sites). Both counts
102
+ are gated by trivial threshold constants (`SMELLS_THRESHOLDS`, currently 5
103
+ each) so a single stray event never surfaces. Three surfaces: one
104
+ `session_start` line (only emitted when a threshold trips, via
105
+ `runtime-session.ts`'s `emitSmellsSessionStartLine`), an always-on compact
106
+ `/lens-health` line (current counts regardless of threshold, matching
107
+ #1123 item 2's `formatMemoryHealthLine` style), and a `turn_end` note —
108
+ re-checked every 20 turns (`shouldCheckSmellsThisTurn`), `ctx.ui.notify`d
109
+ at most ONCE per smell per session (`checkSmellsAndNoteOnce`'s gate,
110
+ re-armed by `resetSmellsSessionState()` at the next `session_start`).
111
+
112
+ New tests: `tests/clients/smells-rollup.test.ts` (tail-scan cost bound
113
+ proven by construction — a file far larger than the byte budget only ever
114
+ yields tail content; threshold gating for both the session_start line and
115
+ the always-on health line; the once-per-session notify gate) and
116
+ `tests/index-smells-rollup-wiring.test.ts` (turn_end wiring: nothing before
117
+ the check interval, one notify at the trip turn, no repeat notify on the
118
+ next check turn for an already-notified smell) — fail-then-pass verified
119
+ against a deliberately broken threshold gate.
120
+
121
+ - **Oxfmt formatting support for Svelte (refs #1134)** — `.svelte` is now a
122
+ recognized oxfmt extension, gated by a stricter conditional than oxfmt's
123
+ other extensions. Empirically verified against the real `oxfmt` npm
124
+ package (0.62.0, scratch fixture outside vitest, per this repo's
125
+ verify-the-CLI-contract-empirically rule): oxfmt requires BOTH the
126
+ `svelte` package installed AND the config's `svelte: true` flag enabled —
127
+ either alone exits non-zero ("excluded by ignore rules" or "Cannot find
128
+ module 'svelte/compiler'"), only both together format the file. The new
129
+ `hasOxfmtSvelteConfig` (`clients/tool-policy.ts`) encodes this, consulted
130
+ only for `.svelte` in `formatters.ts`'s `hasExplicitFormatterConfig` — the
131
+ other oxfmt extensions are unaffected. **Class fix**:
132
+ `oxfmtFormatter.extensions` (`clients/formatters.ts`) and
133
+ `OXFMT_SUPPORTED_EXTENSIONS` (`clients/tool-policy.ts`) were two
134
+ hand-maintained parallel lists (the #883 single-source-of-truth class);
135
+ `OXFMT_SUPPORTED_EXTENSIONS` is now exported as the sole source of truth
136
+ and `oxfmtFormatter.extensions` derives from it directly, plus a drift-guard
137
+ test asserting they stay equal. `docs/language-coverage.md`'s Svelte row now
138
+ reports the formatter and its gating condition instead of "—". The real
139
+ format-smoke fixture (`tests/fixtures/format-smoke/`) was NOT extended: no
140
+ fixture in that harness has a dependency-install step (oxfmt itself is only
141
+ found via a global `which` lookup, not `ensureTool`), so a svelte fixture
142
+ would either always skip (oxfmt unavailable) or, worse, hard-fail with a
143
+ "Cannot find module 'svelte/compiler'" error if oxfmt happened to resolve
144
+ without svelte actually installed — a strictly worse risk than omitting it.
145
+ - **Instance health + memory-attribution observability (refs #1123 item 2)** — the #1126 sizing study diagnosed a 1.37 GB pi-lens instance from code + serialized artifacts alone, because nothing recorded a per-subsystem trajectory over time (the same detection-without-attribution gap loop_block had before #1122/#1125). Three pieces:
146
+ 1. **Vanished-instance markers.** `deregisterInstance()` (`clients/instance-registry.ts`) synchronously removes a process's own `instances.json` entry on a clean `session_shutdown`, so an entry whose owning pid is confirmed dead is, by construction, proof that process never reached that shutdown path — no new "clean shutdown" flag is needed, the existing `heartbeatAt`/`rssBytes` fields already ARE the "lastSeen"/"rss" pair this needs (`clients/vanished-instance-marker.ts`). `session_start` now reads the registry and logs one `sessionstart.log` line per such entry — `previous instance pid X last seen <ts> (RSS <y>MB) exited without shutdown` — BEFORE `sweepOrphans()` (`clients/instance-reaper.ts`) prunes those same dead-pid entries; the read is sequenced ahead of the sweep (via `.finally()`) rather than let both fire-and-forget calls race, or the vanished set would already be empty by the time the marker ran.
147
+ 2. **Periodic memory-attribution sample (`clients/memory-sampler.ts`).** Every 10 turns, one `memory_sample` `latency.log` phase line: `process.memoryUsage()` (rss/heapUsed/heapTotal/external/arrayBuffers) plus O(1)/O(bounded-cache-size) per-subsystem counters — review-graph resident workspace-cache entry count + summed node/edge counts (`getReviewGraphWorkspaceCacheSnapshot`, `clients/review-graph/builder.ts`), word-index doc/posting/forward-entry counts (off `runtime.wordIndex`), loaded tree-sitter grammar/parser/query-cache counts + tree-cache size/bytes (`TreeSitterClient.getRuntimeStats`, `clients/tree-sitter-client.ts`), and the dispatch cascade's turn-bounded cache sizes (`getDispatchCascadeCacheStats`, `clients/dispatch/integration.ts`). Every field is a `Map`/array `.size`/`.length` read or a `process.memoryUsage()` call — nothing iterates a large structure's contents, nothing snapshots the heap. **Documented gap vs the #1126 spec:** the WASM linear-memory byte length (`Module.wasmMemory.buffer.byteLength`) is NOT included — inspecting the installed web-tree-sitter 0.25.10 package confirmed that value lives in a private closure (`bindings.ts`'s `Module` singleton) with no public export, and reaching it would require either internal reflection (brittle across versions/bundling) or overriding Emscripten's `wasmMemory` init option with a hand-built `WebAssembly.Memory` (risks a memory-import mismatch breaking ALL structural analysis, for an observability-only feature) — `process.memoryUsage().arrayBuffers` is used as the process-wide proxy instead (WASM linear memory backs an ArrayBuffer, so it's already included there).
148
+ 3. **`/lens-health` memory block**, reusing the same sample: RSS/heap/external plus the tree-sitter cache byte total and review-graph node/edge counts, following #1125's compact single-line health-line style. `instances.json`'s `rssBytes`/`heartbeatAt` refresh cadence needed no new wiring — `runtime-turn.ts`'s existing per-turn `updateHeartbeat()` call (#449 slice 1) already refreshes both every turn, which is what makes marker (1)'s RSS meaningful.
149
+
150
+ New tests: `tests/clients/vanished-instance-marker.test.ts` and `tests/index-vanished-instance-wiring.test.ts` (fail-then-pass: a dead-pid entry logs the marker and is still pruned afterward; a live-pid or already-clean entry logs nothing), `tests/clients/memory-sampler.test.ts` and `tests/index-memory-sample-wiring.test.ts` (cadence assertions: nothing before turn 10, exactly one sample at turn 10 and turn 20, never in between), and an `/lens-health` line assertion in `tests/index-wiring.test.ts`.
151
+
152
+ - **Handle-origin tracer debug flag (refs #1123 item 4)** — institutionalizes the `async_hooks` handle-origin tracer the #1097 print-mode-hang investigation hand-rolled to find a leaked, ref'd `setTimeout` (root-caused/fixed in #1110); the #1097 reporter explicitly asked for a built-in active-resource dump. New `clients/debug-handles.ts`: `PI_LENS_DEBUG_HANDLES=1`, read ONCE at extension load, gates everything — unset (the default), every export is a no-op past that one boolean check (no writer constructed, no `async_hooks` hook installed, zero cost on the hot lifecycle paths that call it). Set at startup, `dumpActiveHandles(label)` writes one ndjson line to `~/.pi-lens/debug-handles.log` (standard `createNdjsonLogger` family — size-bounded, log-cleanup registered) with `process.getActiveResourcesInfo()` counts by resource type, plus per-type creation-site stack attribution from a bounded `async_hooks` tracker (capped at `TRACKER_MAX_ENTRIES`, never unbounded growth even in a timer-storm session) that is itself only installed when the flag was already on before the module first loads (real per-resource-creation overhead, strictly opt-in). **Eviction is protect-the-earliest, not drop-oldest**: a #1097-style leak is typically among the earliest handles created in a session, so a naive drop-oldest policy would let a later burst evict exactly that evidence — the first `TRACKER_PROTECTED_COUNT` insertion-order entries are pinned, eviction targets the oldest entry outside that zone, and a running `evictedCount` is always present on the dump entry once the tracker is installed (even at `0`) so an attribution gap from a burst past the cap is explicit rather than silent. Wired into `index.ts` at two points: `agent_settled` (after the quiet-window work is scheduled — the #1097-class leak is only visible once whatever settle itself queued is in flight) and `session_shutdown` (after teardown — whatever survives IS the leak). New tests: `tests/clients/debug-handles.test.ts` (flag unset → no writes/no tracker; flag set → dump emitted with type counts + creation-site attribution; a 600-entry burst against the 500 cap proves the first `TRACKER_PROTECTED_COUNT` and the most-recent entries both survive with `evictedCount` correctly reported; a real `async_hooks.createHook`-driven test creates and clears a genuine `setTimeout` and asserts it appears in, then is pruned from, a real dump) and two fail-then-pass wiring cases in `tests/index-integration.test.ts` asserting the dump fires strictly after LSP teardown / after quiet-window scheduling at the real `index.ts` seams.
153
+
154
+ - **Source-walk generated-artifact escape hatch (closes #1107, phase 2 of 2)** — three pieces, building on phase 1's
155
+ counters (#1111):
156
+ 1. **Directory-level skip counting.** `shouldRecurseIntoDir`'s
157
+ `isGeneratedArtifactDirectoryName` branch (`clients/source-walker.ts`)
158
+ pruned whole directories (`generated/`, `codegen/`, `__generated__/`, …)
159
+ with zero counting — an entire directory of real files reported zero.
160
+ `SourceCollectionResult` gains `generatedDirSkips`, threaded through the
161
+ same `SourceWalkSkipCounters` seam and the `source_walk_skip_summary`
162
+ log line: one count per PRUNED DIRECTORY, never per file inside it
163
+ (enumerating the contents would defeat the pruning). The
164
+ `SourceCollectionResult` docblock's former "KNOWN GAP" note is now the
165
+ real field doc.
166
+ 2. **Tool-facing surfacing.** `ProjectDiagnosticsSnapshot` gains
167
+ `generatedFileSkips` (the raw skip total, all evidence tiers),
168
+ `generatedNameOnlySkips` (the narrower at-risk subset — see below), and
169
+ `generatedDirSkips` (only present, like `scanTruncated`, on a walk that
170
+ actually ran); `lens-engine.ts`'s new `generatedSkipNotice` renders a
171
+ one-line notice mirroring `scanTruncationNotice`'s (#784) style, wired
172
+ into both `pilens_project_scan` (`mcp/server.ts`) and `lens_diagnostics`
173
+ (`tools/lens-diagnostics.ts`). **Review fix (P1, empirically proven):**
174
+ the notice originally keyed off the raw `generatedFileSkips` total,
175
+ which includes STRONG evidence (lockfiles, declaration files,
176
+ minified/bundle/chunk output — expected on almost every real repo) —
177
+ a repo with just `index.ts` + an ambient `.d.ts` + `package-lock.json`
178
+ showed "2 file(s) excluded" on every single scan, forever. It now keys
179
+ off `generatedNameOnlySkips`: the narrower bucket of WEAK name matches
180
+ trusted with NO corroborating evidence check at all (only reachable
181
+ when a caller opts out of the header probe; the default project-walk
182
+ path always enables it, so this is rare-to-zero in practice — meaning
183
+ the notice is meaningful again when it does fire) plus `generatedDirSkips`
184
+ (directory pruning has no escape hatch, so it stays a genuine
185
+ unverified-content signal). Both `ProjectDiagnosticsScanOptions` and
186
+ both tool surfaces also gain `includeGenerated` (default `false`) so
187
+ the notice's opt-out advice is now actually actionable, not just prose.
188
+ `module_report` has no walk-backed section of its own to thread this
189
+ through (it reads the cached review graph, built asynchronously
190
+ elsewhere) — surfaced in the project-diagnostics path only.
191
+ 3. **Content-probe escape hatch (the actual behavior change).** A file
192
+ matching a generated-artifact NAME pattern but with no other evidence is
193
+ now KEPT instead of silently dropped. `generated-artifacts.ts` splits
194
+ its path check into STRONG evidence (a generated directory segment, a
195
+ lockfile, minified/bundle/chunk output — always conclusive, never
196
+ rescued) and WEAK evidence (the remaining filename-regex patterns, e.g.
197
+ `gen.ts`, `foo_generated.go`); `classifyGeneratedOrArtifactDetailed`
198
+ requires a WEAK match to be corroborated by a generated-code header in
199
+ the first 4 KB (or the sibling-source probe already checked upstream by
200
+ every walk-driven caller) before treating it as an artifact —
201
+ cheapest-first. With NEITHER piece of evidence, the file is KEPT and
202
+ counted under the new `generatedNameOverrides` counter (also in the
203
+ rollup log line), so the heuristic's rescues stay observable.
204
+ **Review fix (P2, maintainer decision):** minified/bundle/chunk output
205
+ (`bar.min.js`, `vendor.bundle.js`, `runtime.chunk.js`) is STRONG tier,
206
+ not WEAK — the escape hatch's evidence checks are structurally dead for
207
+ it (minifiers strip banners, so the header leg never confirms; the
208
+ sibling probe looks for `app.min.ts`, never `app.js` at a different
209
+ stem, so that leg is unreachable too), which would have made it a
210
+ PERMANENT override rather than an occasional rescue. `app.min.js` next
211
+ to `app.js` is skipped unconditionally, like a lockfile.
212
+ Shipped **ungated**: the existing `LENS_FLAGS`/`.pi-lens.json` toggle
213
+ registry is sized for whole-subsystem behavior (lsp/tests/delta/…), not
214
+ a narrow heuristic refinement, and the new `includeGenerated` scan
215
+ option (item 2) already gives callers a full, actionable opt-out.
216
+ Documented tradeoff: a false-KEEP (one extra file an agent must judge)
217
+ is preferred over the prior silent false-DROP (a real file invisibly
218
+ never analyzed) — this changes walk output for review-graph/word-index/
219
+ project-diagnostics/call-graph: a repo with a real `gen.ts` gains
220
+ coverage (intended), and a repo with a headerless generated file with
221
+ no source twin also gains coverage (accepted per the issue). Invariant
222
+ preserved for lockfiles, declaration files, minified/bundle/chunk
223
+ output, and anything the ignore-matcher/extension filter already
224
+ excluded — none of those are ever rescued.
225
+
226
+ - **Machine-wide test-suite lock (closes #1101)** — `npm test` /
227
+ `npm run test:unit` / `npm run test:integration` now route through the new
228
+ `scripts/with-test-lock.mjs` wrapper, which acquires a single machine-wide
229
+ `~/.pi-lens/test-suite.lock` before running vitest and releases it after.
230
+ Concurrent full-suite runs (several agents on parallel worktrees, plus an
231
+ interactive run) previously each spawned a fork pool sized for a dedicated
232
+ machine and fought over CPU/RAM, producing vitest worker-crash cascades and
233
+ timing-budget flakes that looked like real bugs but weren't; they now
234
+ serialize (queue) instead. A waiting run prints a heartbeat line at least
235
+ every 15s so it never looks hung, and — mirroring the installer's
236
+ `.install.lock` — a lock is stale only after its recorded PID is confirmed
237
+ dead, never on a timer alone. `PI_LENS_TEST_NO_LOCK=1` opts out (CI sets it:
238
+ runners are isolated, nothing to serialize against). Lock core lives in
239
+ `scripts/lib/suite-lock.mjs`, unit-tested for acquire/release, dead-PID
240
+ takeover, and real contention (two concurrent acquisitions serialize).
241
+ AGENTS.md documents the lock plus the companion layer-1 policy for agents
242
+ running tests concurrently.
243
+ - **Source-walk skip observability (refs #1107, phase 1 of 2)** — the source
244
+ walk silently dropped real files whose NAMES match generated-artifact
245
+ heuristics (a real `src/gen.ts` was invisible to
246
+ `collectSourceFilesWithBudgetAsync`, with nothing anywhere saying so), an
247
+ invisible coverage hole for review-graph/word-index/project-diagnostics/
248
+ call-graph. This phase adds observability only — it does NOT change which
249
+ files get skipped (a content-probe escape hatch for name-only matches is
250
+ phase 2, tracked on #1107). `SourceCollectionResult` gains two additive
251
+ optional counters, `generatedOrArtifactSkips` (files dropped by the
252
+ generated/artifact NAME heuristic, `isGeneratedOrArtifact`) and
253
+ `buildArtifactSkips` (files dropped by the sibling-source artifact probe,
254
+ `isBuildArtifact`) — separate from extension-filter and ignore-matcher
255
+ skips, which are policy/config driven rather than name/content heuristics.
256
+ Both `collectSourceFilesWithBudget` and `collectSourceFilesWithBudgetAsync`
257
+ (and therefore every consumer that funnels through them — the review-graph
258
+ builder, project-diagnostics scanner, word index, etc.) populate the
259
+ counters and log a one-line rollup through the existing `logLatency`
260
+ channel (`phase: "source_walk_skip_summary"`) whenever either is nonzero; a
261
+ clean walk with no name/artifact-probe skips emits no new log line.
262
+ `tests/clients/graph-cache.test.ts`'s two remaining `gen.ts` fixtures
263
+ (unrelated to the drift-path test, which already used `alpha.ts`) were
264
+ unknowingly exercising empty-walk graph builds — renamed to
265
+ `buildstamp.ts`/`identitycheck.ts` since their assertions only cover
266
+ buildGeneration/cache-mode plumbing, never node/symbol content, so the
267
+ rename is behavior-preserving.
268
+
269
+ - **LSP diagnostics content binding (refs #1095, first PR)** — diagnostics are now
270
+ bound to the document content they were computed against, so a consumer can ask
271
+ "were these diagnostics computed against what's on disk now?" instead of inferring
272
+ staleness purely from mtime/TTL proxies. When a `publishDiagnostics` notification
273
+ echoes a document `version`, the owning client binds the stored diagnostics to a
274
+ content fingerprint captured at didOpen/didChange SEND time (never a disk read on
275
+ the notification path). `getAllDiagnostics`/`touchFile` results and the
276
+ workspace-diagnostics cache lookup expose a `binding`
277
+ `{version?, contentHash?, boundToCurrentDisk: boolean | "unknown"}`, with
278
+ `boundToCurrentDisk` verified lazily against disk (memoized per file+mtime, reading
279
+ with the identical raw-UTF-8 transform the payload was built with, so CRLF/BOM files
280
+ round-trip correctly). Across multiple contributing clients the merged binding is
281
+ `false` if ANY contributor mismatches disk, `"unknown"` if all are unknown, else
282
+ `true`. `tools/lsp-diagnostics.ts` adopts it: a result whose binding demonstrably
283
+ mismatches disk (`boundToCurrentDisk === false`) is demoted to inconclusive — even a
284
+ non-empty result no longer re-cements the footer with a stale view (the #1092
285
+ re-cementing path). Servers that never report a version yield binding `"unknown"` and
286
+ behave exactly as before (zero regression). Binding state (`bound`/`mismatch`/
287
+ `unknown`) is logged in the `lsp_touch_file` latency metadata so an unbinding server
288
+ is diagnosable. Non-goals (stated for follow-ups): cascade adoption in
289
+ `clients/dispatch/integration.ts` (#1094 in flight), pull-diagnostics resultIds,
290
+ widget per-entry timestamps (#1093), aux retag (#1094).
291
+ - **Cascade adopts LSP diagnostics content binding (refs #1095, second PR)** — the
292
+ cross-file cascade (`clients/dispatch/integration.ts`) now consults the `binding`
293
+ from the first PR instead of trusting a snapshot purely on TTL. A passive neighbor
294
+ snapshot whose diagnostics `boundToCurrentDisk === false` (the server's view diverged
295
+ from disk — e.g. the PRE-fix content) is no longer reconciled into the footer widget;
296
+ it falls through to an active touch on the existing cold-snapshot budget. This kills
297
+ the window where the first cascade after a fix-edit replayed a neighbor's stale
298
+ pre-fix snapshot. `"unknown"` bindings (version-less servers) keep EXACTLY the prior
299
+ TTL-only behavior; `true` reconciles (TTL stays the outer bound). On the active-touch
300
+ path, a bound-false result is treated like `inconclusive` — composed with it into one
301
+ `isConfirmedTouch()` predicate so a future flag can't be missed at just one gate — so
302
+ it neither reconciles nor seeds the recently-clean neighbor cache. `observedAt` stays
303
+ the snapshot's publish time (`entry.ts`) for passive reconciles. Binding-rejected
304
+ snapshots/touches are logged through the cascade channel (`bindingState`). The lazy
305
+ binding getter is re-read from a fresh `getAllDiagnostics()` every cascade run (the
306
+ Map is never retained across turns), with a caveat comment guarding future retention.
307
+ - **Project-level rule policy via `.pi-lens.json` `rules.<id>.disable` / `rules.<id>.select`** — a project's own config can now narrow what diagnostics actually surface.
308
+ Filtering is output-only, so the baseline, widget state, and dedup cache stay
309
+ authoritative and a policy edit never corrupts or resets delta tracking.
310
+ Matching is project-wide: the `<id>` key is a grouping label, not a filter
311
+ scope. Every `disable` list across every key unions into one drop list, and
312
+ every `select` list unions into one allowlist, so
313
+ `"security": { "disable": ["no-eval"] }` works without `security` being a real
314
+ rule id. Disable wins over select project-wide, even across different keys.
315
+ A non-empty `select` anywhere silences every rule not listed, project-wide, so
316
+ it is a big hammer (documented as such). Findings that carry no rule id at all
317
+ are exempt from select, since `Diagnostic.id` is a dedup key rather than a rule
318
+ id and measuring it against an allowlist would have silently dropped blocking
319
+ findings like eslint parse errors. A user lists `no-eval` once and the filter
320
+ covers `no-eval`, `ast-grep:no-eval`, and `no-eval-js`, reusing the rule-id
321
+ normalization that `inline-suppressions.ts` and `tools/lens-diagnostics.ts`'s
322
+ dedup already applied, now consolidated in
323
+ `clients/dispatch/rule-id-normalize.ts` so the three surfaces cannot drift.
324
+ That normalization conflates a rule whose real name ends in `-js` with its stem
325
+ (`prefer-js` with `prefer`), which is now pinned by tests and called out in the
326
+ docs. The policy applies on the per-edit dispatch path
327
+ (`clients/dispatch/dispatcher.ts`, resolved from the project root so a
328
+ package-local `.pi-lens.json` in a monorepo cannot shadow the root's policy,
329
+ matching the root `lens_diagnostics` already used) and across the
330
+ `lens_diagnostics` tool's `mode=delta`, `mode=all`, and `mode=full` paths. Each
331
+ loads the policy map once per call, filters after inline suppression and
332
+ disposition, and re-summarizes so the blocking/error/warning counts reflect the
333
+ drop, including delta mode's carried-over tally and mode=full's structured
334
+ `details` counts. Silencing a rule is not the same as fixing it: the stored
335
+ delta baseline is deliberately unfiltered, so dispatch compares against a
336
+ policy-filtered view of it. Otherwise every disabled finding would sit in the
337
+ "fixed" set on every single dispatch and inflate the agent's resolved tally
338
+ (`trackAgentFixed`) for as long as the rule stayed off. In the project-config
339
+ loader (`clients/project-lens-config.ts`) the lists are tolerantly parsed: a
340
+ non-array, an empty array, or an all-whitespace array is logged once and
341
+ dropped, matching the existing threshold-validation warn-once contract.
342
+ Threshold-only entries are unaffected, and policy-only entries (no `threshold`)
343
+ coexist cleanly. A `rules.<key>` whose value is not an object, or whose keys are
344
+ all unrecognized, now warns instead of failing silent. That covers the two
345
+ shapes a user is most likely to reach for first, both of which parse as valid
346
+ JSON and do nothing: the lists written directly under `rules`, and `only`
347
+ instead of `select`. New `tests/clients/dispatch/rule-policy.test.ts` (32 tests:
348
+ matcher, normalization, project-wide grouping, disable-wins-select, the `-js`
349
+ conflation, the rule-less select exemption, and the hot-path fast return),
350
+ `tests/clients/dispatch/rule-policy-dispatcher.test.ts` (13 tests covering
351
+ dispatch integration including monorepo root-versus-nested config resolution,
352
+ baseline integrity, and the resolved-tally guard),
353
+ `tests/clients/dispatch/rule-id-normalize.test.ts` (7 tests), and
354
+ `tests/tools/lens-diagnostics-rule-policy.test.ts` (14 tests covering the three
355
+ mode paths, `select` on the cache-only paths, the delta carried-over tally, the
356
+ project-diagnostics delta report, and mode=full's structured details).
357
+ Docs: `docs/settings.md` and `docs/globalconfig.md` gained the project-wide
358
+ schema, precedence, the select warning, and the `-js` caveat. #533 hygiene is
359
+ preserved, so typo and unknown-key warnings still surface and foreign LSP
360
+ namespaces stay silent.
361
+
362
+ - **Document the standing maintenance-routines menu (invoke-on-request)** — catalog named maintenance sweeps and their safety rails in `AGENTS.md`.
363
+
364
+ - **Ast-grep dogfood rules for raw JSON store writes and win32 path qualification (refs [#1158](https://github.com/apmantza/pi-lens/issues/1158))** — flag direct JSON store writes and `win32.isAbsolute` qualification calls, with the atomic-write and `isFullyQualified` seams documented as the sanctioned alternatives.
365
+
366
+ - **Helm chart linting (refs #1283, slice A)** — YAML and `.tpl` edits within a chart now run one bounded, canonical-root-deduplicated `helm lint`; warnings remain advisory and chart/template errors block. Rendered-manifest validation remains deferred to slice B.
367
+
368
+ - **Session degradation telemetry in health and performance reports (refs #1292 items 4–5)** — Trust refusals, output-mode suppressions, TypeScript idle evictions, grammar blocks, untrusted formatter skips, and actionable spawn-failure buckets now remain visible as bounded per-session telemetry; LSP pull failures also appear beneath their server in `pilens_health`.
369
+
370
+ - **Remaining silent degradation gates now surface in `pilens_health` (refs #1292)** — LSP breaker trips and diagnostic-timeout tallies, formatter failures, and tree-sitter WASM aborts are recorded with bounded per-session dedupe.
371
+
372
+ - **Deterministic LSP acquisition-race coverage (refs #1292)** — concurrent initialization and an aborted waiter now assert single-client ownership, zero leaked leases, and teardown reaping through the shared interleaving kit.
373
+
374
+ - **Weekly stale open-issue detector (closes #1323)** — the scheduled GitHub workflow reports open issues referenced by closing-shaped `master` commits or issue-named regression tests for human triage; it never closes issues automatically.
375
+
376
+ - **Opt-in compact one-line tool result rendering (closes #1327)** — pi-lens tools can now render as a single theme-aware line (`<status glyph> <tool name> — <summary>`) instead of two rows, via `--lens-compact-tool-line` / `ui.compactToolLine=true` in `~/.pi-lens/config.json`. Default off — behavior is byte-identical when disabled. Expand-to-view-full-output is unchanged.
377
+
378
+ - **HostPorts capability boundary (refs #1358, S2)** -- a typed interface over every host capability the engine consumes (notify, trust, mode, log, emit, status, spawn policy, render, session, workspace, flags, tools), with headless-parity defaults and the existing getter seams as thin adapters; no behavior change, direct-ctx migration deferred to S4.
379
+ - **Global LSP configuration fallback (refs [#870](https://github.com/apmantza/pi-lens/issues/870))** — load shared LSP settings from `~/.pi-lens/lsp.json`, with project configuration taking precedence.
380
+ - **Per-rule configuration controls (closes [#444](https://github.com/apmantza/pi-lens/issues/444))** — enable or disable individual rules through `.pi-lens.json`.
381
+ - **Edit-batch read-guard observability (refs [#1053](https://github.com/apmantza/pi-lens/issues/1053))** — expose batch-level read-guard activity for clearer diagnostics.
382
+ - **Review-graph call evidence (closes [#1070](https://github.com/apmantza/pi-lens/issues/1070))** — include call/reference evidence in project-intelligence reports and codebase models.
383
+ ### Changed
384
+
385
+ - **Safe dependency refresh for v4** — updated the pi host SDK and TUI to `0.84.2`, ast-grep NAPI to `0.45.1`, and Biome to `2.5.8`; refreshed the transitive dependency graph to remove the audited high-severity `nanoid` finding. Major or otherwise non-essential updates remain deferred for follow-up validation.
386
+
387
+ - **Installer probe-cache persistence now uses the shared durable-store protocol (closes #1212)** — awaited commits use the shared quarantine-recovering bounded PID lock, authoritative in-lock reread/delta merge, throwing atomic publication, and in-lock mirror refresh while retaining deferred/failed retry outcomes; TTL, existence, and mtime validation remain read-side policy. Turn-state folding remains a future decision, not part of this change.
388
+
389
+ - **Shared durable-store and atomic-stage protocols (refs #1212, closes #1209)** — diagnostic dispositions and actionable-warning suppression now use one locked in-lock-reread/merge/throwing-atomic-write commit seam, while one staging namespace module owns `.tmp-<pid>-<threadId>-<seq>` minting, classification, and bounded own-stage sweeping.
390
+
391
+ - **Standalone managed-tool clients share one typed availability/install seam (closes [#1290](https://github.com/apmantza/pi-lens/issues/1290), refs [#1214](https://github.com/apmantza/pi-lens/issues/1214))** — madge, Knip, Biome, and ast-grep now join Ruff/jscpd behind runner helpers. Single-command clients use the cached typed checker; ordered-candidate clients retain their local/global/npx/platform search through a typed custom-probe wrapper. The seam also owns managed child environments and read-only discovery, while a source coverage test rejects direct `ensureTool()` calls or bare managed-tool spawns outside sanctioned wrappers. Raw ENOENT consumers in `lsp/launch.ts` remain for #1214's safe-spawn-wide taxonomy.
392
+
393
+ - **Cooperative word-index startup work (closes #1224, closes #1225, closes #1226, refs #1215)** — centralizes monotonic event-loop work budgets, time-budgets shared source walking and word-index reads, yields within high-document-frequency posting filters while publishing each replacement atomically, and reuses the bounded refresh preflight file/stat set for full rebuilds instead of walking and stating it twice.
394
+
395
+ - Ruff and jscpd availability now use the shared dispatch checker, including typed missing-command installation, in-flight dedupe, install-failure suppression, and session reset (refs [#1290](https://github.com/apmantza/pi-lens/issues/1290), phase 1; not closing the issue). The seam adds configurable probe timeout and a managed-command fast path to preserve jscpd's 1500ms PATH probe and managed-tools lookup.
396
+
397
+ - **Review-graph persistence lifecycle telemetry (refs #1048)** — `review-graph.log` now correlates graph/build and persistence generations, sequence hints, graph timestamps/counts, exact total/persisted node/edge/file coverage, source-walk truncation, coalescing and supersession, worker outcomes, and fallback status with bounded metadata, while preventing stale-generation fallback writes; source contents and paths are never added to the new metadata.
398
+
399
+ - **Prompt-cache context attribution (refs #1018)** — add bounded `cache_context` latency records that correlate context injection sources, placement, message counts, sizes, and privacy-preserving hashes with provider `cache_usage` by session/turn, while keeping provider cache misses and local prefix changes distinct.
400
+ - **Friendlier AST search (refs #125)** — correct agent-facing examples, add exact grammar-kind and explicit recursive descendant searches, bound explicit path lists, keep pagination output aligned with `maxMatches`, and document lazy-tool activation timing.
401
+
402
+ - Document red-first regression-test and worktree-hygiene conventions
403
+
404
+ - **Adopt commit-message and doc/prose (Google + STE) writing conventions**
405
+
406
+ - **Refresh README/docs for v4 and update contributors** — reconciled the
407
+ README feature list and `docs/` reference against current code: documented
408
+ the four memory idle-eviction env vars (`PI_LENS_TS_IDLE_EVICT_MS`,
409
+ `PI_LENS_WORD_INDEX_IDLE_EVICT_MS`, `PI_LENS_PROJECT_SNAPSHOT_IDLE_EVICT_MS`,
410
+ `PI_LENS_REVIEW_GRAPH_IDLE_EVICT_MS`), the session degradation ledger
411
+ surfaced through `pilens_health`/`/lens-health`, deferred-format bounded
412
+ concurrency, and the `--lens-guard` commit/push blocker; linked
413
+ `docs/mcp.md` from the README and added an MCP-server bullet. Added six
414
+ external contributors (Nathan Cooke, Eli Stark, Marvin Aziz, Mark Faga,
415
+ aeturnal, floatGray) to the README contributors table, sourced from
416
+ `git shortlog` and merged-PR authorship.
417
+
418
+ - **Dogfood the shape-2 path-semantics rule (refs #1158)** — Flag host-default path operations inside branches committed to Windows path semantics.
419
+
420
+ - **Cooperative-budget acceptance coverage hardened (closes #1215)** — the shared time-budget helper's occupancy and abort-latency guarantees are now locked by non-vacuous regression tests at 800-item scale (assertions within a small multiple of the budget); no runtime behavior change.
421
+
422
+ - **Per-entry changelog files end merge-train conflicts (closes #1321)** — User-facing changes can now land as independent per-entry files and roll up in the version-bump PR before normal CI and tag-time verification.
423
+
424
+ - **TypeScript language services release idle program memory (refs #1332)** — After five inactive minutes (configurable with `PI_LENS_TS_IDLE_EVICT_MS`), TypeScript LSP clients shut down and rebuild transparently on the next request instead of retaining fully hydrated programs indefinitely.
425
+
426
+ - **Terminal behavior now follows the pi host's run mode (refs #1334)** — pi-lens reads `ctx.mode` (`"tui" | "rpc" | "json" | "print"`) instead of assuming it owns an interactive terminal. The diagnostics widget mounts only in `tui`, and proactive `ui.notify` chatter is logged rather than rendered in the one-shot `print`/`json` modes, so piped and machine-readable runs stay clean. `/lens-widget-toggle` now says which mode is blocking it instead of blaming the pi version. Hosts that expose no `mode` field behave exactly as before.
427
+
428
+ - **Enforce the host-SDK type-only import rule that keeps clean installs working (refs #1334)** — `@earendil-works/pi-coding-agent` is an optional peer that pi omits at install time, so a value import of it breaks pi-lens at user sites. That rule was prose only; a regression test now scans every shipped source file for static, dynamic and `require` value imports and fails the build on one. The edit-tool result payload also now uses the host's own `EditToolDetails` type instead of a locally re-declared shape.
429
+
430
+ - **A formatter that exits nonzero is now reported as a failure (closes #1337)** — formatter exit-code strictness was opt-in, so any formatter that had not opted in turned every nonzero exit — rejected flags, a crashed binary, an unparseable file — into `{ success: true, changed: false }`, indistinguishable from "already formatted". That default is what let `ruff format` silently no-op for a full release cycle (#1336). The seam is now strict by default, with an audited opt-out (`lenientExitCode`, carrying its evidence) for the four lint-autofix formatters whose exit status reports remaining offenses *after* a successful rewrite: `rubocop -a`, `standardrb --fix`, `ktlint -F`, and `sqlfluff fix`. **Behavior change:** the other 29 formatters now surface an error where they previously reported a silent no-op — most visibly, saving a file with a syntax error reports the formatter's parse error instead of quietly doing nothing. Three supporting fixes make that surfacing trustworthy: Biome now passes `--no-errors-on-unmatched` (it exits 1 on paths its own `biome.json` ignores, which would otherwise report a failure on every edit under an ignored directory); the reported message skips decorative banners and falls back to stdout, so tools that report there — Biome, ktlint, `mix format` — no longer surface a bare exit code; and the PowerShell formatter now runs with `$ErrorActionPreference = 'Stop'` plus a null guard, so a failing `Invoke-Formatter` reports the failure instead of exiting 0 and truncating the file it was asked to format.
431
+
432
+ - **Re-home probe-cache ageing and cooperative yields (closes #1354)** — authoritative probe-cache commits now prune expired entries while preserving durable-store quarantine recovery; the remaining corpus-scaled scan yields use the cooperative time budget.
433
+
434
+ - **Warm dispatch, LSP async consumers, and formatter catalog lazily at session start (refs #1394 — Phase 2).**
435
+
436
+ ### Removed
437
+
438
+ - **Dead-code cleanup: 5 orphaned production modules + the `server-strategies.ts` deprecated shim (refs #1090)** — `clients/project-metadata.ts` (orphaned since the `/lens-booboo` removal, #397/#396), `clients/lsp/interactive-install.ts` + its test (superseded by `resolveAndLaunch`, #374/#375), `clients/ast-grep-parser.ts` (superseded by the napi runner, #197), `clients/types.ts` (initial-commit LSP types, superseded by `LSPDiagnostic` in `clients/lsp/client.ts`), and `clients/lsp/lsp-index.ts` (stillborn barrel, zero references ever) all had zero remaining importers, re-verified at current master (including dynamic `await import()` patterns). `clients/lsp/server-strategies.ts` (a `@deprecated` re-export shim over `wait-policy/strategies.ts`) is also deleted after repointing its last two consumers — `scripts/probe-clean-signal.mjs` and `tests/clients/lsp/server-strategies.test.ts` — directly at `clients/lsp/wait-policy/strategies.ts`, plus ~25 stale doc/comment references across `CONTRIBUTING.md`, `docs/lsp-capability-matrix.md`, and the LSP/probe source. Non-goals: `clients/module-report-lsp.ts` and `clients/source-groups.ts` (parked pending #236 / roadmap decisions) and the un-export candidates identified in #1090 remain untouched.
439
+
440
+ ### Fixed
441
+
442
+ - **Git guard closes shell-expansion and command-wrapper bypasses (refs #1084, PR #1395)** — command-position normalization now handles all supported IFS and positional-parameter forms, and guarded verbs nested in known or unknown command-string launchers fail closed while literal text remains non-blocking.
443
+
444
+ - **LSP roots and per-session clients are conservatively bounded (closes #1325, refs #1126, refs #1129)** — manifest-bearing fixture conventions (`tests/fixtures`, `__fixtures__`, and `testdata`), gitignored directories, and atomic-write staging namespaces no longer become standalone LSP roots; their files attach to the nearest eligible ancestor project. Client reuse remains keyed by server and normalized resolved root, while a configurable `PI_LENS_LSP_CLIENT_CEILING` (24 by default) evicts the least-recently-used idle client through graceful shutdown before spawning another and never evicts a client with an active LSP request.
445
+
446
+ - **Fully qualified Windows paths use one shared classifier (closes [#1213](https://github.com/apmantza/pi-lens/issues/1213))** — drive-relative (`C:foo`) and rooted-relative (`\\foo`) paths are no longer mistaken for self-contained absolute paths; UNC and drive-absolute paths remain fully qualified across command validation, installer caching, and managed-tool retention.
447
+
448
+ - **Kotlin formatter selection follows Spotless configuration deterministically (closes #1306)** — a Spotless `kotlin { ktlint() }` block pins ktlint and `kotlin { ktfmt() }` pins ktfmt, so both selection branches cannot claim the same Kotlin file and unconfigured projects retain the style-preserving no-format policy.
449
+
450
+ - **Managed npm clients see and execute off-PATH installs (closes [#1289](https://github.com/apmantza/pi-lens/issues/1289))** — madge and Knip availability probes receive the managed environment, Knip threads the same environment into analysis, Biome checks the managed absolute candidate before its npx fallback, and every install result is retained as the command used by the client.
451
+
452
+ - **Turn-end madge telemetry stays compact and its batch metadata is runtime-tested (closes [#1250](https://github.com/apmantza/pi-lens/issues/1250), [#1251](https://github.com/apmantza/pi-lens/issues/1251))** — aggregate batch counters remain exact, while per-target timing breadcrumbs are retained only for spawns taking at least 100 ms and capped at 12 slow targets, preserving the smells-rollup lookback; the real turn-end path now verifies madge batch execution and metadata propagation.
453
+
454
+ - **Rule-policy normalization and source-filter matrices no longer drift (closes #1087)** — P3.2 derives CodeRabbit's language suffixes from the vendored rules tree so disable/select and inline suppression cover every shipped language; P3.3 aligns source-twin precedence ordering with sibling resolution and adds a behavioral agreement guard while preserving the broad `.jsx` build-artifact fallback.
455
+
456
+ - **Smart-default formatters preserve existing indentation when a repository has no style configuration (closes [#1144](https://github.com/apmantza/pi-lens/issues/1144))** — Biome and shfmt now infer and pin a file's indentation for unconfigured formatting, skipping when their style cannot be inferred; Prettier and Ruff receive the same fallback indentation flags. Explicit formatter configuration and `.editorconfig` remain authoritative.
457
+
458
+ - **Bus emitters no longer retain a stale session context after replacement (closes #1128)** — all pi-lens event publishers resolve the live `pi.events` emitter at delivery time through a shared getter seam; deferred lens events also resolve inside their `setImmediate` callback. The sweep found no other outliving session-context emitter closures: the quiet-window summary already uses its current-context holder and its stale-session guard; LSP repaint callbacks capture UI primitives intentionally rather than `ctx`; remaining lifecycle callbacks use the event-time context or runtime generation gates.
459
+
460
+ - **Durable disposition marks no longer lose sibling-process updates (closes [#1202](https://github.com/apmantza/pi-lens/issues/1202))** — the repo-wide shared-store audit classifies every durable writer and records accepted best-effort caches explicitly. The sole unguarded behavior-gating read/modify/write store, `diagnostic-dispositions.json`, now takes a bounded pid-owned cross-process lock, re-reads under that lock, and merges only its anchor delta, so an older writer cannot promote a stale whole-file snapshot over a concurrent mark.
461
+
462
+ - **Behavior-gating durable stores no longer lose sibling-process updates (closes [#1202](https://github.com/apmantza/pi-lens/issues/1202))** — the repo-wide shared-store audit classifies every durable writer and records accepted-loss telemetry explicitly. Diagnostic dispositions and actionable-warning suppression state now share a bounded pid-owned cross-process lock, re-read under that lock, and merge only the caller's delta before atomic replacement. Disposition telemetry fires only after persistence succeeds, and turn-state clear/cycle/deferred-format paths now enforce the same live-owner boundary as edit registration.
463
+
464
+ - **Tool discovery caches now recover from session changes and deleted binaries (closes [#1203](https://github.com/apmantza/pi-lens/issues/1203))** — cwd-scoped runner probes and the shared ast-grep memo follow the existing dispatch session generation, while installer in-memory positives revalidate absolute paths and evict the matching persisted entry before re-probing when the binary disappears.
465
+
466
+ - **Managed ruff, jscpd, and madge installs are now actually used after auto-install (refs [#1289](https://github.com/apmantza/pi-lens/issues/1289))** — retain the absolute path returned by `ensureTool` so off-PATH managed shims are spawned directly instead of falling back to the bare command name.
467
+
468
+ - **The shared async walk engine no longer reads directories synchronously (refs [#1137](https://github.com/apmantza/pi-lens/issues/1137), [#1170](https://github.com/apmantza/pi-lens/issues/1170))** — `walkTreeStackAsync` (`clients/source-walker.ts`) already `setImmediate`-yielded every N entries, but every per-directory read still went through `readDirEntriesSafe` → `fs.readdirSync`. Chunked yielding only covers the CPU axis: on a cloud/network-backed tree (OneDrive) one stalled directory read held the Node event loop — and pi's TUI — for the entire stall, no matter how often the walk yielded around it. This is the exact shape #1170 fixed in `pipeline.ts`'s autofix snapshot walk while explicitly deferring the shared engine; fixing it here means every async walker (`collectSourceFilesAsync`, `countSourceFilesWithinLimitAsync`, and so the session-start source scans and word-index refresh) inherits the fix at once. The traversal itself is untouched: the one shared generator now yields a directory-read *request* and each driver satisfies it — `readdirSync` for the sync driver, `fs.promises.readdir` for the async one — so the sync/async pair still cannot drift, and visit order, `maxFiles` cap behaviour and results are identical. `tools/lsp-diagnostics.ts`'s directory-mode `collectFiles` walk is converted the same way (it was fully synchronous with no yielding at all, bounded only by files *kept*, and ran once per language in the `LANG_EXTENSIONS` loop); it keeps its immediate-descent recursion rather than adopting the stack-based shared driver, because the `maxFiles` cap makes traversal order observable. A `measureMaxSyncBlockMs` fail-then-pass occupancy screen injects the same per-directory stall into both APIs and pins that the async walk no longer holds the loop (verified to fail against pre-fix code: a 420ms block against a 30ms budget).
469
+
470
+ - **Direct-write cache stores now publish JSON atomically (refs [#1239](https://github.com/apmantza/pi-lens/issues/1239) [#1205](https://github.com/apmantza/pi-lens/issues/1205))** — turn state, rule, diagnostics, call-graph, codebase-model, actionable-warning, and metrics-history stores stage JSON through the shared tmp-plus-rename writer, so a process killed during a write leaves the previous complete cache value rather than a truncated file.
471
+
472
+ - **Dispatch's install-failure suppression never cleared, and a completed install could keep serving a pre-install madge resolution (closes #1266, refs #1276)** — `handleSessionStart`'s install-attempt suppression window (`installFailureReasons`, #1222/#1223) had no reset hook, so a tool that legitimately failed once stayed suppressed for the rest of the process even across sessions; it is now reset at session start for both sequential and MCP session starts (the existing concurrent-session guard already prevents a subagent session start from over-resetting a live parent). Separately, `DependencyChecker`'s madge managed-path memo (#1276) is keyed only by `projectRoot` but reads PATH/discovery/managed-install state that a completed install changes, and `finishInstallAttempt` dropped it via a fire-and-forget dynamic import — a caller that started the next madge resolution the instant `installTool`/`ensureTool` resolved could still observe the stale pre-install memo, and a rejected import became a silent unhandled rejection with no invalidation at all. The reset is now `await`ed before `installTool`/`ensureTool` return, wrapped in try/catch that logs (rather than swallows) an import failure, so a resolution issued right after a successful install deterministically sees the reset memo. The checker's own instance registry (needed so the reset reaches every live checker, not just whichever one ran the install) now holds instances via `WeakRef` instead of a strong `Set`, so short-lived checkers (tests, reinit) can be collected instead of retained for the process's lifetime; and the bare-command spawnability revalidation `resolvedCommandIsStale` added under #1276 is now cached per checker (invalidated by that same reset) instead of re-running a synchronous PATH scan on every cached resolution.
473
+
474
+ - **Orphan staging-file reaping no longer deletes legitimately named files (refs [#1267](https://github.com/apmantza/pi-lens/issues/1267), [#1260](https://github.com/apmantza/pi-lens/issues/1260))** — the session-start atomic-stage sweep now requires the full writer-emitted `.tmp-<pid>-<threadId>-<seq>` shape with strict, unpadded decimal components, so names such as `backup.tmp-000123-02-03` are left alone while genuine current-shape dead-pid orphans are still reaped.
475
+
476
+ - **`no-bare-except` and `duplicate-function-arg` grammar coverage, including a binding-aware fix for the broadened traversal (closes [#1269](https://github.com/apmantza/pi-lens/issues/1269), closes [#1270](https://github.com/apmantza/pi-lens/issues/1270))** — `no-bare-except`'s `not: any: [...]` exception-spec enumeration (identifier/attribute/tuple/parenthesized/as_pattern/subscript) was a denylist that any new node shape could slip past; it now matches the grammar's bare `except:` syntax directly (`pattern: "except:"`), so a future exception-spec kind cannot become a false negative by omission. `duplicate-function-arg`'s query previously bound `(required_parameter (identifier) @NAME)` with no depth restriction, so a rest parameter (`...a`) and other non-`required_parameter` shapes went unmatched; the query and its `same_param_name` post-filter's `bindingNames` helper were broadened to walk the full parameter pattern (covering rest params, arrow functions, and destructured/renamed collisions). That broadening over-corrected: `bindingNames` treated every descendant `identifier` as a bound name, so a reference inside a destructuring **default** (`{a = b}`, `[a = b]`, `{x: a = b}`, `{a = (() => b)}` — the `b` is a default-value expression, not a binding) or inside a **computed property key** (`{[key]: a}` — `key` is a reference) was wrongly counted, producing false-positive duplicate reports against an unrelated same-named parameter elsewhere in the list. `bindingNames` is now binding-aware: it descends into an `assignment_pattern`/`object_assignment_pattern`'s `left` field only (never `right`, the default expression) and skips `computed_property_name` entirely, while still walking nested destructuring binding positions so `{a}`/`[a]`/renamed-binding collisions keep flagging. (`{a = a}, a` legitimately still flags — the left side of `{a = a}` binds `a`, colliding with the second parameter's own `a`; Node itself rejects that snippet with "Duplicate parameter name not allowed in this context".) Both false-positive fixtures were confirmed to reproduce against the pre-fix traversal before the change and pass after.
477
+
478
+ - **Stop-hook turn-end residuals: stdin hang, invisible skips, unguarded client cwd, delivery-loss on the ack error path, and transcript rendering (refs [#1271](https://github.com/apmantza/pi-lens/issues/1271), [#1272](https://github.com/apmantza/pi-lens/issues/1272), [#1273](https://github.com/apmantza/pi-lens/issues/1273), [#1274](https://github.com/apmantza/pi-lens/issues/1274), [#1275](https://github.com/apmantza/pi-lens/issues/1275))** — five defects found in post-merge review of #1211, all on the turn-end path.
479
+ - `pi-lens-analyze`'s `main()` awaited the stdin read BEFORE examining `--file`/`--turn-end`, with no timeout and only an `isTTY` guard, so any spawner that left a piped stdin open hung the bin forever — a regression on the pre-existing `--file` path, and as a Stop hook a full 60 s Claude Code timeout every single turn. argv now wins: stdin is read only when neither flag answers the question, and the read is raced against a 2 s timer so a stuck pipe degrades to "no payload" instead of hanging. Every existing test called `child.stdin.end()`, which is exactly why the suite could not catch this; the two new guards deliberately do not.
480
+ - Every turn-end skip wrote one line to **stderr** and exited 0, and Claude Code does not surface stderr from a hook that exits 0 — so a permanently dead integration was byte-for-byte indistinguishable from a clean turn, indefinitely. The skip now also goes to stdout (the hook's only transcript-visible channel, one line), the message names the actual wire reason instead of always claiming "no warm pi-lens MCP server" (absent server, slow pass, schema skew and stale build have different remedies), and the hook records `{ran, skipped, lastSkipReason, lastRunAt}` per workspace so `pilens_health` reports it — the #544 precedent applied to the hook process, which is separate and short-lived and therefore left no other trace. That per-workspace status file is a shared-tmpdir read-modify-write two concurrent Stop hooks for the same workspace can race; the write now routes through `writeFileAtomic` (`clients/atomic-write.ts`) so a race can no longer torn-read the file (parse failure resetting both counters) or hit a Windows sharing violation — the read-modify-write itself can still drop one increment under a true race, which is accepted for this bounded best-effort telemetry.
481
+ - The warm turn-end IPC route trusted the client-supplied `cwd`, called `ensureReady` on it and ran **that directory's configured test runner** there. Turn-end and its ack now reject any cwd that is not the server's own `DEFAULT_CWD` or inside it, and log the mismatch; every legitimate client already derived the socket path from that same cwd, so the guard costs nothing legitimate. A subdirectory of the workspace stays accepted. The guard's first cut wrongly rejected the exact workspace root on win32 whenever the cwd it was handed differed from `DEFAULT_CWD` only in case (drive letter or a path segment): `path.relative` is case-insensitive there and returns `""` for that pair, but the guard's `rel !== ""` term read that as "outside the workspace" — a real gap, since Claude Code's Stop-hook cwd can legitimately differ in case from how the server derived its own `DEFAULT_CWD` (`ipcPathForCwd`/`workspaceHash` in `clients/mcp/ipc.ts` already `.toLowerCase()` for exactly this reason), silently no-opping the feature at the root on every turn. Fixed by dropping the redundant `target === DEFAULT_CWD` fast path and the `rel !== ""` term together — `rel === ""` only ever means "this is the workspace root" and must be accepted, not rejected.
482
+ - `acknowledgeTurnEnd` deleted the pending delivery BEFORE calling `commit()`, so a throwing consume destroyed the only handle to the findings — the exact loss the two-phase protocol exists to prevent, reintroduced on its error path. It now commits first and drops the capability only on success. `pendingTurnEndDeliveries` also had no expiry and no size cap (every timed-out client and every distinct raw-cwd alias left an entry for the life of the process); it now expires at 10 min and caps at 8, both dropping entries **without** committing so the findings re-arm for a later Stop rather than vanishing. The route no longer starts a heavy pass for a client whose socket is already destroyed, and byte-identical in-flight Stop requests (no files, no sessionId) coalesce onto the one pass instead of a third being rejected outright by the single-waiter queue.
483
+ - Transcript rendering: the 2000-character cap sliced UTF-16 code units and split surrogate pairs, putting U+FFFD where an emoji marker belonged; the cut now backs off a high surrogate. The `tests` section is the raw vitest failure dump, so ANSI escape sequences and C0 control characters (newline/tab excepted) are stripped before capping, which also removes the half-sliced-escape leak. The injection-framing strip was `startsWith`-only and now removes every occurrence, so a token carried into the middle of a joined section cannot survive.
484
+
485
+ - **Marksman silent-clean confirmation now requires a liveness check, so a wedged server is no longer rendered clean (refs [#1277](https://github.com/apmantza/pi-lens/issues/1277))** — #1253 converted a diagnostics timeout on a `tier3-silent` server (e.g. Marksman) into a confirmed-clean verdict from the static capability classification alone. A server that accepted the notify write and then became unresponsive was therefore reported clean rather than inconclusive — the unresponsive-server-as-clean silent-failure shape. The gate now additionally requires `clientPingLiveness`, an active `workspace/symbol` round-trip bounded at 300ms and independent of the notify-write: a wedged server never answers, the timer wins, the ping returns false, and the result stays inconclusive. A genuinely silent-clean server (alive, tier3-silent, no diagnostics) still confirms clean, and the enumerable `confirmation` wrapper contract is unchanged. Guarded by a notify-accepted-then-unresponsive test confirmed to fail against the pre-fix body.
486
+
487
+ - **Stop-hook turn-end findings are no longer lost when the client deadline wins (refs [#1218](https://github.com/apmantza/pi-lens/issues/1218))** — the warm Stop route now separates execution from durable delivery. It returns a delivery capability over one one-shot connection, and consumes the turn-end/test finding bridges only after a receipt acknowledgement on a separate one-shot connection. A timeout, late reply, or connection close leaves findings available for a later authorized Stop; repeated acknowledgements are idempotent. Turn-state writers now carry explicit pi/MCP ownership, so `sessionId:null` cannot erase a shared worklist or let a live foreign session consume it. Spawn tests use isolated temp workspaces/data/home roots and refuse the real workspace socket.
488
+
489
+ - **Markdown `lsp_diagnostics` now preserves Marksman's authoritative silent-clean confirmation (closes [#1253](https://github.com/apmantza/pi-lens/issues/1253))** — `LSPService.touchFile` already treated a successful Marksman notify followed by its expected no-publish clean behavior as confirmed, but the tool boundary retained only the empty diagnostics array and `inconclusive` flag. The tool then reclassified the same empty tier-3 result, attempted the TypeScript-only fallback path, and rendered clean Markdown as inconclusive. `TouchFileResult` now carries explicit confirmation metadata for completed collections, and every local diagnostics request routes through `touchFile` when available — including default all-scope batches with no explicit wait. Primary-scope and non-TypeScript all-scope consumers accept that confirmation; all-scope TypeScript still runs its synchronous tsserver fallback before any clean verdict, so unpublished TypeScript findings remain visible. Absent metadata, failed writes, timeouts, binding mismatches, and ambiguous capability state remain unconfirmed; confirmed clean results remain cacheable and widget-reconcilable, and real findings are unchanged. Two carriage holes that only became reachable once the confirmation was surfaced to the tool are closed with it. (1) `touchFile` recorded its recent-touches debounce entry even when a server's notify write TIMED OUT or rejected, so the next touch inside the 1500ms window skipped the notify entirely and reported `notifyWriteTimedOut: false` — the exact precondition both silent-clean gates (#799 single-server, #814 aggregate) read as "the server saw this content". A `silentOnClean` server that never received the file was therefore confirmed clean one debounce window later; in a batch this fired routinely, because `ensureWarmForSweep`'s warm-up touch is the first push and the per-file touch is the second. The debounce is now keyed **per server** (`file:clientScope:serverId`) and each entry is recorded at the moment that server's own write lands, so a failed write re-pushes instead of laundering into a confirmation. Per-server rather than the file-level "record only when every spawned server's write landed" this fix first shipped as: `recentTouches` carried no server component, so withholding the entry withheld it from every co-touched server, and one wedged auxiliary disabled the debounce for its HEALTHY siblings — re-pushing them on the next touch and clearing diagnostics they had already computed, which is the same per-file collapse #743 removed from the notify-write deadline (and which its own in-file doctrine comment forbids). Keyed per server both hold at once: the stalled server has no entry and is re-pushed, while every sibling whose write landed keeps its own debounce and its original timestamp, so the window still expires naturally instead of being extended by reuse. The version-baseline diagnostics wait is likewise per-server now, so a server that WAS pushed still gets its `minVersion` wait when a sibling was debounced away. Guarded by a two-server test (one stalled, one healthy, same content twice) confirmed to fail against the pre-fix body — the healthy server's `notify.open` was called twice where it must be called once. (2) The warm-attach collection branch returns before the local touch and had no provenance to carry, so a warm-attached session reported every clean Markdown file unconfirmed regardless of the tool-side fix; `WarmDiagnosticsResponse` now carries the incumbent touch's `confirmation` as an explicit enumerable DTO field (same doctrine as `inconclusive`), consumed as an AGGREGATE confirmation because the incumbent always touches `with-auxiliary` — an incumbent that omits the field stays unconfirmed. A confirmed touch whose findings are all filtered away by the requested severity floor now renders **clean at that severity** rather than consulting the empty-result classifier (which answered "unconfirmed" for a tier-3 silent server): the unconfirmed doctrine exists for a server that said NOTHING, not for one that demonstrably answered and had its findings filtered by the caller's own floor. The filtered diagnostics are still cached and widget-reconciled, and the tier-3 sync escape hatch is still reserved for a genuinely empty result. Covered end-to-end: a new suite drives the real `LSPService` through the real tool with only the server registry and client transport faked, so the confirmation has to actually flow rather than being asserted separately on each side of a mock; the incumbent-side DTO composition is pinned separately (a confirmed touch emits the field, an unconfirmed or inconclusive one OMITS the key rather than serializing `undefined`, which is what makes the absent-field degradation byte-identical to a pre-#1253 incumbent and the no-version-bump safe).
490
+ - **Turn-end madge resolved its command once per FILE, and a slow older pass could overwrite newer circular-dep state (refs [#766](https://github.com/apmantza/pi-lens/issues/766))** — `resolveMadge` ran inside `checkFilesBatch`'s per-file mapper, and its `findNodeToolBinary` miss path spawns uncached `npm config get prefix` / `pnpm bin -g` / `yarn global bin` probes, so a turn touching N import-changed files paid that whole chain N times before falling back to `npx madge` — never consulting the managed tools tree pi-lens installs madge into in the first place. Resolution now runs once per project root (memoized on the instance) and falls through local/global bin → `ensureTool("madge", { allowInstall: false })` → `npx madge`; the discovery step can never download, because installation stays owned by `ensureAvailable()`. That memo is deliberately not one-way, since re-probing per spawn is also what used to make resolution self-healing: a memoized absolute path is re-validated with one `existsSync` per resolution, so a madge uninstalled mid-session re-resolves instead of failing every remaining spawn, and the `npx` result is never pinned — it is the slow path being removed, and one transient installer failure would otherwise disable managed resolution for the whole session. The resolved command's `kind` is classified from the returned STRING (`local`/`managed`/`global`/`path`/`npx`) rather than from the step that produced it: `findNodeToolBinary` probes the npm/pnpm/yarn/bun GLOBAL bins as well as the project's `node_modules/.bin`, and the installer's discovery legitimately hands back a bare PATH name or an npm-global path as well as the managed one. `checkFilesBatch` now returns `{ results, stats }`, and turn-end attaches `MadgeBatchStats` — requested/missing/cacheHits/spawned/failed, `commandKind`, `resolveMs`, and per-target durations capped at 12 with `targetsTruncated` so the cap is never silent — as metadata on the existing `phase: "madge"` latency entry, which as one whole-phase duration could not distinguish many files from one slow file from command resolution. Separately, `lastCircular`/`circularFiles` are shared instance state that `checkFile`, `checkFilesBatch` and `scanProject` all overwrite wholesale while genuinely overlapping (turn-end against the background scans in `runtime-session.ts` and `project-diagnostics/fresh-fetch.ts`): each writer now takes a generation when it CLASSIFIES and publishes only while that generation is still the newest, so an operation that started against older file content can no longer resurrect that view over a newer one, whatever order the spawns finish in. The batch folds into a batch-local copy and publishes once at the end, so its returned map keeps the array-order sequential equivalence either way, and both guard tests were confirmed to fail against the unguarded publish rather than merely to pass after it. No result cache was added (#533, "when in doubt, re-run") — changed content still re-runs madge, and the guard only skips writes that a newest-wins sequential interleaving would also have discarded. A content-fingerprint single-flight map was considered and dropped as unreachable: `importsChanged` writes its `{timestamp, size}` entry synchronously during classification and neither caller has an interleaving await there, so a second overlapping operation on the SAME content always classifies as a cache hit and never reaches a spawn. Scope honesty: `isInCircular`/`getCircularForFile` have no production callers and turn-end only dbg-logs `hasCircular`, so the guard's payoff today is the correctness of future cache-hit results rather than user-visible output — which is input to the still-open step 4 of #766 (whether the turn-end madge pass should exist at all).
491
+ - **`duplicate-function-arg` no longer fires a false duplicate when a parameter's default-value expression references an earlier parameter name (closes [#1256](https://github.com/apmantza/pi-lens/issues/1256))** — the query bound `(required_parameter (identifier) @NAME)` with no depth restriction, so an identifier at **any depth** inside the second parameter's subtree — the default value (`root = node`) or a type annotation — captured as `@NAME`, and `same_param_name` compared it to the first parameter's name and fired. Dogfooded live: `containsYieldInFunctionBody(node, root: TreeSitterNode = node)` in `clients/tree-sitter-client.ts` reported a phantom duplicate on every dispatch. Both captures now bind through `pattern: (identifier)`, the field the grammar actually exposes (`name:` does not exist on `required_parameter` — it would silently kill the rule; `pattern:` is already used by the sibling `variable-shadowing.yml`), so only the parameter NAME can match. Regression tests: a default-value reference must NOT match, and genuine duplicates with a default-valued earlier parameter must still match.
492
+
493
+ - **Autofix surfaces now apply the same config as the lint surfaces that reported the diagnostics (closes [#1247](https://github.com/apmantza/pi-lens/issues/1247))** — `markdownlint-cli2 --fix`, `ruff check --fix` and `biome lint --write` ran with NO `--config`, so on projects without their own config the tool's default ruleset rewrote files per rules the lint surface never endorsed: markdownlint's default all-rules-on config produced whole-file rewrites of CHANGELOG.md/AGENTS.md and `#946` → `# 946` heading-spacing corruption (MD018), while ruff/biome applied default rule sets instead of the package-owned `core.toml`/`core.jsonc`. The lint runners and the autofix paths now consume ONE shared config-args seam (`markdownlintConfigArgs`/`ruffConfigArgs`/`biomeConfigArgs` in `clients/tool-policy.ts`) — package-owned fallback when the project has no config, the user's config when present — so the two surfaces cannot drift again. `biomeClient.fixFileAsync`/`ruffClient.fixFileAsync` gained an optional dispatch-cwd for discovery. Guarded by builder-level tests (fallback + user-config states for all three tools) and a pipeline-level test pinning the `--fix` invocation args.
494
+ - **Three hand-rolled `.tmp-<pid>` staging writers bypassed `atomic-write.ts` and never inherited the #1205 fix, and the fixed staging name still collided across worker threads (closes [#1205](https://github.com/apmantza/pi-lens/issues/1205), closes [#1217](https://github.com/apmantza/pi-lens/issues/1217), refs [#1202](https://github.com/apmantza/pi-lens/issues/1202))** — #1205 made `writeFileAtomic`'s staging path unique per call, but only for writers that actually route through the module. `clients/instance-reaper.ts`'s `pruneDeadInstances` (machine-global `~/.pi-lens/instances.json`), `clients/gzip-stage-write.ts` (both persist workers' streamed-gzip stage write) and `clients/grammar-source.ts`'s `downloadGrammar` each interpolated their own per-PROCESS staging name, so all three still published torn files exactly as pre-#1205 code did. The reaper was the reachable one: `instances.json` is written fire-and-forget from `instance-registry.ts`'s `prunePids` as well, with nothing serializing the two, and `readInstanceRegistry` degrades a parse failure to empty — so a tear dropped **every** registered instance rather than the one entry being pruned. Reproduced against the pre-fix body at **22/40 unparseable on Linux**, the same scale as #1205's own 21/40. All three now source their staging name from `atomic-write.ts` (`writeFileAtomicAsync` for the reaper and `writeFileAtomic` for the grammar fetch, which also gains the failure-path staging cleanup neither hand-rolled copy had; `stagePathFor` alone for `gzip-stage-write.ts`, which needs its own streaming pipeline). `stagePathFor` itself gained `threadId`: its counter is per-THREAD (a worker gets its own module instance, so its own `_stageSeq` starting at 0) while `process.pid` is shared across every thread, so two threads each minted the identical `${target}.tmp-<pid>-0` — #1205's shared-inode tear reintroduced one axis over, and no longer hypothetical now that `gzip-stage-write.ts` is a real worker-side caller. The staging name is therefore `${target}.tmp-<pid>-<threadId>-<seq>`, covering the three ways two writes can overlap — pid across processes, thread id across threads, counter within one thread — and `STAGE_TMP_PATTERN` grew a third optional group so sweepers still collect leftovers in all three shapes on disk (`.tmp-<pid>`, `.tmp-<pid>-<seq>`, `.tmp-<pid>-<threadId>-<seq>`). `gzip-stage-write.ts`'s docstring claimed "the `stagePath` itself is only ever created by the atomic rename, so a crash mid-write can never leave a torn stage file behind" — true for a crash, false for concurrency, the same false-confidence pattern #1205 found in `atomic-write.ts`'s own docstring — and now scopes its guarantee to crash-safety and states that it provides no ordering and no isolation beyond tear-freedom. The repo-wide sweep #1217 asked for ran over every `rename` call site rather than a `.tmp-` grep, which is what surfaced `grammar-source.ts` (missed by the incidental discovery that filed #1217); the remaining renames are log rotation, installer moves, and LSP workspace edits, none of which stage. Every new guard was confirmed to fail against the pre-fix body on Linux, not merely to pass after (recurring defect shape 2/7): the reaper tear at 22/40, the gzip writers' renames at `ENOENT`, and two worker threads minting one byte-identical staging path.
495
+
496
+ - **The review graph's stale-stage sweep deleted OTHER modules' in-flight atomic-write staging files out of the shared project cache dir (refs [#1206](https://github.com/apmantza/pi-lens/issues/1206), [#1202](https://github.com/apmantza/pi-lens/issues/1202))** — `sweepStaleStageFiles` (`clients/review-graph/builder.ts`) matched `entry.includes(".stage-") || /\.tmp-\d+$/` and `fs.rm`'d the result out of `getProjectDataDir(cwd)/cache`. The `.tmp-<pid>` alternative is the generic staging shape `clients/atomic-write.ts` produces for **every** durable store — diagnostic dispositions, cache-manager scanner data, project snapshot, call graph, codebase model, workspace/project diagnostics caches — not the review graph's property. A review-graph persist that readdir'd the cache dir while another process sat between its `writeFileSync` and its `renameSync` deleted that staging file, so the rename threw `ENOENT` (propagating out of `markDisposition` into the agent's mark tool for `bestEffort: false` stores) or silently dropped the update for `bestEffort: true` ones — one subsystem's garbage collection reaching into unrelated subsystems' transactions, and the inverse of the atomic-write contract. The predicate is now scoped to artifacts the review graph itself produces (basename starts with `review-graph.` **and** contains `.stage-`, covering `review-graph.json.gz.stage-<pid>-<gen>`, the checkpoint stage, and either's persist-worker `<stage>.tmp-<pid>`), and an entry whose embedded stage pid is still alive is skipped via the reaper's conservative `realIsPidAlive` (ESRCH-only-means-dead) rather than a second hand-rolled liveness probe — so a concurrent healthy owner's in-flight stage survives, and a recycled pid leaks one stage file rather than destroying a live one. Because the bare `.tmp-<pid>` shape is no longer matched at all, this is independent of the per-call-unique staging rename in #1205; the now-unowned orphaned-atomic-write-temp GC gap is tracked separately by #1228.
497
+
498
+ - **`writeFileAtomic` published torn files for concurrent same-process writes — the staging name was per-PROCESS, not per-CALL (refs [#1205](https://github.com/apmantza/pi-lens/issues/1205), [#1202](https://github.com/apmantza/pi-lens/issues/1202))** — `clients/atomic-write.ts` staged every write at `${target}.tmp-${process.pid}`, so two in-flight writes from one process to one target shared a staging inode: both `open(O_TRUNC)`ed it, the first `rename` published that inode to the target, and the second writer's still-open fd kept writing into the now-published file. The result was a torn hybrid at the target path — measured 35/40 in the issue report and reproduced by the new regression test against the pre-fix body on **both** OSes — 13/40 on a Windows dev box and **21/40 on ubuntu-latest CI** (verified by pushing the pre-fix body to a throwaway draft PR so the test would actually run on the CI OS, rather than assuming a Windows-only reproduction generalizes — recurring defect shape 2/7). The test carries deterministic structural assertions too (two concurrent `bestEffort: false` writers must report two different `err.path` staging files; `stagePathFor` must never repeat), so the guard does not depend on winning a scheduler race on any platform. The precondition is violated by design: `recordLspChild` (`lsp/client.ts`), `updateHeartbeat` (`runtime-turn.ts`), `prunePids` (`instance-registry.ts`) and `appendRecentTouches` (`bus-publish.ts`) are all deliberately fire-and-forget, and every reader in that family swallows parse errors to `{}`/`[]`, so a tear degraded to silent TOTAL loss of the store rather than partial loss. Fixed by routing both the sync and async paths through a new `stagePathFor()` that appends a monotonic per-process counter (`${target}.tmp-<pid>-<seq>`), making the staging path unique per call; the failure-path cleanup unlinks that same per-call path, so it still removes exactly the file the call created. A counter rather than `randomBytes` because it is allocation-free on these hot per-turn/per-touch paths and makes collision impossible by construction rather than merely improbable, with `process.pid` still supplying cross-process distinctness. The module docstring previously claimed "a concurrent reader always sees either the fully-old or fully-new file" — true cross-process, false same-process — and has been rewritten to state the real guarantee (crash-safe replacement + tear-free publication) alongside what the primitive explicitly does NOT provide: no read-modify-write isolation, no ordering, no mutual exclusion, no fsync durability, no orphan reaping (tracked in #1228), and a Windows-specific `bestEffort: true` loss mode when a reader holds the destination open without `FILE_SHARE_DELETE`. The naming scheme is now exported as `stagePathFor`/`STAGE_TMP_PATTERN` so orphan-staging-file sweepers consume it instead of re-deriving `.tmp-<pid>` (the pattern keeps the `<seq>` group optional so pre-#1205 leftovers on disk are still swept); `sweepStaleStageFiles` in `review-graph/builder.ts` is migrated to it separately under #1206, which must add its own pid-based exclusion rather than rely on that sweeper's existing `.stage-<pid>-` own-marker, which does not cover the `.tmp-` branch.
499
+
500
+ - **Dense word-index startup refresh no longer turns quadratic or monopolizes the TUI event loop (refs [#1197](https://github.com/apmantza/pi-lens/issues/1197))** — replacing one stale document is cheap, but the incremental path repeated posting-array filters even when almost the entire persisted index was stale: pi-lens refreshed 2,061 documents with zero reuse in 216.8s and recorded a genuine 22.5s event-loop block, while a linear full build of the same corpus took 7.5s. Refresh preflight now selects a separately-built full replacement before mutating the old index whenever the stale set's estimated WORK exceeds one full rebuild — density is not a bound, because per-document cost grows with the corpus: 800 documents with 239 stale (29.875%, under any 30% ratio) measured 90,577ms with a 39,553ms synchronous block against 1,029ms / 10.6ms for a full build, and at the 6,000-file cap up to 1,799 stale documents stayed on that path. The work model is calibrated against this repository's own 2,062-document corpus and its predicted crossover (23 stale documents) matches the measured one (~21). The >30% ratio, >30% file-set-churn and legacy-metadata transitions remain as-is. Every bulk path — the cooperative full builder and both refresh loops — now yields on an ~8ms monotonic budget OR'd with its item checkpoint rather than count-only, including within large documents and after any line of 4,096+ characters (a minified bundle previously got zero in-document yields). A build superseded mid-warmup returns gracefully instead of throwing out of the quick-mode warmup pass and skipping the #947 LSP pre-warm. Results remain byte-equivalent to the synchronous reference builder, superseded sessions never publish partial state, and sparse refreshes retain their unreadable-file retry semantics.
501
+
502
+ - **Windows bare-command resolution ignored the caller's child `PATH`/`PATHEXT` and could reuse another environment's cached answer (closes #1199)** — `safeSpawnAsync` and deprecated `safeSpawn` now derive case-insensitive Windows command resolution from the exact normalized environment passed to the child, so managed command shims such as Knip's `.cmd` are found even when only `options.env.Path`/`PATH` contains their directory. Cache identity includes the effective path and extension list; explicit/relative Windows paths use `path.win32`, and the same canonical absolute cwd used by drive-relative resolution is passed to the child. Positive cache hits revalidate the executable, negative entries expire after one second, and successful managed installs invalidate the cache immediately, while existing shell, UTF-8, timeout, abort, and tree-kill behavior remains unchanged.
503
+
504
+ - **`project_report`'s `toDisplayPath` hand-rolled relativization without the shape-aware `isWindowsPath` branch, mis-rendering a Windows-shaped path as the full absolute path on Linux CI (closes #1194, refs #1163 #1152 #1024)** — `clients/project-report.ts`'s `toDisplayPath` used host-default `path.isAbsolute`/`path.relative` directly, the exact defect `toProjectRelativePath` (`clients/path-utils.ts`) was fixed for in #1163 — its sibling `module-report.ts`'s `toDisplayPath` already delegates to that shape-aware helper, but this file reimplemented the same convention natively instead, so `path.isAbsolute("C:\\repo\\src\\x.ts")` returned `false` on Linux and short-circuited to the whole absolute path instead of `src/x.ts`. The #1163 sweep missed it because it grepped the `isWindowsPath` hot zone and this file never branched on it at all. Fixed by delegating to `toProjectRelativePath`, matching `module-report.ts`. Second-axis sweep of every other hand-rolled `path.relative`/`path.isAbsolute` relativizer in `clients/`/`tools/` found no further offenders: `call-graph.ts`'s `formatImpact` and `lens-map.ts`'s `toDisplayPath` already delegate to `toProjectRelativePath`; every other `path.relative`/`path.isAbsolute` hit resolves a live, same-process, same-OS path (dispatch context, LSP results, subprocess/runner output, filesystem walks) rather than a persisted/cross-OS review-graph path, so none share this class. Fail-then-pass verified directly against the pre-fix body under explicit `path.posix` semantics (not host-default, since a Windows dev box's bare `path` is already win32 and would pass vacuously) — it returned `"C:/repo/src/x.ts"` instead of `"src/x.ts"`, which the delegated implementation now corrects on any OS.
505
+ - **`trivy-config.test.ts` hardcoded a POSIX path literal (`/tmp/main.tf`), so it failed on a Windows dev machine while staying green on Linux CI (closes #1190, refs #1024)** — the terraform pass-through tests built `ctx.filePath`/`ctx.cwd` from the string literals `"/tmp/main.tf"`/`"/tmp"` and asserted the mocked `safeSpawnAsync` call against those same literals. The runner itself (`clients/dispatch/runners/trivy-config.ts`) is OS-agnostic — it resolves the scan path with `path.resolve(cwd, ctx.filePath)` — but on Windows that resolves `/tmp/main.tf` to a drive-relative `C:\tmp\main.tf`, so the hardcoded POSIX-form expectation no longer matched: the inverse of the #1024 class (a test only holding on one OS, this time POSIX-only rather than Windows-only). Test-only fix: the two cases now derive `tfCwd`/`tfFile` via `path.join(os.tmpdir(), ...)` and assert against those same derived values, so the expectation is computed the same way the runner computes its resolved path — real on both POSIX and Windows, with no hardcoded separator on either side.
506
+
507
+ - **Structural shape-5 hardening: LSP `touchFile` now returns a `{ diags, inconclusive, binding }` wrapper whose flags survive any copy by construction, plus a fail-closed graph-build-info guard (closes #1179, refs #1108 #1094 #1096)** —
508
+ the #1108 audit found all five side-channel flags safe as-is (every consumer
509
+ reads off the original), but the three LSP diagnostics flags stayed
510
+ fragile-by-construction: a non-enumerable property hung on the returned
511
+ diagnostics array, silently dropped by any `[...]`/`.map`/`.filter`/
512
+ `structuredClone`/`JSON` copy between producer and consumer (the class that bit
513
+ as #1094 `inconclusive` and #1096 `binding`). `touchFile` now resolves an
514
+ explicit `TouchFileResult` wrapper (`clients/lsp/diagnostic-binding.ts`) with
515
+ `inconclusive` and `binding` as ENUMERABLE fields alongside `.diags`, so a copy
516
+ operates on `.diags` and can no longer drop them — the copy-loss is impossible
517
+ by construction. Every flag-reading consumer was migrated in lockstep (the
518
+ cascade `readInconclusive`/`readBoundToCurrentDisk`/`isConfirmedTouch` and its
519
+ `.filter()` site in `clients/dispatch/integration.ts`, the dispatch LSP runner,
520
+ the `lsp_diagnostics` tool, the workspace-sweep `lens_diagnostics mode=full`
521
+ path, and the warm-attach IPC producer — which continues to re-surface
522
+ `inconclusive` as an enumerable DTO field over the socket). Behavior is
523
+ IDENTICAL — same flags, same values, same decisions; only the carriage
524
+ changed. `getAllDiagnostics`'s per-entry `binding` is DELIBERATELY left as a
525
+ lazy non-enumerable getter on the documented read-off-original contract (making
526
+ it enumerable would fire the per-file disk stat+hash on any incidental spread —
527
+ the stat storm its laziness exists to prevent). Separately closes a latent P3
528
+ from the #1108 review: `getGraphBuildInfoForGraph`'s global-slot fallback could
529
+ serve a SIBLING graph's build-info on a `_graphBuildInfoByGraph` identity miss,
530
+ feeding the `graph_degraded` marker gate a (possibly healthy) sibling verdict —
531
+ a new `graphBuildInfoIsTrustworthy` guard fails CLOSED so an unstamped/rehydrated
532
+ graph surfaces an honest degraded/unknown advisory instead of a #533 false
533
+ clean. Inert on the live path (every build stamps its returned graph before the
534
+ cascade reads it). An ast-grep rule remains not viable (AGENTS.md shape 5 is
535
+ semantic; cross-ref #1158).
536
+ - **Per-entry widget observation timestamps: a cross-file cascade merge no longer over-clears a whole footer record, dropping only the genuinely-stale entries (closes #1186, refs #1093 #1092 #1020)** —
537
+ `reconcileCascadeNeighborLspErrors` → `commitDiagnostics` used to stamp the
538
+ ENTIRE merged record's single `touchedAt` with the incoming `observedAt`
539
+ (e.g. a passive snapshot's `entry.ts`, up to ~240s old), including PRESERVED
540
+ entries observed more recently. If the neighbor's mtime later fell between
541
+ that stale stamp and a preserved entry's true observation time,
542
+ `reconcileStaleWidgetFiles` dropped the WHOLE record — losing the newer
543
+ preserved findings (the residual documented at `clients/dispatch/integration.ts`).
544
+ `WidgetDiagnostic` now carries a per-ENTRY `observedAt`: `normalizeDiagnostics`
545
+ stamps each incoming entry at its observation time, the cascade merge keeps
546
+ each preserved entry's own prior stamp (never re-aging a fresh finding to the
547
+ incoming stamp), and `reconcileStaleWidgetFiles` gates per ENTRY — dropping
548
+ only entries observed before the file's current mtime, keeping the record when
549
+ any survive, and dropping the record only when empty (a clean, finding-less
550
+ record still gates on `touchedAt`). `PersistedWidgetState` bumps v1→v2:
551
+ `importWidgetState` accepts a v1 snapshot and migrates each stampless entry to
552
+ inherit the record's `touchedAt` (a safe, over-conservative default; a
553
+ missing/non-numeric version or a future version this build can't understand is
554
+ still rejected — the pre-existing strictness is preserved). The sibling gates
555
+ `dropStaleFiles`
556
+ (session-state-store) and `reconcileProjectDiagnosticsSnapshot` are unchanged
557
+ — both compare against a single save/scan time ≥ every entry's observation, so
558
+ a whole-record drop there already equals dropping every entry. The
559
+ `markDependentsUnverified`/seq-stamp half of #1093's sketch is a separate,
560
+ non-contained follow-up (left open under #1093).
561
+ - **The `runtimeExitWindow` breaker map never dropped a stale/aged-out key, and its cooldown comment described the wrong call order (closes #1183, refs #1142 #1181)** — two P3 hygiene follow-ups from the #1181 review of the #1142 windowed-rate breaker in `clients/lsp/index.ts`. (1) `runtimeExitWindow` was only ever `delete`d on the currently-unreachable optional-trip path, so a key that crashed once then recovered kept a stale timestamp array forever (bounded by servers×roots, not a leak, but untidy). The "survived past the threshold" branch now also drops the key once every entry in its window has aged out, mirroring where `runtimeExitCounts` is already `delete`d there — anchored on the death's own `exitedAt` (not `Date.now()`) so it only ever fires for deaths `recordRuntimeExitWindow` declined to record (over the sleep-gap ceiling, or a missing `exitedAt`), never for a death that was just added to the window. (2) The `Math.max` cooldown comment claimed it "never shortens a longer cooldown the fast path may have just set," which has the ordering backwards — `state.broken` is `delete`d at the top of the method, so the window's `.get(key) ?? 0` always reads 0, and the fast path runs AFTER this block. Reworded to describe the actual ordering; harmless either way since the non-optional branch latches `permanentlyBroken` and the optional branch is currently unreachable. Comment-only for (2); pure map hygiene for (1) — trip logic, thresholds, and the fast path are unchanged.
562
+ - **`allowScripts` entries had drifted from what the declared dependency ranges actually resolve to, so npm v12's exact-version script-approval check saw stale pins (closes #1176)** —
563
+ `package.json`'s `@ast-grep/cli` dependency range is `"^0.45.0"`, but the
564
+ `allowScripts` map still keyed its approval on `"@ast-grep/cli@0.44.1"` — a
565
+ version the range can no longer even resolve to. pi-lens has no
566
+ `allowScripts`-enforcing tool of its own (no `@lavamoat/allow-scripts`, no
567
+ `.npmrc` script policy), so the field does nothing for pi-lens's own
568
+ `npm ci`; its only audience is an external consumer or vetting tool reading
569
+ the *published* package. The published package ships **no lockfile**
570
+ (`package-lock.json` is not in the `files` array and there's no
571
+ `npm-shrinkwrap.json`), so that consumer fresh-resolves `"^0.45.0"` against
572
+ the registry — which lands on `0.45.1` (registry `latest`; only `0.45.0`
573
+ and `0.45.1` exist in the `0.45.x` line), exactly what the original report
574
+ observed. The correct target is therefore the **freshest in-range /
575
+ fresh-resolve version**, not the locally-committed lockfile's resolution —
576
+ so the entry is `"@ast-grep/cli@0.45.1": true`, and the committed
577
+ `package-lock.json` was also bumped (`npm install @ast-grep/cli@0.45.1`,
578
+ an in-range patch bump — the `^0.45.0` dependency range is unchanged) so
579
+ pi-lens's own dev tree and a fresh consumer resolve both land on the same
580
+ version. `0.45.1` ships the same `postinstall` script `0.44.1` needed
581
+ approval for. Swept every other `allowScripts` member for the same drift
582
+ class (a pinned exact version no longer matching the freshest version its
583
+ governing range resolves to): `@google/genai@1.52.0` (transitive via the
584
+ `@earendil-works/pi-coding-agent` devDependency) already matched, unchanged;
585
+ `protobufjs@7.6.4` (also transitive via `@earendil-works/pi-coding-agent`)
586
+ had drifted the same way and moved to `"protobufjs@7.6.5": true` — the
587
+ freshest in-range version, which here happens to match both the lockfile
588
+ and a fresh resolve. `@ast-grep/napi` (also range `^0.45.0`) was re-checked
589
+ for an install-time script at both `0.45.0` and `0.45.1` (`npm view
590
+ @ast-grep/napi scripts`) and has none in either, so it correctly has no
591
+ `allowScripts` entry. `fsevents@2.3.3` also has an install script
592
+ (`hasInstallScript: true`) but is intentionally NOT listed: it's
593
+ `dev:true, optional:true, os:["darwin"]`, so it's never part of a
594
+ consumer's production install and out of scope for this allowlist — noting
595
+ it here rather than leaving the omission silent. No other version-pinned
596
+ metadata (`overrides`/`resolutions`/`pnpm.overrides`/`packageManager`)
597
+ exists in `package.json` to drift, and a repo-wide grep for hardcoded
598
+ `0.44.`/`0.45.` ast-grep version literals outside `package.json`/the
599
+ lockfile turned up only historical comments in test files and one rule
600
+ doc-comment describing ast-grep's behavior as of a past version — not pins
601
+ that need to track the current range.
602
+ - **`switch-case-termination` false positives for returning try/catch and exhaustive conditionals (closes #1079)** — the `no_terminating_statement`
603
+ post-filter only checked whether a case's last statement was a literal
604
+ terminator, so a case ending in `try { return … } catch { return … }` (or an
605
+ exhaustive `if/else`) was flagged even though every path returns. It now
606
+ runs a bounded, fail-safe control-flow analysis: trailing blocks,
607
+ `try/catch/finally` (a returning try completes after finally; a try body
608
+ that completes normally only terminates via a finally), and exhaustive
609
+ `if/else` (no else still falls through). It also honors an intentional
610
+ `// fallthrough` marker comment (ESLint `no-fallthrough` convention),
611
+ checked against comment nodes only so a `case "fallthrough":` value can't
612
+ suppress a finding. Genuine fall-throughs are still reported. TS + JS
613
+ regression fixtures cover grouped labels, try/finally, and intentional
614
+ fallthrough.
615
+ - **Non-enumerable side-channel flags — the shape-5 copy-loss sweep (refs [#1108](https://github.com/apmantza/pi-lens/issues/1108))** — Audited every non-enumerable / identity-scoped side-channel flag hung on a returned diagnostics or graph value for the copy-loss class that bit as #1094 (`inconclusive`) and #1096 (`binding`): the `inconclusive` and `binding` flags on `touchFile` results, the lazy `binding` getter on `getAllDiagnostics` entries, and the `_graphBuildInfoByGraph` / `_graphImportChanges` WeakMaps keyed by `ReviewGraph` identity. All five are safe — every consumer reads the flag off the ORIGINAL producer object before any `.filter()`/`.map()`/spread, the one genuine JSON boundary (the warm-attach IPC socket) already re-surfaces `inconclusive` as an explicit enumerable response field, and both WeakMaps fail safe on a copy/rehydrate miss (a global-slot fallback for build-info; a full-rebuild + generation-gate for the import delta). Hardened the contract durably where it is not owned by an in-flight PR: documented the "read off the original, never a derived copy; re-surface as an enumerable field to cross a serialization boundary" carriage contract on the `DiagnosticBinding` type, and added an IPC-boundary guard test asserting the consumer still honors an inconclusive answer carried as an enumerable field across the JSON round-trip. The producer-side type annotations and the structural `{ diags, inconclusive, binding }` wrapper migration live in the LSP index (owned by the #1142 breaker work) and are deferred to a tracked follow-up ([#1179](https://github.com/apmantza/pi-lens/issues/1179)); an ast-grep rule is not viable (AGENTS.md shape 5 is semantic — good and bad uses are syntactically identical).
616
+ - **LSP runtime-exit breaker missed persistent low-frequency crash loops (closes #1142, refs #1127 #1139)** —
617
+ #1139's `runtimeExitCounts` counts a client death toward the circuit breaker
618
+ only when its lifetime (`exitedAt - spawnedAt`) is under
619
+ `RUNTIME_EXIT_UPTIME_THRESHOLD_MS` (60s) — the hot crash loop (opengrep dies in
620
+ seconds). A death OVER the threshold falls into the "survived → reset the
621
+ streak" branch, so a server that reliably dies at ~65-90s after every spawn
622
+ NEVER tripped: every death reset the streak and it churned forever, a slow loop
623
+ the hard-cutoff design structurally can't detect. Added a SECOND, independent
624
+ windowed-rate trip that COMPOSES with (does not replace) the fast path:
625
+ `RUNTIME_EXIT_WINDOW_TRIP_COUNT` (5, = `BROKEN_PERMANENT_AFTER`) non-intentional
626
+ deaths within a rolling `RUNTIME_EXIT_WINDOW_MS` (15 min) window trip the
627
+ breaker regardless of each death's individual lifetime. N/M err against
628
+ false-tripping: five clustered non-intentional deaths is definitionally a loop,
629
+ and sparse benign crashes age out of the rolling window before five coincide.
630
+ The fast path is byte-for-byte unchanged — a hot loop still trips at exactly 5
631
+ consecutive early exits, never delayed by the window. Benign over-threshold
632
+ "deaths" are excluded: intentional teardowns (user restart, config/workspace
633
+ reload, session change, #743 eviction, generation handoffs) are gated out by the
634
+ existing `!wasShutdownIntentional()` guard the windowed trip shares, and a
635
+ death whose lifetime exceeds a 10-min ceiling — a genuinely long healthy run, or
636
+ an `exitedAt - spawnedAt` inflated across a machine-sleep/Modern-Standby suspend
637
+ (the #1122/#1139 death-timestamp lesson) — is not recorded, backed structurally
638
+ by per-server-key windowing (one suspend kills at most one live client per key).
639
+ The death-timestamp window is bounded on BOTH axes (prune aged-out entries +
640
+ hard-cap the array at the trip count, drop-oldest) and needs no timer (ages by
641
+ prune-on-check). Fail-then-pass regression tests cover all three cases: the slow
642
+ ~65-90s loop now converges (verified failing on pre-fix — the whole bug), the
643
+ hot loop still trips fast (no regression), and a sleep-gap/long-run death,
644
+ sparse aged-out crashes, and deliberate restarts do NOT trip.
645
+ - **Closed out the three #1134 P3 tails left after #1138's Svelte oxfmt support (closes #1134)** —
646
+ 1. **TOML line-match heuristic limits.** `hasOxfmtSvelteConfig`'s
647
+ `OXFMT_SVELTE_TOML_TRUE` regex required `svelte = true` to be
648
+ immediately followed by end-of-line/end-of-file, so a trailing inline
649
+ comment (`svelte = true # enable`) false-negatived. Relaxed the
650
+ trailing match to tolerate an optional `#`-comment, and added a
651
+ docblock sentence documenting the heuristic's remaining known limits
652
+ (it is a line match, not a TOML parser — a `[table]`-sectioned or
653
+ multi-line-string `svelte = true` occurrence could still false-positive;
654
+ accepted since that only causes oxfmt to be offered, never a silent
655
+ formatter failure). Added a fail-then-pass regression test for the
656
+ `svelte = true # comment` case (`tests/clients/tool-policy.test.ts`).
657
+ 2. **Monorepo asymmetry.** `hasOxfmtSvelteConfig`'s svelte-dependency check
658
+ stops at the nearest `package.json` (`hasNearestPackageJsonDependency`)
659
+ while its config walk goes all the way to the repo root
660
+ (`walkUpDirs`) — a root-level `svelte` dependency with a sub-package
661
+ `cwd` under-offers oxfmt for `.svelte` there. Documented via a
662
+ docblock sentence (no behavior change: this cell fails safe —
663
+ under-offering, never mis-offering a formatter that then errors at
664
+ runtime — and stays untested by design, matching the async
665
+ `.tflint.hcl` nearest-vs-root-walk note already in this file).
666
+ 3. **docs/language-coverage.md's Vue row** showed formatter `—` though
667
+ `.vue` has been wired to `prettier` (default) + `oxfmt` (appended via
668
+ `OXFMT_SUPPORTED_EXTENSIONS`) since #1138 landed. Corrected the row to
669
+ `prettier, oxfmt`.
670
+ - **Rule-cache disk cache could replay a stale compiled rule set on a same-mtime+same-size rule-file edit (closes #1118, refs #1105 #878)** —
671
+ `clients/cache/rule-cache.ts`'s `computeRuleHash` fingerprinted every
672
+ effective rule file over METADATA only (`mtimeMs:size`) — the review-graph
673
+ first-filter without its content-hash CONFIRM step. A rule-file edit
674
+ preserving both mtime and byte size (git-checkout timestamp restoration, a
675
+ same-length tweak, a formatter that preserves mtime) replayed a stale
676
+ compiled rule set from `<language>-rules-v6.json`, and because this is a
677
+ PERSISTED disk cache, the stale set was re-persisted under the
678
+ fresh-looking fingerprint, poisoning every future process (the #878
679
+ `force`-reload failure mode, one axis over). Fixed by splitting the
680
+ fingerprint on rule-file origin, mirroring the project/bundled split
681
+ `yaml-rule-parser.ts`/`ast-grep-napi.ts` already use for ast-grep rules
682
+ (#1105): the small, mutable PROJECT-LOCAL subset (under
683
+ `<project>/rules/tree-sitter-queries/`) now gets a content-hash CONFIRM on
684
+ top of the metadata fingerprint; the ~705 BUNDLED files (immutable within a
685
+ process) stay metadata-only, so `RuleCache.get`'s per-edit tree-sitter
686
+ runner hot-path cost is unchanged when a project has no rule overrides.
687
+ `CACHE_VERSION` bumped v6→v7 so a v6 entry persisted under the old
688
+ metadata-only formula (a potentially poisoned one) misses once on upgrade
689
+ instead of being trusted. Fail-then-pass regression tests pin a rule
690
+ file's mtime via `utimesSync`, edit its content while preserving byte size,
691
+ and assert the cache invalidates for project-local files but still hits
692
+ for bundled-only rule sets; a third test confirms a persisted
693
+ metadata-only-fingerprinted entry is rejected post-fix.
694
+ - **Formatter definitions and `FORMATTER_POLICY_BY_EXTENSION` were unbound hand-maintained inverse lists; added a bidirectional drift guard and fixed two latent mismatches (closes #1135; refs #1086 #1134 #883)** —
695
+ `clients/formatters.ts` (formatter → `extensions[]`) and `clients/tool-policy.ts`'s
696
+ `FORMATTER_POLICY_BY_EXTENSION` (extension → `formatterNames[]`) are hand-maintained
697
+ INVERSE mappings of the same relation with no test binding the two directions — the
698
+ #883/#209 single-source-of-truth class, and the general shape under #1134's oxfmt/.svelte
699
+ gap. Added `tests/clients/formatter-policy-consistency.test.ts`, which imports the real
700
+ `ALL_FORMATTERS` definitions and the policy maps and asserts both directions: every
701
+ definition extension is policy-included / a documented deliberate exclusion / a documented
702
+ no-policy fallback (so a definition gaining a policy-gated extension can't be silently
703
+ never-offered — #1134's exact symptom); and every policy `formatterName`/`defaultFormatter`
704
+ is a real formatter whose definition claims that extension or filename (so a broken option
705
+ can't be offered — including the terragrunt-hcl filename-keyed variant). Fixed two real
706
+ latent drifts surfaced by the guard, both behavior-identical (the mismatched entries were
707
+ already inert): the `.sass` policy listed `oxfmt` though oxfmt does not support `.sass`
708
+ (absent from `OXFMT_SUPPORTED_EXTENSIONS`), and the `.fish` formatter policy named
709
+ `fish-indent`, which has no `FormatterInfo` (it is a lint runner) — a dead, unsatisfiable
710
+ entry, now removed. A structural derive (tool-policy importing the definitions) was rejected
711
+ because `formatters.ts` already imports `tool-policy.ts`, so the reverse edge would create a
712
+ module import cycle; the test-based guard binds both directions while keeping the dependency
713
+ one-way. Also bound `AUTO_INSTALLABLE_DEFAULT_FORMATTERS` keys to real formatter definitions
714
+ (same formatter-name-reference class). The guard reads four newly-exported read-only symbols
715
+ (`ALL_FORMATTERS` plus the three policy maps `FORMATTER_POLICY_BY_EXTENSION`,
716
+ `FORMATTER_POLICY_BY_FILENAME`, `AUTO_INSTALLABLE_DEFAULT_FORMATTERS`), and its own two
717
+ allowlists (deliberate exclusions, no-policy fallbacks) each carry a minimality check so
718
+ they cannot rot into blanket escape hatches.
719
+ - **Pull-diagnostics entries never bound to document content, so they never demoted (closes #1104, refs #1095 #1096 #1100)** —
720
+ #1096 bound LSP diagnostics to a content fingerprint on the PUSH path
721
+ (`publishDiagnostics` version echo + send-time hash); the PULL path
722
+ (`textDocument/diagnostic`, `workspace/diagnostic`) recorded no fingerprint
723
+ at all, so pull-served cache entries read binding `"unknown"` forever and
724
+ the #1096 P2-1 service-sweep binding gate could never protect them. Fixed by
725
+ threading the server's `resultId` and a request-time content hash through
726
+ both pull requests: a `"full"` report is fingerprinted (the single-file path
727
+ reuses the exact sent-content hash `recordSentContent` already captures on
728
+ every didOpen/didChange — no extra read; the project-wide
729
+ `workspace/diagnostic` pull hashes disk bytes at request time since files
730
+ may not yet be open when it fires), and an `"unchanged"` report (now
731
+ requested via `previousResultId`/`previousResultIds`) inherits the prior
732
+ pull's diagnostics AND binding instead of being misread as a confirmed-clean
733
+ `[]` (the #570/#571 false-clean shape). The workspace pull-sweep record site
734
+ (`clients/lsp/index.ts`) now threads this `contentHash` into
735
+ `workspaceDiagnosticsCacheCtx.record()`, closing the gap the site's own
736
+ `#1095` doc comment used to document as intentional. Also closed an
737
+ AGENTS.md shape-5 gap in the sibling per-file touch path: `processFile` read
738
+ the `#1095` binding off the RAW `touchFile` diagnostics array before
739
+ `applyAuxiliarySuppressions`' `.filter()` rebuilds it (a `.filter()` copy
740
+ does not carry a source array's non-enumerable `.binding`), and
741
+ `LSPService.mergeBinding` no longer gates a contributor's `contentHash` on
742
+ that SAME contributor also carrying a `version` — pull bindings
743
+ legitimately carry a hash with no version, and the old gate silently
744
+ dropped it. Folded in two #1100-review P3s on the same surface: the
745
+ cascade's degraded/fallback display paths and the `lsp_binding_rejected`
746
+ indeterminate-advisory preamble (`clients/runtime-turn.ts`), which
747
+ previously reused the graph-unavailable wording even when the review graph
748
+ was fine and only the LSP display was withheld, now use a
749
+ binding-rejection-specific frame. Fail-then-pass regression tests cover:
750
+ the pull-sweep record site actually receiving a `contentHash`; a
751
+ pull-recorded entry whose content changed under a matching mtime being
752
+ demoted (not replayed) on the next sweep; the per-file touch path's binding
753
+ surviving the suppression filter; and the `"unchanged"`-report inheritance
754
+ on both the single-file and workspace pull protocols.
755
+ - **Config caches gated freshness on mtime alone — the mtime-only cache-freshness class sweep (closes #1105)** —
756
+ Completed the shape-6 (freshness stamp that doesn't cover the data's real
757
+ dependency) sweep the #1092→#1119 diagnostics/word-index arc deferred. Audited
758
+ every persisted/derived cache with an mtime/TTL key: word-index (#1119),
759
+ `rule-cache.ts` (`mtime:size`), `sgconfig.ts` (content-hashed), the
760
+ `yaml-rule-parser` project/bundled split (project rules content-hashed via
761
+ `loadYamlRulesFresh`, bundled dir-mtime is safe by process-lifetime
762
+ immutability), `reverse-deps` (snapshot-seq/generation-coupled), installer
763
+ `probe-cache` (existence re-validated every read + ast-grep version-family
764
+ verify — mtime is a refresh hint, not the correctness gate), `TreeCache`
765
+ (content-hash authoritative, #890), and the project-snapshot per-file
766
+ `mtime:size` entries — all already-hardened or safe. The one gap: the
767
+ `.pi-lens.json` config caches gated reuse on the config file's **mtime alone**,
768
+ so an in-place edit that preserved mtime (git checkout timestamp restoration, a
769
+ same-second rewrite) but changed the file's byte length replayed a stale parsed
770
+ config / ignore matcher — a config that drives mutation, ignore, and rule
771
+ policy. This spanned every freshness gate in the two files: the root parsed-
772
+ config caches (`loadPiLensProjectConfig`/`loadPiLensConfigInDir` in
773
+ `clients/project-lens-config.ts`), the root ignore-matcher cache, and — the
774
+ member found in adversarial review — the **nested `.pi-lens.json`/`.gitignore`
775
+ layering cache** (`patternsForDir`, #783) in `clients/file-utils.ts`, which
776
+ short-circuits AHEAD of the root caches so a preserved-mtime, length-changing
777
+ edit to a NESTED config replayed stale patterns for that subtree. Fixed by
778
+ adding **`size` as the free second axis** of the review-graph `size:mtimeMs`
779
+ signature to every gate — the root `.pi-lens.json` (threaded through
780
+ `PiLensConfigMarker` in `clients/workspace-topology.ts`), the root `.gitignore`
781
+ and global `~/.pi-lens/config.json` (via a shared `fileFreshnessSignature`
782
+ helper), and both the nested `.pi-lens.json` and nested `.gitignore` axes. The
783
+ same stat that yields mtime already reads size, so the cheap hit path stays
784
+ cheap (no content hashing on the hot path). The residual (identical mtime AND
785
+ identical size, changed content) matches the review-graph/word-index accepted
786
+ residual by design. Fail-then-pass regression tests cover the root and nested
787
+ gates (each proven to replay stale on the pre-fix mtime-only code); FS-agnostic
788
+ (mtime pinned via `utimesSync`, size varied by content length) so they exercise
789
+ the gates identically on Linux CI.
790
+
791
+ - **Windows drive-root & autofix-snapshot `readdirSync` blocked the event loop on slow cloud-backed dirs (refs #1137)** —
792
+ the genuine synchronous event-loop-block tier from #1122 (the 1.6–10.6 s
793
+ single-process blocks in `latency.log`, distinct from the machine-level
794
+ Modern-Standby/commit-exhaustion artifacts dispositioned in #1122 and tagged
795
+ `suspectSystemStall` by #1125). On a OneDrive/network-backed path a stalled
796
+ synchronous directory read blocks the Node loop — and pi's TUI — for the whole
797
+ stall. Converted the two clearly-live-path, low-risk offenders: (a) the Windows
798
+ Ruby drive-root enumeration (`readdirSync(driveRoot)` scanning `C:\` for
799
+ `ruby<N>` installer dirs), previously re-run **synchronously on every LSP spawn**
800
+ in `buildAugmentedPath` (PATH build) and on every Ruby candidate build in
801
+ `rubyBinCandidates` — now a shared `clients/lsp/ruby-drive-dirs.ts` module that
802
+ **memoizes** the result once per process (O(1) amortized) and reads the drive
803
+ root **off the loop** via `fs.promises.readdir` on the hot spawn path; and
804
+ (b) the `tool_result` autofix side-effect snapshot walk (`snapshotDirInto`,
805
+ `clients/pipeline.ts`), whose per-directory `readdirSync`/`statSync` are now
806
+ `fs.promises.readdir`/`stat` (the walk was already async + chunk-yielding, but
807
+ each synchronous per-dir read still blocked on a cloud stall). Same outputs,
808
+ just non-blocking. `measureMaxSyncBlockMs` occupancy guards assert the converted
809
+ drive-root path no longer holds the loop and fail against the pre-conversion
810
+ sync shape. Remaining suspect sites (the shared `walkTreeStackAsync` per-dir
811
+ read in `clients/source-walker.ts`; `hasProjectMarker`/`expandWorkspacePattern`
812
+ deep in sync memoized/bounded chains; the `safe-spawn.ts` Windows `spawnSync`
813
+ teardown/cached paths) are deferred as scoped follow-ups on #1137 — each ripples
814
+ into a widely-called sync API or subprocess-teardown correctness.
815
+ - **`session_start_sequence_read` was an unbounded synchronous blocking read on the session_start hot path (closes #1162)** —
816
+ `readLatestProjectSequence` called `fs.readFileSync` on the project
817
+ change-log before `session_start_total` returned; normally ~2ms, but under
818
+ host I/O pressure it had no escape hatch and was observed to balloon to
819
+ 2125ms in production latency.log. A `setTimeout`/`Promise.race` timeout
820
+ cannot preempt a synchronous read (the thread only returns to the event
821
+ loop once the OS call returns), so the fix adds an async twin
822
+ (`readLatestProjectSequenceAsync`, `fs.promises.readFile`) and races it
823
+ against a 250ms budget (`PI_LENS_SEQUENCE_READ_BUDGET_MS`-overridable) in
824
+ both the quick-mode and full-mode session_start paths. On a healthy read
825
+ (the common case) this adds ~zero overhead; on a stalled read, session_start
826
+ proceeds immediately with the safe cold-start sequence (only gates
827
+ snapshot freshness, never correctness) while the real read finishes in the
828
+ background and re-seeds the runtime — skipped for a one-shot `pi --print`
829
+ process via `isPrintMode()`, screening the #1154/#1153 one-shot
830
+ referenced-handle retention class. The fallback is never silent: the
831
+ `session_start_sequence_read` latency line now carries a `timedOut` flag,
832
+ and a background reseed logs its own
833
+ `session_start_sequence_read_deferred_reseed` phase. Fail-then-pass
834
+ regression tests inject a controllable slow read and assert session_start
835
+ returns within budget, falls back to cold-start with the flag set, and
836
+ still seeds normally on the healthy path. Adversarial review (#1168) caught
837
+ two P3s in this exact stall regime, both fixed in the same PR: (1) the
838
+ background reseed's `isCurrentSession` guard caught a cross-session move-on
839
+ but not a SAME-session advancement — an edit landing in the stall window
840
+ could have its `bumpFileSeq` result clobbered by a late reseed of the
841
+ pre-edit state; fixed with a `runtime.projectSeq > 0` guard (the cold seed
842
+ always sets it to exactly 0, so `> 0` at reseed time can only mean an
843
+ in-window bump happened). (2) The cold sentinel's `projectSeq: 0` was
844
+ indistinguishable from a project's legitimate first-ever snapshot (also
845
+ persisted at `seq === 0`), so a timed-out read could hydrate a stale seq-0
846
+ snapshot as fresh; fixed with a dedicated `UNKNOWN_PROJECT_SEQ` (`-1`)
847
+ sentinel fed only to the freshness check (never to `runtime.projectSeq`
848
+ itself, keeping fix (1)'s guard valid). Both have their own fail-then-pass
849
+ regression tests.
850
+ - **Resource-sampler Windows CIM spawns were not `.unref()`'d, the same one-shot-retention shape as the orphan reaper (refs #1155)** —
851
+ `clients/resource-sampler.ts`'s two Windows-only `Get-CimInstance Win32_Process`
852
+ spawns (`findDescendantPidsWindows`'s descendant-tree lookup and
853
+ `sampleProcessesWindows`'s CPU/RSS query) used `stdio:["ignore","pipe","ignore"]`
854
+ with a piped, `data`-listener-attached stdout and neither the child nor its
855
+ stdout was ever `.unref()`'d — the same shape #1153/#1160 fixed for the orphan
856
+ reaper (shape 4 of AGENTS.md's recurring-defect catalog: a referenced handle
857
+ that outlives a one-shot settle). The sampler was empirically absent under a
858
+ trivial `pi --print` prompt (its own `setInterval` was already unref'd, and it
859
+ only runs bracketed to an awaited analyzer spawn), but was not safe by
860
+ construction for a file-editing repro that does exercise it. Fixed by
861
+ extracting the reaper's `unrefReaperChild` AND its `spawnCollectStdout`
862
+ spawn→pipe-stdout→resolve-on-close plumbing into a shared, dependency-free
863
+ `clients/child-unref.ts` (`unrefChildAndPipes`, `spawnCollectStdout`) and
864
+ calling `spawnCollectStdout` at both sampler spawn sites — a single source
865
+ of truth for both modules instead of a second hand-rolled copy (the
866
+ promotion also resolved a SonarCloud new-code-duplication gate failure:
867
+ adding an identical `unrefChildAndPipes(child)` line to both near-identical
868
+ spawn blocks had pushed duplicated-line density over the 3% threshold;
869
+ collapsing both blocks to parse-only call sites around the shared helper
870
+ removed the duplication instead of adding to it). Unref only detaches this
871
+ child ALONE from keeping a settled one-shot alive; in an interactive/
872
+ long-lived session (or one bracketed to real analyzer work) the loop stays
873
+ referenced for other reasons, so sampling is unaffected — the parse logic
874
+ at both call sites is otherwise unchanged, so a spawn/error failure still
875
+ resolves to the same empty/partial result as before. Fail-then-pass
876
+ regression tests assert both spawn sites unref the child and its stdout, in
877
+ both the sampler and (unchanged) the reaper.
878
+ - **`toProjectRelativePath` never relativized a Windows-shaped path off native Windows (closes #1163, refs #1150/#1152/#1161/#1024)** —
879
+ shape-2 bug-class sweep of the `path.*`-on-cross-shaped-input hot zone.
880
+ `clients/path-utils.ts:toProjectRelativePath` used the host-default
881
+ `path.isAbsolute`/`path.relative` even when the input was Windows-shaped
882
+ (drive letter or UNC). On Linux CI, `path.isAbsolute("C:\\repo\\src\\x.ts")`
883
+ is `false` (no POSIX leading slash), so the function short-circuited and
884
+ returned the whole absolute path instead of the project-relative `src/x.ts`
885
+ it produces on Windows — a persisted call-graph symbol-key path or graph
886
+ display path (via `module-report`/`lens-map`'s `toDisplayPath` and
887
+ `call-graph`'s `formatImpact`) rendered as a full absolute path on Linux
888
+ (green-locally / wrong-on-CI, the #1024 divergence class). Fixed
889
+ shape-conditionally (`isWindowsPath(p) ? win32 : path`, the #1152 idiom):
890
+ a Windows-shaped path is parsed with `win32.*` on ANY OS; native same-OS
891
+ paths are unchanged. Fail-then-pass regression tests feed `C:\...`/UNC
892
+ literals as INPUT and assert the relative result on any OS (meaningful on
893
+ Linux CI). The rest of the swept hot zone
894
+ (`widget-state`/`file-utils`/`call-graph`/`installer`/`elixir-check`, plus
895
+ `file-role` #1152 and `resolveNonExisting` #1150 already fixed) was audited
896
+ and cleared as native-by-design — inputs are real on-disk paths the running
897
+ OS produced (cwd/project-roots/scanned files, `path.resolve`'d first) or
898
+ already fold through `normalizeEphemeralMapKey`/`PathKeyedMap`/the
899
+ regex-based `parseSymbolKey`.
900
+ - **`generated-artifacts.ts` used module-default `path.basename` on Windows-shaped paths, under-detecting lockfiles/declarations off native Windows (closes #1161, sibling of #1150/#1152)** —
901
+ `hasStrongGeneratedArtifactPath` (lockfile match), `hasWeakGeneratedFileNamePattern`
902
+ (name-pattern match), and `isDeclarationFile` (`.d.ts`/`.d.mts`/`.d.cts` match)
903
+ all took the module-default `path.basename(filePath)` on shape-committed
904
+ input. On Linux CI, `path.basename("C:\\proj\\package-lock.json")` finds no
905
+ POSIX separator and returns the whole string unchanged, so
906
+ `LOCKFILE_NAMES.has(...)` misses — a Windows-shaped lockfile or declaration
907
+ path was silently treated as ordinary source. `generated-artifacts.ts` is
908
+ imported by `file-role.ts`'s `"generated"` branch, so this residual sat
909
+ within `detectFileRole`'s own call tree even after #1152 fixed the
910
+ dir-segment/basename split there. Fixed with a shared `basenameForShape`
911
+ helper that routes through `path.win32.basename` when `isWindowsPath`
912
+ (exported by #1152) is true, mirroring `file-role.ts`'s fix exactly —
913
+ shape-conditional, not shape-committed, so native-OS classification is
914
+ unchanged. The strong directory-segment match (`pathSegments`, which splits
915
+ on `[\\/]+`) was already shape-safe and untouched. Fail-then-pass regression
916
+ tests cover a `C:\...`-shaped lockfile and `.d.ts` literal.
917
+ - **Quick-mode background warmup kept a one-shot `pi -p`/`--print` process alive (closes #1154)** —
918
+ `handleSessionStart` forces **quick mode** for both a real `pi -p`/`--print`
919
+ one-shot AND an interactive process's first session (to protect keystroke
920
+ latency, then warm caches for the next `/new`). Quick mode scheduled a +2s
921
+ background warmup that (1) armed a `setTimeout` that was **not** `.unref()`'d —
922
+ violating the repo-wide convention that every background timer is unref'd
923
+ (`clients/runtime-session.ts` previously had zero `.unref()` calls) — and
924
+ (2) launched LSP-prewarm children + a language-profile source walk that
925
+ outlive settle with **no `session_shutdown` abort**. In a one-shot (which
926
+ exits right after the turn and has no future session to warm) both are pure
927
+ waste AND referenced-handle keep-alives that hold the settled process open —
928
+ the located **#1122 hypothesis-A** concern and a member of the
929
+ one-shot-retained-by-a-referenced-handle class of #1097/#1110/#1148/#1149.
930
+ Fixed by (a) skipping the warmup entirely in print mode via a new shared
931
+ `isPrintMode()` helper (`clients/print-mode.ts`) — an interactive first
932
+ session (quick but not print) still warms — and (b) `.unref()`'ing the warmup
933
+ timer as defense-in-depth + convention conformity. Also unref'd the adjacent
934
+ full-mode `scheduleStartupScans` deferred timers (same latent violation, not a
935
+ one-shot member today). This resolves the #1122 hypothesis-A tail (cross-ref
936
+ #1122). Fail-then-pass regression tests assert the warmup does not run in print
937
+ mode and the scheduled warmup timer is unref'd.
938
+ - **`detectFileRole` misclassified a Windows-shaped path off native Windows (closes #1152, latent sibling of #1150/#1151)** —
939
+ `clients/file-role.ts:detectFileRole` used the module-default `basename`/
940
+ `dirname` (POSIX on Linux) even when the input path was Windows-shaped
941
+ (drive letter or UNC prefix). A backslash-only `C:\...` path has no `/`
942
+ for POSIX `dirname` to find, so it collapsed to `"."` and the dir-based
943
+ role branches (`/tests/`, `/spec/`, generated-marker dirs) silently
944
+ misclassified — the byte-identical forward-slash form already worked,
945
+ since POSIX `dirname` handles `/` regardless of the leading drive
946
+ letter. `detectFileRole` is platform-native by design, so the fix is
947
+ shape-conditional rather than shape-committed (unlike #1151's
948
+ `resolveNonExisting`, which is already win32-committed): a Windows-shaped
949
+ path (per the now-exported `isWindowsPath`, `clients/path-utils.ts`) is
950
+ parsed with `path.win32.basename`/`dirname` regardless of the running
951
+ OS; a same-OS-native path is unaffected (native win32 already equals the
952
+ module default there; native POSIX was never win32-shaped). Added
953
+ regression coverage in `tests/clients/file-role.test.ts` asserting
954
+ coherent `"test"`/`"init"` classification for `C:\...`, `C:/...`, and
955
+ UNC-shaped inputs regardless of the running OS.
956
+ `clients/lsp/server.ts:639`'s `normalizeSlashKey(path.dirname(path.resolve(file)))`
957
+ (flagged in #1151's review as the same shape) was audited and verified
958
+ **safe, not fixed**: `path.resolve(file)` runs before `dirname`, so by
959
+ the time `dirname` sees it the value is already coerced to the running
960
+ OS's native absolute-path shape — `file` here is always a real on-disk
961
+ path produced by this process's own directory walking (extension-root
962
+ resolution backed by real `existsSync`/`stat` probes, confirmed via
963
+ `tests/clients/lsp/typescript-extension-root.test.ts`), never a
964
+ persisted or cross-OS-supplied literal. Same "platform-native by
965
+ design" exemption #1151 already applied to `path-utils.ts`'s
966
+ `walkUpDirs`/`findNearestContaining` family.
967
+ - **Orphan-reaper fire-and-forget PowerShell/`ps` spawns kept a completed `pi --print` alive past settle (closes #1153)** —
968
+ the orphan reaper (`clients/instance-reaper.ts`) is fired fire-and-forget from
969
+ `session_start` (`index.ts` `sweepOrphans`/`sweepUntrackedOrphans`), not
970
+ awaited and not gated out of one-shot/`--print` mode. Its OS-process-table
971
+ enumeration spawns (`enumerateManagedProcesses`, `queryCommandLines`,
972
+ `findPidsByMarkerWindows` — PowerShell on Windows, `ps` on POSIX) used
973
+ `stdio:["ignore","pipe","ignore"]` with a `data` listener and **no `.unref()`
974
+ anywhere in the file**. A piped, listener-attached stdout stream keeps the
975
+ event loop REFERENCED until the child `close`s, and `sweepUntrackedOrphans`
976
+ guarantees ≥1 such PowerShell on every Windows `session_start` — so a settled
977
+ one-shot process could not exit until that PowerShell finished cold-starting
978
+ (routinely 300 ms–2 s). This is the child-process member of the referenced-
979
+ handle class (#1097/#1110 timers, #1148/#1149 worker ports). Fixed by
980
+ `unref()`-ing every reaper child AND its stdio pipes (a `child.unref()` alone
981
+ does not release a piped stdout that re-refs the loop) via a shared
982
+ `unrefReaperChild` helper applied at all six spawn sites (the five enumeration
983
+ spawns plus `killPidTree`'s `taskkill`). Unref, not a print-mode skip: the
984
+ reaper is a machine-wide orphan backstop, not a next-session-only concern, so
985
+ gating it out of `--print` would blind orphan cleanup on print-only machines
986
+ (CI/automation/subagents — exactly where one-shots dominate and orphans
987
+ accumulate); unref preserves the sweep in interactive sessions (the loop stays
988
+ referenced for other reasons, so every child's `close` still fires and the
989
+ sweep completes) while letting a genuinely-settled one-shot exit without
990
+ waiting. Regression test spawns a fake child per spawn site and asserts the
991
+ child + its stdout are unref'd (fails pre-fix, passes post-fix).
992
+ - **Persistence workers could keep completed one-shot processes alive ([#1148](https://github.com/apmantza/pi-lens/issues/1148))** — project-snapshot and review-graph workers called `unref()` before registering their `"message"` listeners, and Node re-referenced the public `MessagePort` when each listener was added. Both workers now install all lifecycle listeners before `unref()`, so persistence remains asynchronous without retaining an otherwise-finished `pi --print` or subprocess workflow. Real child-process regression tests require both persistence paths to finish writing and exit naturally.
993
+ - **`normalizeFilePath` mangled a Windows-shaped path on non-Windows OS (closes #1150)** —
994
+ `normalizeFilePath` commits to its win32 branch by path *shape*
995
+ (`isWindowsPath`), so a `C:/…`- or `C:\…`-shaped path enters that branch on
996
+ ANY OS — but `resolveNonExisting`'s upward walk used the module-default
997
+ `dirname` (POSIX on Linux) while the rest of the branch already used
998
+ `win32.resolve`/`win32.normalize`. On Linux the POSIX `dirname` found no
999
+ separator in the win32-resolved path, collapsed to `.`, stopped the walk at
1000
+ `process.cwd()`, and produced `<cwd>/file.ts` instead of the literal key — so
1001
+ a Windows-shaped path normalized to a DIFFERENT key on Linux than the
1002
+ byte-identical literal it is on Windows (the #1024 OS-divergence class,
1003
+ surfaced by #1139's green-on-Windows/red-on-Linux-CI). Fixed by using
1004
+ `win32.dirname` in the win32-committed branch, making the function internally
1005
+ coherent regardless of the running OS. Guarded by a both-OS-meaningful
1006
+ regression test (native win32 path on Windows; shape-committed win32 branch on
1007
+ Linux) and an AGENTS.md convention: tests must derive `normalizeMapKey`-keyed
1008
+ structure keys via `normalizeMapKey`, never hardcode a drive-letter literal.
1009
+ - **Workspace-edit preflight: five contained P3 deferrals from #1085 (refs #1085)** —
1010
+ `clients/lsp/edits.ts`:
1011
+ - **P3-2** `mergeWorkspaceTextEditsByPriority`'s exact-duplicate dedup
1012
+ collapsed identical zero-width inserts duplicated within ONE server's
1013
+ own edit on the `renameFile` merge path, contradicting the documented
1014
+ multiplicity invariant (several identical zero-width inserts at one
1015
+ point are meaningful, same as `validateTextEdits` on the normal apply
1016
+ path). The dedup now only applies to non-empty ranges; a genuine
1017
+ cross-server exact duplicate (two servers proposing the identical
1018
+ non-empty replace) still collapses to one.
1019
+ - **P3-3** an ordered edit `rename(b→c); create(b); textEdit(b)` was
1020
+ falsely rejected in preflight ("text edit target does not exist"): a
1021
+ `create` at a path vacated by an earlier rename in the same edit had
1022
+ `resolveVirtualPath` return `undefined` (correct — the path is
1023
+ virtually gone), so the created state was silently discarded instead
1024
+ of recorded. Added a `virtualOverrides` overlay, keyed on the raw
1025
+ query path, consulted only when `resolveVirtualPath` cannot resolve a
1026
+ physical address, so a later op at that same path (e.g. the trailing
1027
+ text edit) sees the re-established file.
1028
+ - **P3-4** `version: null` (LSP 3.17: "don't check") combined with a
1029
+ numeric version for the SAME uri across two edit containers incorrectly
1030
+ threw "conflicting text document versions". A numeric version is now
1031
+ authoritative and adopted over a `null`/`undefined` counterpart; two
1032
+ genuinely different numeric versions still conflict, and a numeric
1033
+ version adopted from a `null` pairing is still checked against the live
1034
+ document version (a stale adopted numeric is still rejected).
1035
+ - **P3-6** `fileDetails[].importsChanged` reported "the file contains any
1036
+ import statement" (`/^import\s/m.test(updated)`) rather than "this edit
1037
+ changed an import/re-export line", over-invalidating downstream
1038
+ dependency-graph re-checks (`cache-manager.ts`'s `importsChanged`
1039
+ filter, `lsp-mutation.ts`'s `addModifiedRange`) on every edit to a file
1040
+ that merely already had imports. Now compares an import/re-export-from
1041
+ line signature of the pre- and post-edit content; unchanged unless the
1042
+ edit actually touched those lines. `create`/`rename`/`delete` keep
1043
+ their existing conservative flags (a create writes an empty file;
1044
+ rename/delete are structural, not diffable against pre-edit text).
1045
+ - **P3-8** an ordered edit that CREATEs `foo.txt` then case-renames it to
1046
+ `Foo.txt` within the SAME edit fail-closed with "destination already
1047
+ exists" on a case-insensitive FS: the alias check (`isSameFsEntry`)
1048
+ lstats disk, but the just-created file exists only in the preflight's
1049
+ virtual overlay, never on disk yet. The rename branch now also treats
1050
+ a rename as an alias when the destination's cached `VirtualFile`
1051
+ object is referentially identical to the source's (both `stateFor`
1052
+ calls resolve through the same case-folded map key), before falling
1053
+ back to the physical `isSameFsEntry` probe — which is unchanged for
1054
+ genuinely-physical paths, so the #1024/#1120 ino-guard and
1055
+ case-sensitivity invariants are untouched.
1056
+
1057
+ **Deferred at the time (now fixed, see below):** P3-5 and P3-7 were
1058
+ rehomed to #1147; see the #1085 closing comment for that history.
1059
+ - **Workspace-edit CRLF boundary class + rename close-failure plaintext reopen (closes #1147, refs #1085)** —
1060
+ the two P3 deferrals left open after #1146:
1061
+ - **P3-5** (`clients/lsp/edits.ts`) — #1120 fixed only the past-EOL clamp
1062
+ member of the CRLF-boundary class. Two general members remained: (a) a
1063
+ `newText` containing a bare `\n` was spliced verbatim into a CRLF
1064
+ file's content, producing mixed line endings, because the LSP
1065
+ workspace-edit apply path (unlike the host-edit path) never
1066
+ EOL-normalized `newText`; (b) `utf16Position`'s past-EOL clamp only
1067
+ triggered on `character > wireLength`, so an in-bounds,
1068
+ caller-supplied `character === wireLength` on a line whose `\r` is
1069
+ folded into `lineTextAt`'s with-`\r` length landed the position
1070
+ squarely between `\r` and `\n`, splitting the pair on write. Fixed by
1071
+ (a) normalizing every `newText` through the exact
1072
+ `detectLineEnding`/`normalizeToLF`/`restoreLineEndings` contract
1073
+ `clients/host-edit-normalize.ts` already uses for the host-edit path
1074
+ (LF files are unaffected — `restoreLineEndings` is the identity for
1075
+ `"\n"`), applied once in `normalizeTextEditsForContent` so it covers
1076
+ both the preflight virtual-content chain and the final on-disk write;
1077
+ (b) clamping on `character > clampedWireLength` (the `\r`-stripped
1078
+ length) instead of the with-`\r` length, which subsumes and simplifies
1079
+ #1120's original past-EOL clamp into the same branch. All of #1120's
1080
+ existing invariants (single application-ordering sort, clamp, tie-break)
1081
+ are unchanged and covered by the full `edits.test.ts` suite.
1082
+ - **P3-7** (`clients/lsp/index.ts`) — the rename close-failure recovery
1083
+ path reopened the old document as a hardcoded `"plaintext"` languageId,
1084
+ degrading that server's diagnostics until the next genuine open. Now
1085
+ reopens with `getLanguageId(oldFilePath) ?? "plaintext"` — the same
1086
+ resolver every genuine `notify.open` call in this file already uses —
1087
+ so a recognized extension reopens with its real language ID and only a
1088
+ genuinely unrecognized extension still falls back to `"plaintext"`.
1089
+ - **Post-init runtime exits now count toward the LSP circuit breaker (closes #1127)**
1090
+ — `LSPService`'s (`clients/lsp/index.ts`) exponential-backoff breaker
1091
+ (`failureCounts` → cooldown → permanent-disable after
1092
+ `BROKEN_PERMANENT_AFTER`) only incremented on spawn/initialize failure.
1093
+ A server whose spawn SUCCEEDS but then exits shortly after (opengrep's
1094
+ post-init "Unhandled message" JSON-RPC crash, per #1122's Phase C
1095
+ corroborating-signal review — 37 respawns in one real session, never
1096
+ converging) hit the "dead client — needs respawn" path instead, which
1097
+ never touched the breaker: `failureCounts` was already cleared by the
1098
+ preceding successful spawn and the runtime exit itself was never counted.
1099
+ Fixed by adding a parallel `runtimeExitCounts` counter fed only by EARLY
1100
+ (lifetime < 60s) non-intentional exits, sharing the same cooldown formula
1101
+ and the same `state.broken`/`permanentlyBroken` maps as the existing
1102
+ breaker — tracked separately from `failureCounts` specifically because a
1103
+ successful respawn (which correctly resets the spawn/init failure streak)
1104
+ is not proof of health for a crash-loop server, so reusing that map would
1105
+ erase the streak on every respawn attempt (the #1127 bug). Deliberate
1106
+ teardowns (session reset, `#743` notify-backpressure eviction, generation
1107
+ handoffs) call `shutdown()` themselves before the process exits and set
1108
+ `shutdownRequested`; a new `wasShutdownIntentional()` accessor on
1109
+ `LSPClientInfo` (`clients/lsp/client.ts`) exposes that flag so the breaker
1110
+ distinguishes a genuine crash from a restart it initiated and never counts
1111
+ the latter. Adversarial review caught that lifetime was originally
1112
+ computed from the moment a dead client is lazily DETECTED (the next
1113
+ `getClientForFile` attach), not from when it actually died — #1127's
1114
+ documented pattern is attach-triggered respawns minutes to hours apart, so
1115
+ an early crash detected an hour later would misread as a long healthy run
1116
+ and never count. Fixed by stamping a real `exitedAt` on `LSPClientState`
1117
+ the moment the client's connection/process actually dies (first of
1118
+ `onError`/`onClose`/process `exit` to fire) and exposing it via a new
1119
+ `getExitedAt()` accessor; the respawn site computes lifetime as
1120
+ `exitedAt - spawnedAt`, falling back to the detection-time delta only when
1121
+ `exitedAt` is unexpectedly unset. New coverage in
1122
+ `tests/clients/lsp/service-runtime-exit-breaker.test.ts`: a crash-loop
1123
+ respawn sequence converges to permanent-disable instead of respawning
1124
+ forever (fails against pre-fix behavior), an early death with detection
1125
+ delayed by hours still counts (death time, not detection time, decides —
1126
+ fails against the pre-fix detection-time computation in both directions),
1127
+ a deliberate `shutdown()`-driven restart sequence never counts, a runtime
1128
+ exit past the lifetime threshold resets the streak instead of counting,
1129
+ and the REAL `#743` notify-write-backpressure eviction path (driven
1130
+ through `touchFile`, not a synthetic stand-in) never double-counts against
1131
+ this breaker. Full existing LSP suite (536 tests) stays green.
1132
+
1133
+ - **`terragrunt hcl fmt` reported success when it never ran (refs #1117)** —
1134
+ `formatFile` decided success from `result.error` alone, and a formatter that
1135
+ fails leaves the file byte-identical, which is indistinguishable from
1136
+ "already formatted". A terragrunt binary predating the `hcl` command group
1137
+ exits non-zero without touching the file, and pi-lens reported
1138
+ `success: true, changed: false`. `FormatterInfo` gained an opt-in
1139
+ `strictExitCode`, set on `terragrunt-hcl`, which folds the exit status into
1140
+ the check and surfaces the tool's own first stderr line as the error. It is
1141
+ opt-in rather than global because the lint-autofix formatters (`rubocop -a`,
1142
+ `ktlint -F`, `standardrb --fix`, `sqlfluff fix`) exit non-zero when offenses
1143
+ remain AFTER a successful rewrite; failing those would surface a formatter
1144
+ error on every file with an unfixable offense.
1145
+ - **Runners reported a clean file on a non-zero exit with no output (refs #1117)** —
1146
+ seven runners gated their "the tool never ran" skip on
1147
+ `result.error` alone. `safeSpawnAsync` sets `error` only for spawn, timeout,
1148
+ signal and abort failures, and resolves a NORMAL exit with no `error` at any
1149
+ status (see `SpawnResult.failure`: "nonzero exit statuses are not spawn
1150
+ failures"). So an unknown subcommand, a rejected flag, or a config that fails
1151
+ to load — non-zero exit, message on stderr, empty stdout — fell straight
1152
+ through the guard, parsed `""` into zero diagnostics, and reported the file as
1153
+ clean. Terragrunt was the visible case: its docblock claimed a binary
1154
+ predating the `hcl` command group was classified SKIPPED, and it was not. The
1155
+ test now lives in `spawnFailedWithNoOutput`
1156
+ (`clients/dispatch/runners/utils/spawn-outcome.ts`) and folds `status` into
1157
+ the check; terragrunt, tflint, hadolint, taplo, trivy-config, htmlhint and
1158
+ detekt all use it. Runners that exit non-zero BECAUSE they found something are
1159
+ unaffected, since their findings are on the stream the guard tests. ktlint,
1160
+ prisma-validate, gleam-check, zig-check, dart-analyze and elixir-check already
1161
+ covered the case through their own non-zero follow-up branches and are
1162
+ unchanged.
1163
+ - **Bare-binary GitHub release assets skip signature siblings** — the installer
1164
+ picked a release asset with `assets.find(a => a.name.includes(substring))`.
1165
+ Archive-based tools are unaffected (their substrings end in `.zip`/`.tar.gz`),
1166
+ but the three bare-binary entries (terragrunt, marksman, expert) resolve to
1167
+ the FULL asset name, which is a strict prefix of every `.asc`/`.sig`/`.sha256`
1168
+ sibling, so whichever the release listed first would be downloaded and marked
1169
+ executable as the binary. Selection now lives in an exported
1170
+ `pickReleaseAsset`: exact name first, then a substring match that excludes
1171
+ known sidecar suffixes.
1172
+ - **Cascade fallback-display paths re-displayed bound-false LSP snapshots (refs #1104)** —
1173
+ #1100 gated the cascade's RECONCILE path (the footer/widget) onto content
1174
+ binding (`boundToCurrentDisk`), but two DEGRADED-fallback DISPLAY paths in
1175
+ `clients/dispatch/integration.ts` still re-read TTL-fresh `getAllDiagnostics()`
1176
+ snapshots without consulting binding at all: the touch-error fallback (a
1177
+ failed active LSP touch falling back to the passive snapshot) and
1178
+ `appendFallbackNeighbors` (the CR-3/A2 degraded-fallback path when no
1179
+ neighbor produced trustworthy LSP data). A bound-false snapshot — diagnostics
1180
+ computed against a DIFFERENT disk state than what's currently on disk, e.g. a
1181
+ pre-fix-edit read — could still reach cascade OUTPUT even though the widget
1182
+ was protected. Both sites now apply the same false/`"unknown"`/true contract
1183
+ #1095/#1100 already established for reconcile: `false` → skip the stale
1184
+ display (logged via the cascade channel with `bindingState`), `"unknown"` →
1185
+ unchanged (the pre-existing fallback contract), `true` → display. HONESTY
1186
+ fix: filtering a display candidate could otherwise make a genuinely degraded
1187
+ cascade look clean, so when every fallback candidate a run considered was
1188
+ binding-rejected and nothing else produced output, the run now carries the
1189
+ same `indeterminate` marker #1023 built for a degraded graph compute (new
1190
+ `CascadeIndeterminateReason: "lsp_binding_rejected"`), so the turn-end
1191
+ advisory still surfaces an honest note instead of silence. The `resultId`
1192
+ pull-diagnostics plumbing that #1104 also tracks remains open — this covers
1193
+ only the cascade display-binding gap (#1100 review P3-1).
1194
+ - **`parseSymbolKey` mis-parsed LSP-fallback symbol kinds (refs #1088)** —
1195
+ the canonical-id parser whitelisted only the 7 kinds `buildSymbolId` mints
1196
+ directly, but `addLspFallbackSymbols` mints ids using the much larger
1197
+ `lspSymbolKindName` vocabulary (`enum`, `constant`, `struct`, `namespace`,
1198
+ `field`, `constructor`, `module`, the `lsp-symbol-<n>` catch-all, ...). Any
1199
+ of those ids failed the whitelist and fell through to the legacy
1200
+ last-colon split, shearing the id at the wrong boundary (e.g.
1201
+ `c:\p\a.kt:Color:enum:42` parsed as `filePath: "c:\p\a.kt:Color:enum"`,
1202
+ `symbolName: "42"`). `clients/review-graph/symbol-id.ts`'s
1203
+ `parseSymbolKey` now matches the trailing `:<kind-token>:<digits>` shape
1204
+ STRUCTURALLY (any lowercase, optionally hyphen-segmented token) instead of
1205
+ whitelisting specific kind strings, so it stays correct for the open-ended
1206
+ LSP-fallback vocabulary without needing a matching update per kind.
1207
+ - **Java try-with-resources suppression was dead (refs #1089)** —
1208
+ `clients/tree-sitter-client.ts`'s `not_closed_or_try_with_resources`
1209
+ post-filter built its resource-word regex as `` new RegExp(`\b${resource}\b`) ``
1210
+ inside a template literal, where `\b` is the BACKSPACE control character
1211
+ (U+0008), not a regex word-boundary escape. The regex could never match,
1212
+ so the Java 9 short-form `try (resource) { ... }` was never recognized as
1213
+ closing its resource — only an explicit `.close()` call suppressed the
1214
+ "resource should be closed" finding. Fixed to `\\b` plus a regex-escaped
1215
+ identifier. No shipped Java fixture used try-with-resources syntax, so no
1216
+ existing expected-findings fixture changes with this fix.
1217
+ - **Raw NUL byte in `review-graph/builder.ts` made the file look binary to grep (refs #1088)** — the checkpoint's `ignoredIds` join separator was a
1218
+ literal 0x00 byte embedded in source, which makes `ripgrep`/`grep` treat
1219
+ the whole 4,900+ line file as binary and stop scanning partway through —
1220
+ silently defeating the AGENTS.md-mandated repo-wide grep sweep for every
1221
+ commit that touched this file. Replaced the raw byte with the `\u0000`
1222
+ escape sequence, which is the byte-identical runtime string (same
1223
+ `hashIgnoredIds` function computes both the write-time and read-time
1224
+ hash, so no persisted checkpoint compatibility is affected).
1225
+ - **`buildCallGraph` same-file evidence accounting hardened + dead code removed (refs #1089)** — added regression coverage for the audited
1226
+ same-file evidence class (divergent path forms for the same file must
1227
+ both be recognized as same-file AND counted exactly once per reference,
1228
+ keeping the coverage sum invariant `validatePersistedCallGraph` enforces
1229
+ on every load); the normalized-compare fix for this had already landed
1230
+ upstream, so this closes out the finding with a fail-then-pass regression
1231
+ test rather than a behavior change. Also removed `clients/call-graph.ts`'s
1232
+ dead duplicate `saveCallGraph` overload signature (byte-identical to the
1233
+ implementation signature) and the unreachable `{}`-spread branch on the
1234
+ required `identity` parameter.
1235
+ - **JS destructured params dropped from `parameterCount` (refs #1089)** —
1236
+ `clients/dispatch/facts/function-facts.ts`'s `getParameters` recognized
1237
+ `required_parameter`/`optional_parameter` wrapper nodes (how TypeScript's
1238
+ grammar represents every parameter, destructured or not) but not the bare
1239
+ `object_pattern`/`array_pattern` nodes plain JavaScript's grammar uses
1240
+ for a top-level destructured parameter with no wrapper. `function
1241
+ f({a, b})` counted 0 parameters in a `.js` file while the TS-annotated
1242
+ equivalent counted correctly. Added JS-vs-TS parity tests.
1243
+ - **`call-graph.ts`'s exported `CACHE_VERSION` renamed to `CALL_GRAPH_CACHE_VERSION`** — collided in name (not value — separate
1244
+ modules) with `clients/cache/rule-cache.ts`'s own `CACHE_VERSION` export;
1245
+ renamed to match the `<SUBSYSTEM>_CACHE_VERSION` convention already used
1246
+ by `WORKSPACE_DIAGNOSTICS_CACHE_VERSION` and
1247
+ `PROJECT_DIAGNOSTICS_CACHE_VERSION`. Updated the two test-file imports.
1248
+ - **Dead SIGTERM→SIGKILL escalation guard on non-Windows kills (closes #1114)**
1249
+ — `clients/safe-spawn.ts`'s non-Windows `killTree` branch armed a 1s
1250
+ escalation timer gated on `if (!child.killed) child.kill("SIGKILL")`, but
1251
+ Node sets `ChildProcess#killed = true` the moment `kill()` successfully
1252
+ SENDS a signal — not when the child actually dies — so immediately after
1253
+ the `child.kill("SIGTERM")` one line above, the guard was always false
1254
+ and the SIGKILL escalation could never fire: a SIGTERM-ignoring child on
1255
+ Linux/macOS was never force-killed. Fixed by tracking OBSERVED death via a
1256
+ `closed` flag set synchronously (before any `await`) in the close/error
1257
+ handlers, gating the escalation on `!closed` instead — composes cleanly
1258
+ with the existing #1109/#1113 `escalationTimer` clear-on-close fix rather
1259
+ than switching to the LSP `killProcessTree` analog's unconditional-SIGKILL
1260
+ design, since `safeSpawnAsync` already has a real per-call close/error
1261
+ observation point to hang the flag off of. Proven with a new "child
1262
+ ignores SIGTERM → SIGKILL sent at the 1s mark" test
1263
+ (`tests/clients/safe-spawn-kill-escalation-timer.test.ts`) that fails
1264
+ against the pre-fix guard and passes post-fix; the existing #1109 timer-leak
1265
+ tests (escalation timer cleared when close/error DOES arrive) remain green.
1266
+ **Class sweep** of every `.killed` consumer under `clients/` and `scripts/`
1267
+ found two siblings of the same shape in `clients/lsp/client.ts`'s
1268
+ `killProcessTree`/`createLSPClient` and fixed both in this PR: (1) the
1269
+ `fast`-shutdown escalation timer checked `!proc.killed`, but the primary
1270
+ SIGTERM send there goes through the raw `process.kill(-pid, …)` process-group
1271
+ call (which never touches `proc.killed`), so the guard was either always-true
1272
+ (unconditional SIGKILL after the window on the common group-kill path) or
1273
+ dead (on the direct-child fallback path) — now tracked via a real `exit`
1274
+ listener set once up front, seeded from the same `exitCode`/`signalCode`
1275
+ pre-check the function's top-of-body early return already uses (a process
1276
+ that was already dead on entry — reachable when `options.processExiting`
1277
+ skips that early return — would otherwise miss its own "exit" event and
1278
+ still draw a redundant group SIGKILL at the escalation window); (2) the
1279
+ `initialize()`-timeout 2s SIGKILL backstop had the identical always-true
1280
+ `!lspProcess.process.killed` guard — switched to `lspProcess.process.exitCode
1281
+ === null && lspProcess.process.signalCode === null` (both, not `exitCode`
1282
+ alone: a process killed BY a signal — the common case here, since
1283
+ `killProcessTree` above it signals rather than lets the process exit on its
1284
+ own — has `exitCode === null` forever and only `signalCode` set, so
1285
+ `exitCode` alone still re-armed the backstop's kill against an
1286
+ already-dead corpse; harmless in practice since `ChildProcess#kill()` on an
1287
+ exited handle is a swallowed no-op, but not an accurate "still alive"
1288
+ read). Other `.killed` reads audited and left as-is because they're
1289
+ liveness/status checks, not escalation-action gates: `isClientAlive`'s
1290
+ `!state.lspProcess.process.killed` (redundant with `isDestroyed`, already
1291
+ set from real exit/close handlers), `checkProcessAlive`'s informational
1292
+ "was killed" health-check string, `launch.ts`'s post-spawn
1293
+ immediate-failure check (`proc.killed` read before any kill was ever sent),
1294
+ `scripts/with-test-lock.mjs`'s `.once`-registered first-forward guard, and
1295
+ `scripts/smoke-tools.mjs`'s read of Node's own `execFileSync` timeout-kill
1296
+ flag on the caught error object.
1297
+
1298
+ **Adversarial-review follow-up round:** the reviewer ran
1299
+ `kill-process-tree.test.ts` against PRE-fix `client.ts` and it passed 7/7 —
1300
+ the sibling fixes above had ZERO effective test coverage, because the
1301
+ "non-fast shutdown escalates" mock (and the other pre-existing mocks in
1302
+ that file) lacked `once`/never set `killed`, so BOTH the old dead guard and
1303
+ the new fix's guard were vacuously permissive against them (the #1106
1304
+ vacuous-mock class, recurring in mock form: a test's fixture is too weak to
1305
+ distinguish correct from broken behavior, so it passes either way). Fixed
1306
+ by: upgrading that test's mock to be `.once`-capable so it actually
1307
+ exercises the `exited`-flag logic; adding two new `fast`-shutdown tests with
1308
+ a real `.once`-capturing mock proving BOTH directions (exit observed before
1309
+ the 1.5s window → no group SIGKILL; no exit observed → group SIGKILL at the
1310
+ window) — the "no premature SIGKILL" direction fails against the pre-fix
1311
+ `!proc.killed` guard (proven by temporarily reverting the guard and
1312
+ re-running); and adding a real-subprocess POSIX-only test
1313
+ (`tests/clients/lsp/initialize-timeout-backstop.test.ts`, skipped on win32
1314
+ with an explicit reason — killProcessTree's Windows path is
1315
+ `taskkill`-based, not signal-based, and is already covered by the
1316
+ kill-process-tree suite) for the previously fully-untested `initialize()`
1317
+ 2s backstop.
1318
+ - **Micro-gap sweep: recorded coverage/observability/doc gaps (refs #1106, refs #1104)**
1319
+ - `session-state-store.ts`'s `loadSessionState` STATE_VERSION reject path
1320
+ (a wrong-version persisted snapshot is ignored, not rehydrated) had no
1321
+ test; `STATE_VERSION` is now exported so the new test can drive the
1322
+ mismatch off the real constant (`STATE_VERSION + 1`) rather than a
1323
+ hardcoded literal (#1116 pattern).
1324
+ - `tests/clients/cache/rule-cache.test.ts`'s deliberate `raw.version = "v2"`
1325
+ schema-mismatch override now pins `expect(CACHE_VERSION).not.toBe("v2")`
1326
+ alongside it (#1082/#1116 pattern), so the assertion can't vacuously pass
1327
+ if `CACHE_VERSION` ever became `"v2"`.
1328
+ - The cascade `neighbor_touch` log entry (`clients/dispatch/integration.ts`)
1329
+ now carries an `inconclusive` boolean in its metadata alongside
1330
+ `bindingState`, so the two independent unconfirmed-touch causes (notify/
1331
+ diagnostics wait lapsed vs. disk-diverged binding, #1093/#1095) are
1332
+ distinguishable from `cascade.log` alone, without cross-referencing
1333
+ `latency.log`.
1334
+ - `tools/lens-diagnostics.ts`'s `includeGenerated` param description now
1335
+ states it only takes effect with `mode=full refreshRunners=cheap/all`
1336
+ (it's silently a no-op under `cached`/`none`, since no project scan runs
1337
+ to apply it to) — a doc-only fix from PR #1115's review.
1338
+ - **mtime-only cache freshness sweep (refs #1105)** — the #1092→#1096 arc bound
1339
+ LSP-diagnostics freshness to real content; this sweep audited the OTHER
1340
+ persisted/derived caches for the same "mtime unchanged ≠ content unchanged"
1341
+ class (git checkout timestamp restoration, formatters preserving mtime,
1342
+ same-second writes) and hardened the two CONTAINED members whose fix is free:
1343
+ - **word index** (`clients/word-index.ts`): the incremental session-warmup
1344
+ refresh gated re-read on mtime ALONE (`fileMtimes.get(file) !== mtimeMs`), so
1345
+ a mtime-preserving content change left stale identifiers served to
1346
+ `symbol_search`/`pilens_symbol_search`. It now stores a per-file byte `size`
1347
+ (the SAME `stat` the walk already runs to enforce the byte cap — zero extra
1348
+ I/O) and re-reads when mtime OR size differs, matching the review graph's
1349
+ gold-standard `size:mtimeMs` first filter. `size` is a new optional parallel
1350
+ array in the serialized snapshot (`SerializedWordIndex.fileSizes`); a
1351
+ pre-#1105 snapshot lacking it forces one self-healing full re-read on the
1352
+ next refresh (the safe direction) rather than a version bump.
1353
+ - **dependency checker** (`clients/dependency-checker.ts`):
1354
+ `DependencyChecker.importsChanged`'s mtime fast path (`cached.timestamp >=
1355
+ stat.mtimeMs`) skipped the madge circular-dep re-check on a mtime-preserving
1356
+ import edit; it now also requires `cached.size === stat.size`.
1357
+
1358
+ The residual (same mtime AND same byte length, changed content) is left as the
1359
+ same accepted residual the review graph itself carries — closing it needs an
1360
+ unconditional per-read content hash, which the event-loop/hot-path discipline
1361
+ forbids. The sweep's full verdict table (word-index/dependency-checker fixed;
1362
+ project-snapshot seq, installer probe-cache, sgconfig, reverse-deps, TreeCache,
1363
+ yaml-rule-parser bundled cache all SAFE/already-hardened; rule-cache disk cache
1364
+ filed as a follow-up because a content-confirm there would read 700+ rule files
1365
+ on the per-edit hot path) is recorded in the PR.
1366
+ - **Test-role files no longer leak into collateral cascade/impact surfaces (closes #1080)**
1367
+ — the review graph is already tests-free, but several
1368
+ collateral surfaces re-derived neighbors from OTHER sources that never saw
1369
+ that filter, so an UNIGNORED `*.test.*` / `tests/` file could still surface
1370
+ as cascade collateral. The existing `getProjectIgnoreMatcher` filtering only
1371
+ closed the ignored-file half of the gap (#297). A new shared predicate
1372
+ `clients/collateral-test-role.ts:isTestRoleCollateral` composes the SINGLE
1373
+ existing structural classifier (`clients/file-role.ts:detectFileRole`) — no
1374
+ second matcher, no private test-filename list — and is applied at every
1375
+ collateral producer boundary: (1) `computeCascadeForFile` filters
1376
+ `impact.directImporters` / `directCallers` / `neighborFiles` AFTER graph
1377
+ neighbors, reverse-deps, LSP reference expansion, and transitive expansion
1378
+ are merged, so a test neighbor is excluded from the active-touch/passive-
1379
+ snapshot set, the returned `impact`, AND the formatted header
1380
+ (`formatImpactCascade` reads `impact` verbatim for `Direct importers` /
1381
+ `Check next` counts and names) — module-level downstream files (added inside
1382
+ `computeImpactCascade`) are caught here at the consumption boundary rather
1383
+ than inside the shared graph query; (2) `appendFallbackNeighbors` drops an
1384
+ unignored test file from the passive-snapshot fallback (ignore filtering
1385
+ stays separate and unchanged); (3) `runtime-turn.ts` filters call-graph
1386
+ `impact()` results so a test caller appears in neither the turn-end advisory
1387
+ text nor the persisted delta; (4) `callGraphImpactToProjectDiagnostics`
1388
+ re-applies the predicate at the persistence producer boundary. Honesty
1389
+ preserved: a KNOWN `"test"` role is filtered, but a role-classification
1390
+ failure RETAINS the candidate (never a false clean), the ignore matcher's
1391
+ fail-open behavior is untouched, and LSP-unavailable/inconclusive cascade
1392
+ semantics are unchanged. Per-runner and auxiliary-profile `skipTestFiles`
1393
+ semantics, primary LSP diagnostics, intentional test-runner findings, and
1394
+ the generic project-diagnostics snapshot/delta display are all left as-is.
1395
+
1396
+ - **`no-bare-except` (Python, ast-grep) still flags dotted/qualified exception types as bare (closes [#1031](https://github.com/apmantza/pi-lens/issues/1031))** — the ast-grep twin of the #1026 tree-sitter fix never received its `attribute` alternative: `except asyncio.TimeoutError:` has no direct `identifier` child (the identifiers live inside the `attribute` node), so the `not: any:` check passed and the rule reported a false positive on every dotted exception type — live for binary-installed users, where the ast-grep LSP/CLI runs this rule on every Python file. The rule now accepts the same five exception-spec shapes as the tree-sitter filter's `bare_except_only`: identifier, attribute (dotted name), tuple, `parenthesized_expression`, and `as_pattern` — the parenthesized-single-name case is the same latent class and was swept in with it (verified firing pre-fix via the real CLI). Fixtures: dotted + parenthesized names added to `no-bare-except-test.yml` as valid cases; the bare `except:` invalid cases unchanged. Verified against the real ast-grep CLI: pre-fix fired on `except asyncio.TimeoutError:` and `except (ValueError):`, post-fix fires only on genuine bare `except:` among the identifier/attribute/tuple/parenthesized/as_pattern spec shapes (`subscript` specs like `except dict[str, int]:` remain a tracked gap on both surfaces); the full 251-case `ast-grep test` corpus passes.
1397
+ - **The warm IPC side-channel re-dispatched the same request on stray bytes (closes [#1219](https://github.com/apmantza/pi-lens/issues/1219))** — the server's socket `data` handler (`mcp/server.ts` `startIpcServer`) buffered chunks but never consumed the request line, so any further `data` event on the connection — stray bytes without a newline still re-found the original line — re-ran the whole warm `analyzeFile` pass (three dispatches per connection in the pre-fix repro). The channel's clients write exactly one newline-delimited request and read one reply (`requestWarmAnalyze` ends the socket), so the handler is now strictly one-shot: `createWarmIpcLineReader` (`clients/mcp/ipc.ts`, re-exported through the lens-engine seam) accumulates chunks, dispatches the first complete line exactly once, and ignores everything after it. The stdio JSON-RPC loop already consumed its lines and is untouched. Regression tests drive the real reader: one request + stray bytes → exactly one dispatch (pre-fix: three), a second newline-terminated request is ignored, and a request split across chunks still assembles.
1398
+
1399
+ - **`npm run test:unit` runs again, and actually excludes the integration files** — the script passed `--ignore <file>`, which is not a vitest option, so every invocation died with `CACError: Unknown option --ignore` before a single test loaded (broken since the tier split was written; CI never caught it because CI runs `npm test`). The rename to the real flag, `--exclude`, fixes the crash but not the intent: `vitest.config.ts` defines per-project `exclude` lists, and a project's `exclude` REPLACES the root/CLI value rather than merging with it, so a command-line `--exclude` is silently dropped (verified with `vitest list`: both integration files still resolved). The exclusion now lives in the config, keyed off the script name npm exports (`npm_lifecycle_event`), which survives the `with-test-lock` wrapper identically on every OS — unlike an inline `FOO=1 …` prefix, which cmd.exe cannot parse. `npm run test:unit` is now 456 files, `npm run test:integration` the other 2, and `npm test` all 458.
1400
+ - **`loop_block` no longer reports a machine sleep or paging stall as a pi-lens synchronous block (refs #1122, refs #1123 item 1)** — the event-loop occupancy probe (`clients/event-loop-monitor.ts`) wraps Node's native `monitorEventLoopDelay`, whose libuv timer-lag is measured against the monotonic clock (`uv_hrtime`/`QueryPerformanceCounter`). When the whole process is frozen — Windows Modern Standby, or paging thrash under commit-charge exhaustion — the next timer fires late by the entire wall-clock gap, and that gap was logged as a "block". Two distinct machine artifacts were confirmed against the Windows System event log: a 290,179 ms block lined up exactly with a 14:33:05Z→14:37:55Z Modern Standby window (Kernel-Power 506/507), reported byte-identically by two independent pids because the histogram is HDR-bucketed and quantizes ~290 s into one bucket; and a later silent host exit with **zero** sleep events but twelve Resource-Exhaustion-Detector (2004) events at 97% commit charge. `latency.log` also held multi-*hour* "blocks" that can only be overnight sleep. Comparing a wall clock to a monotonic clock does NOT catch this — on Windows both advance across Modern Standby — so the probe now uses **CPU accounting**: a genuine synchronous block of D ms burns ≈ D ms of main-thread CPU, so its window must have consumed ≥ ~D ms of CPU; a frozen/thrashing process consumes ~0 CPU across the gap. The histogram is now windowed **per turn** (its long-intended reset, previously never wired, so each block is attributable to its turn and its CPU budget is bounded), and a worst block above a 20 s floor that the window's CPU cannot account for is tagged `suspectSystemStall` in the `loop_block` metadata (alongside `windowCpuMs`/`windowWallMs` and the `lastPhase` attribution hint) rather than raising the genuine-block high-water — so a machine freeze can no longer permanently suppress logging of later real blocks, and `/lens-health` reports the worst *genuine* block. Sub-floor blocks are never auto-tagged, but the logged CPU-vs-wall ratio still exposes a shorter paging stall for a human. Pure `isSuspendSuspectedBlock` and `getLastLoggedPhase` seams are unit-tested without a real machine sleep.
1401
+ - **`runtime-tool-result.ts`'s three tool-result-turn maps now key through `PathKeyedMap` (refs #1086, the #210/#1020/#1025 raw-path-key class)** — `inFlightPipelines`, `lastAnalyzedStateByFile`, and `debouncedPipelines` keyed directly on `filePath`, which is only `path.resolve`d from raw agent-supplied tool input (never case-folded, never realpath'd). A divergent Windows spelling of the same file (case, or an unresolved absolute path an agent supplies verbatim) bypassed the concurrent-state dedupe and debounce coalescing, producing duplicate pipeline runs, doubled LSP dispatch, and split telemetry participant counts; `flushDebouncedToolResults(filePath)` silently missed the pending entry it was meant to flush. All three now wrap `PathKeyedMap` with `normalizeEphemeralMapKey` — the cheap slash-fold + win32-lowercase normalizer (no `realpathSync`), matching the state's ephemeral, turn-scoped, single-process lifetime per AGENTS.md's normalizer-choice guidance (`normalizeMapKey`'s realpath cost is for long-lived cross-call-site state, not this). `inFlightPipelines`'s composite `${filePath}:${stateHash}` key is now a `PathKeyedMap<Map<string, InFlightPipeline>>` — normalized path outer, raw stateHash inner — rather than folding both halves through one string normalizer, so a genuine same-path content change still gets a distinct entry while divergent spellings of the same state collapse to one. Every read/write/delete/iteration site in the file was audited; `flushDebouncedToolResults`'s iteration and `scheduleDebounced`'s coalesce path both continue to see the map's preserved display path. No other raw path-keyed map exists in the file. New tests (`tests/clients/runtime-tool-result-debounce.test.ts`) prove, fail-then-pass, that (1) two concurrent `tool_result`s for the same file under a divergent (win32-normalizer-driven) spelling collapse into one pipeline run, and (2) `flushDebouncedToolResults` addressed by a divergent spelling still hits the entry scheduled under the other spelling (asserted by elapsed time, not just call count, so a silent miss falling back to the natural debounce timer can't vacuously pass); a third test proves the composite key's stateHash axis still keeps same-path-different-content calls distinct.
1402
+ - **`pi --print --no-session` no longer hangs after `agent_settled` — uncleared race-loser timers (refs #1097, recurrence of #22's symptom via a different handle)** — a completed one-shot print-mode process kept running in the event loop instead of exiting. Root cause: `LSPService.getClientForFile` raced the client-wait against an inline `setTimeout(effectiveMaxWaitMs)` whose handle was never stored, so when the client resolved first (the common case) the losing timer stayed a **ref'd** pending timer for the full remaining wait budget. In a long-lived interactive session that is invisible (it fires later, resolves an orphan promise, is GC'd); in a `--print`/subagent process it keeps the loop alive for up to `effectiveMaxWaitMs` (15s in the report, minutes with a server whose `clientWaitTimeoutMs` is large) after the run settled — the `session_shutdown` handler runs and `resetLSPService` completes, but nothing owned or cancelled this timer. Proven via `process`-handle async-hooks tracing: the sole pi-lens-owned handle surviving into the hang was this timeout. The defect **shape** — *an operation raced/bounded by an independently-armed timer whose loser is never cancelled on the operation's resolution* — was swept repo-wide; two same-shape siblings were fixed in the same pass: the quiet-window heartbeat sample-timeout (`clients/quiet-window.ts`, 2s ref'd, kept a one-shot alive ~2s per settle) and the formatter timeout (`clients/format-service.ts`, 30s ref'd). All three now store the timer and `clearTimeout` it in a `finally`, matching the `withDeadline` helper contract the rest of the codebase already uses. Regression test (`tests/clients/lsp/client-wait-timer-cleanup.test.ts`) asserts no pending timer survives a fast client win — it fails on the pre-fix code (`expected 1 to be 0`). One further same-shape site — the cascade references race in `clients/dispatch/integration.ts` (750ms) — is tracked in #1109 for a follow-up as it is under concurrent modification.
1403
+ - **Two more uncleared-race-timeout members closed out (closes #1109, sweep follow-up to #1097/#1110)** — the two class members #1110's adversarial review flagged rather than fixed. (1) `computeCascadeForFile`'s symbol-level blast-radius upgrade (`clients/dispatch/integration.ts`) races `lspService.references()` against an inline `setTimeout(() => reject(...), 750)` per changed symbol (up to 3 per file); when `references()` wins — the common case — the losing timer was never stored, so it stayed a REF'D pending timer for the remaining budget. Same `finally`-clear contract as the #1097 fix: the handle is now stored in a per-call `let` and cleared once the race settles. (2) `safeSpawnAsync`'s non-Windows `killTree` else-branch (`clients/safe-spawn.ts`) arms a REF'D 1s SIGTERM→SIGKILL escalation timer that was neither cleared on child exit nor unref'd (the main per-spawn `timeoutId` alongside it was already correctly cleared on both the `close` and `error` paths). Bounded to 1s and only on kill paths, so it can't reproduce #1097's hang by itself, but it is the same defect shape and now clears alongside `timeoutId` in both handlers. (Review-round finding: the escalation ACTION itself has always been dead code — `!child.killed` is false after a successful SIGTERM *send*, so SIGKILL never fires; this change fixes only the timer-handle leak and preserves the window; the dead guard is tracked in #1114.) Regression tests prove both fail on pre-fix code: `tests/clients/cascade-compute.test.ts` (`vi.getTimerCount()` after a fast `references()` win) and the new `tests/clients/safe-spawn-kill-escalation-timer.test.ts`, which mocks `process.platform` and `node:child_process`'s `spawn` (mirroring `tests/clients/lsp/kill-process-tree.test.ts`'s established technique) so the non-Windows branch is exercised deterministically on any host OS, including Windows dev machines — no vacuous platform-gated pass. A re-run of #1110's repo-wide `setTimeout`/`setInterval`/`Promise.race` grep over every file changed since that sweep (#1103, #1110, #1111) found no new members of the class.
1404
+ - **`sg scan` exit-1-with-matches is no longer misclassified as a CLI failure (refs #1087)** — ast-grep's linter-style contract (verified first-hand against the bundled CLI: a `severity: error` rule that MATCHES exits **1** with valid JSON matches on stdout and stderr "Scan succeeded and found error level diagnostics"). `SgRunner.interpretScanResult()` / `exec()` only exempted `status===1` with *empty* output, so every real match under an error-severity rule was dropped as `failure: "cli-failure"` with `matches: []`. Now `status===1` + stdout that parses as valid JSON is treated as SUCCESS (matches parsed); stderr-with-unparseable-stdout stays a failure, and the historical status-1/no-output no-match convention is preserved. Invariant: **an exit code that means "scan succeeded with findings" must never be classified as a CLI failure.** This restores `ast_grep_search`/`pilens_ast_grep_search` raw error-severity rules, the `pilens_ast_grep_replace` preview/preCheck path, and `validateRule` for a matching error-severity rule.
1405
+ - **"Silencing is not fixing" now covers the whole suppression class, not just the policy member (refs #1087)** — the delta baseline in `dispatcher.ts` was only re-filtered through the project rule policy before `filterDelta`, while the live set was additionally filtered by LSP/docker-overlap suppression, inline `pi-lens-ignore` comments, and agent/user dispositions. A finding persistently dropped by any of those three sibling filters stayed in the baseline's `after` view forever, so it counted in `resolvedCount` on EVERY dispatch and inflated `trackAgentFixed` unboundedly. The output filter pipeline (overlap → inline → disposition → policy) is now a single closure applied identically to the live set AND the delta baseline, so a persistently-suppressed finding is absent from both sides and never oscillates into `fixed`. The stored baseline remains the unfiltered deduped set (a policy/suppression edit never resets the user-authored delta baseline).
1406
+ - **Inline `pi-lens-ignore` comment tokens are now normalized like the diagnostic id (refs #1087)** — the diagnostic side matched raw OR normalized, but the comment side stored only the raw token, so `// pi-lens-ignore: no-eval-js` failed to suppress a finding surfaced under the normalized `no-eval` even though the identical `disable: ["no-eval-js"]` config key worked. The comment token now also contributes its `normalizeRuleId` form, making the two suppression surfaces symmetric.
1407
+ - **`"disable": []` / `"select": []` no longer warn spuriously (refs #1087)** — an explicitly empty rule-policy list is a well-formed no-op, not an error; it is now dropped silently. A NON-empty array whose entries are all blank/non-string is still a real authoring mistake and still warns ("must contain at least one non-empty string").
1408
+ - **Test-runner subcommands no longer lost during binary resolution (closes #1098)** — `TestRunnerClient`'s `resolveExec` unconditionally dropped `args()[0]` whenever local/global binary resolution succeeded, an npx-wrapper-convention assumption (arg 0 names the binary, e.g. `npx vitest run …`) that only holds for wrapper-style runners. For direct runners whose args lead with a real subcommand it silently ate that subcommand instead — `cargo test --no-fail-fast` resolved to the argv-invalid `cargo --no-fail-fast` (a clap usage error), likewise for go/dotnet/maven/mix, misreported at turn-end as a false "1/1 failed". The new `stripWrapperArgs` helper strips a leading arg ONLY when it actually names the resolved binary (`[binName, ...]`, or the pytest-style `["-m", binName, ...]` pair) and leaves every other argv untouched, applied at both the local-bin and global-bin resolution sites. Also fixes the same defect's inverse: `rspec`'s config declares `command: "bundle"` with `args: ["exec", "rspec", file]`, but binName resolution defaulted to the runner key `"rspec"` instead of the real binary `"bundle"`; it now declares `binName: "bundle"` so local/global resolution looks up the right binary and `exec rspec <file>` reaches `bundle` intact. A generic regression matrix (`tests/clients/test-runner-client.test.ts`) iterates the exported `RUNNERS` table (not a hand-copied runner list) across local-bin/global-bin/fallback resolution paths, so a future runner is automatically covered. A repo-wide sweep for the same defect shape (positional argv stripping tied to binary resolution, and command/binName-resolution-name mismatches) found no other live instance — every other `findGlobalBinary` caller (biome-client, formatters, ast-grep runners, LSP launch) builds its argv as fresh literals per resolution branch rather than slicing a shared generator, so none share this defect's precondition. Three additional command/binName-mismatch members exist but are unreachable in practice (their binaries never land in the npm-family bin dirs findGlobalBinary probes) and now resolve MORE correctly if they ever did: gradle, maven, and minitest (command "ruby" vs default binName "minitest"); pytest's resolved-path argv was also silently broken pre-fix (slice(1) ate "-m") and is now correct.
1409
+ - **Call-graph persisted-cache validator and canonical-freshness invariant now have real test coverage (refs #1089)** — two `call-graph.test.ts` "rejects…" tests hand-wrote `version: 4`, identity-less fixtures, so `loadCallGraph` returned `undefined` at the version/identity gate before the ~200-line semantic validator (`validatePersistedCallGraph`) ever ran; rewritten as `version: 5` fixtures with a valid canonical identity, each now isolating and proving out the ONE validator check it claims to cover (the inDegree/adjacency cross-check and the `complete && unsupportedEvidence > 0` honesty check). Also added the first tests anywhere that pass `expectedIdentity` to `loadCallGraph`, covering the freshness invariant edab5d5d introduced: a mismatched `reviewGraphVersion` or `reviewGraphSignature` rejects the cache (the #210/#1020 stale-replay class), while a matching identity loads normally.
1410
+ - **`module_report`'s `callGraph: true` surface gains coverage for the `file-cap` and `identity-missing` unavailability reasons and the `provenance.callGraph` field (refs #1089; the Java try-with-resources regex and grammar co-load items remain open there)** — `cached-call-graph` / `unavailable:file-cap` / `none` / omitted-when-not-requested are now all asserted, so the tool description's own honesty contract ("unavailable cache state is never reported as zero calls") is enforced by tests, not just documented.
1411
+ - **Review-graph cache-identity guard now detects a stale graph instance instead of a vacuous schema-tag compare (refs #1088; the `parseSymbolKey` LSP-fallback-kind item remains open there)** — `getReviewGraphCacheIdentity` compared `version`, the constant schema tag (e.g. `"v8"`, identical for every live graph), so it could never tell a caller's graph reference apart from a newer one that had since replaced it in the workspace cache — the concrete race is a session call-graph task computing a projection from an older `graph` instance while a concurrent cascade build races ahead and replaces `_workspaceGraphCache` before the identity lookup runs, letting a stale call-graph projection get persisted under a fresh-looking signature. Now compares the workspace-cache ENTRY's `buildGeneration` stamp (#459) — not the stored graph object's, which the reuse paths deliberately leave unstamped on their `cloneGraph` copies — so identity resolves for the graph a drift-reuse or disk-hit build just returned, while a stale or unstamped instance is still rejected.
1412
+ - **Nested-link diagnostics no longer flag ordinary JSX wrappers (closes #1076)** — report only the outermost anchor in a directly or deeply nested chain, while allowing single and sibling links inside non-anchor containers across both ast-grep and Tree-Sitter dispatch paths.
1413
+ - **LSP workspace edits apply safely in declared order (refs #1053)** — preserve text/resource operation ordering (including directory subtrees), stabilize same-position inserts, collapse identical replacement duplicates, prevalidate every text batch before writing, and close an open old URI before rename notifications.
1414
+ - **LSP workspace-edit apply paths: reversed same-position inserts, win32 casing destruction, versioned-edit rejection, out-of-range clamping (closes #1085; P3-1 folded in, P3-2/3/4/5/6/7 deferred on #1085)** — four empirically-reproduced defects in the `clients/lsp/edits.ts` write pipeline, each with a fail-then-pass regression test.
1415
+ 1. **Same-position inserts applied REVERSED through `applyWorkspaceEdit` (the #1066 invariant).** The tie-break sort was correct only for exactly one pass, but the apply pipeline sorted an even number of times (and differed by server position-encoding), so `["A","B"]` inserted at one position produced `"aBAbc"` instead of `"aABbc"` for UTF-16 servers (typescript-language-server, pyright — the majority). The application-ordering sort is now performed **exactly once**, at the single string-write site; `normalizeTextEditsForContent` returns validated edits in original array order (never pre-sorted), so the same-position order can no longer flip on an even number of passes. Invariant: same-position inserts apply in array order through every entry path (direct `applyTextEditsToString`, `applyWorkspaceEdit` `changes`, `documentChanges`) under all three position encodings — a full encoding×entry-path matrix now asserts it.
1416
+ 2. **Windows case-preservation destroyed on create/rename; case-only renames rejected (invisible to Linux CI).** Disk mutations routed the URI through `uriToPath`→`normalizeFilePath`, which lowercases a nonexistent path tail on win32 — so `create NewFile.txt` wrote `newfile.txt`, `rename → MixedCase.txt` wrote `mixedcase.txt`, and a legitimate case-only rename (`foo.txt → Foo.txt`) was rejected as "source and destination must differ". Confinement/validation still run on normalized paths, but the actual `fs.writeFile`/`fs.rename`/`fs.readFile`/`fs.rm` calls now target the URI's decoded path via the new `uriToDiskPath`. The preflight "destination exists" check decides a case-only (or otherwise-aliased) rename by on-disk **FS identity** (matching `dev` and matching **nonzero** `ino` via `lstat({ bigint: true })`), not a platform-keyed path fold — so it works on ANY case-insensitive FS (win32 **and** macOS APFS/HFS+, per the #1024 "probe the FS, don't branch on platform" lesson) and closes the inverse edge where a case-sensitive FS holding both spellings would otherwise silently clobber. The nonzero-`ino` requirement (and BigInt comparison) is fail-closed: on ino-less filesystems that report `ino: 0` (FAT32/exFAT, some SMB redirectors) the identity check never matches, so the destination-exists check runs and a rename can only error, never silently clobber — a genuine case-only rename there needs an explicit `overwrite`. Tests are FS-probe-guarded (assert casing preservation on a case-insensitive FS, success on a case-sensitive FS) plus a direct unit test of the `ino: 0` guard — never a vacuous pass.
1417
+ 3. **Versioned `TextDocumentEdit`s always rejected on the tool apply paths.** Rename `apply:true` (`tools/lsp-navigation.ts`) and code-action autofix (`clients/actionable-warnings.ts`) never pass `documentVersions`, while `client.rename`/`codeAction` validated versions against the live map and then PRESERVED them — so a numeric `textDocument.version` reached an apply site with no map and failed 100% for version-stamping servers (gopls). `normalizeClientWorkspaceEdit` now validates the version against the live map (unchanged) and then STRIPS it (spec `null` = don't check), so the tool paths succeed. The server-initiated `workspace/applyEdit` handler does NOT route through here (it applies `params.edit` with the live `documentVersions`), so its real version check is fully intact. The existing rejection test's key-mismatch vacuity (raw-path key vs `normalizeMapKey` lookup — the #1106 class) is fixed and a positive success-path test added.
1418
+ 4. **Out-of-range positions threw instead of clamping (LSP 3.17 regression).** A line past EOF now clamps to end-of-document and a character past line end clamps to line length, so the `(0,0)-(9999,0)` whole-document-replace sentinel idiom works again; malformed shapes (negative/non-integer, `start > end`, mid-codepoint boundary) still throw. The character clamp is **CRLF-safe**: because `lineTextAt` keeps a trailing `\r`, the clamp targets the length BEFORE that `\r`, so a char-past-EOL insert lands at the CRLF boundary and the whole-line sentinel replace preserves the `\r\n` (it does not eat the `\r` or leave a stray one mid-line).
1419
+ P3-1 (an insert listed after a replace starting at the same position was spuriously rejected as overlapping — LSP-legal, VSCode applies it) is fixed as a byproduct of the sort rework via an end-descending tie-break, making the result independent of listing order. The general CRLF-boundary hazard (P3-5) and P3-2/3/4/6/7 remain deferred on #1085.
1420
+
1421
+ - **Read-guard did-you-mean no longer quotes NFKC-normalized text as "current lines"** (#1050) — the `Did you mean one of these current lines?` block (#505) rendered its rows from the normalized match space instead of the file's real characters, so for CJK markdown it folded `:`→`:`, `,`→`,`, `;`→`;`, `()`→`()` and `—`→`-` and presented the result as ground truth about file content. That inverted #505's own stated invariant ("only for COMPARISON, never for the written content"). The damage compounded past a bad suggestion: an agent that copied the half-width row into `oldText` got past the guard, then the host's `fuzzyFindText` missed exact match, fell into fuzzy space, and `applyReplacementsPreservingUnchangedLines` rewrote the *touched* line from the normalized base — silently replacing full-width punctuation with ASCII while reporting success (untouched lines are safe per earendil-works/pi#5899; touched-line normalization is wontfix per earendil-works/pi#3554, so the suggestion is the only place this can be fixed). The failure mode is self-reinforcing: the agent re-reads the file next turn, sees changed punctuation, and concludes a markdown formatter is corrupting the file — a wrong root cause it then acts on. `findSimilarLines` now returns only `{ line, score }` and `formatSimilarLines` renders from the raw file, making it structurally impossible to leak normalized text into an error. The raw view is `normalizeToLF(stripBom(raw).text)` — the same STRUCTURAL normalization the match space applies, so line numbers cross-index 1:1 even on lone-CR or BOM files, but none of the CHARACTER folding, so full-width punctuation, smart quotes, and NBSP survive. A naive `\\r\\n`-only fold desyncs on lone-CR files and is covered by a regression test.
1422
+ - **Experimental git guard correctness (refs #1063)** — `--lens-guard` now detects real commit/push invocations through the shared shell tokenizer, gates only structured blocking findings (including blocking test failures), and rejects stale, malformed, cross-session, or sequence-mismatched state conservatively. Aggregate per-file blocker state, clean-turn invalidation, and bounded decision telemetry prevent advisory findings or a later clean file from silently clearing an unresolved blocker.
1423
+ - **Stale cross-file footer diagnostics now clear (refs #1093, refs #1092)** — a widget finding in file A that was *caused* by file B (a missing declaration, a changed export) is fixed by editing B, but every widget staleness gate compares A's OWN mtime to the record timestamp — and A's mtime never advances, so the resolved finding rendered forever (footer, `lens_diagnostics` replay, resume, fork adoption). Two minimal fixes close the reported shape. (1) The per-edit cascade already re-checks every edited file's dependents (`clients/dispatch/integration.ts`) but threw the result away as "display-only"; each **confirmed** neighbor result (a valid passive snapshot or a completed, non-inconclusive active touch — #571 semantics) now reconciles into widget state, **including the confirmed-clean `[]` case** that clears A's stale entry, keyed by the primary edit's `writeSeq` so a genuinely newer per-edit write still wins the `WriteOrderingGuard`. A touch that resolves `[]` while flagged `inconclusive` (its wait lapsed) is never treated as confirmed-clean — it neither reconciles nor seeds the recently-clean cache, so it cannot wipe a live finding (#533). Because the cascade only re-checks the *language server* and only for *errors*, the reconcile **merges** rather than replaces: it swaps out just the neighbor's existing LSP-error entries and preserves live biome/ruff/ast-grep findings and LSP *warnings* it never re-examined. Auxiliary-LSP findings (opengrep/ast-grep/zizmor/typos, identified by `findAuxiliaryProfileForSource`) are excluded from what the cascade writes — unlike the per-edit/scan writers it does not run `retagAuxiliaryDiagnostics`, so writing them would double-count the neighbor's own aux entry and escalate an advisory finding into a blocking `tool:"lsp"` error, bypassing aux policy. Tier-3-silent/recently-clean/within-turn-cache/rejected-touch results are never written. (2) `reconcileScanDiagnostics`/`recordDiagnostics` accept an observation timestamp; the `lsp_diagnostics` workspace-cache-hit branch (plus `lens_diagnostics mode=full`'s cache-served results) stamp `touchedAt` with when the diagnostics were *observed* (the cache entry's `scannedAt`), and the cascade's passive-snapshot reconcile stamps the snapshot's own publish time (`entry.ts`, up to `CASCADE_TTL_MS` old) — not `Date.now()` — so replaying an aging cached/snapshot view no longer keeps re-arming the mtime gate. The structural seq-stamping redesign (marking a changed file's reverse-dep set unverified up front) remains a follow-up under #1093.
1424
+ - **Class sweep: test fixtures hand-writing persisted-format version literals no longer go vacuous on version bumps (closes #1106; class generalized from #1089/#1082, fresh-risk flagged from #1096)** — a fixture that hand-writes a persisted-format `version:`/schema-tag literal a runtime gate checks BEFORE the code-under-test runs silently orphans on a version bump: the gate now rejects the (unrelated-stale) fixture and the test stays green for the wrong reason. Repo-wide sweep of `tests/` for hardcoded `version:` numerics and `"v<digit>"` schema tags across every known persisted-cache surface. Converted to import the runtime constant (exporting it where it was previously a private literal): `clients/call-graph.ts`'s `CACHE_VERSION` (was unexported — `tests/clients/call-graph.test.ts`'s `validPersistedCallGraph()` and `tests/clients/module-report.test.ts`'s call-graph-cache fixture both now import it instead of hand-writing `version: 5`), `clients/read-guard.ts`'s `READ_GUARD_STATE_VERSION`, `clients/widget-state.ts`'s `WIDGET_STATE_VERSION`, `clients/review-graph/builder.ts`'s `REVIEW_GRAPH_VERSION`, and a newly-extracted `clients/word-index.ts` `WORD_INDEX_FORMAT_VERSION` (previously an inline `2` duplicated at both the write and gate call sites). `tests/clients/project-diagnostics/scanner-wasm-abort.test.ts`'s `priorSnapshot()` fixture now imports the already-exported `PROJECT_DIAGNOSTICS_CACHE_VERSION` instead of hardcoding `2`. Every test that deliberately persists an OLD version to exercise the rejection path itself (call-graph v4, project-diagnostics v0 ×2, word-index v1, review-graph v3/v4) is pinned per the #1082 pattern: an adjacent `expect(CURRENT_CONSTANT).not.toBe(oldLiteral)` so a future bump onto that exact old value fails loudly instead of silently un-exercising the rejection path. Spot-proved non-vacuous for the three highest-traffic surfaces (call-graph, project-diagnostics, and — via the existing #1089 validator-disable proof already recorded in `call-graph.test.ts` — the semantic validator itself) by locally bumping the runtime constant, reverting the fixture to its old hardcoded literal, and confirming a loud failure (not a silent pass) before restoring both. Surfaces confirmed already immune (import the constant / derive-then-override a live value, so version drift cannot silently orphan them): `workspace-diagnostics-cache` (`WORKSPACE_DIAGNOSTICS_CACHE_VERSION` + self-relative `+1` mismatch), `warm-attach`/`mcp/ipc` (`WARM_DIAGNOSTICS_SCHEMA_VERSION`), `rule-cache` (`CACHE_VERSION`), `project-snapshot` (`PROJECT_SNAPSHOT_VERSION` for valid fixtures; deliberate-bad-version sentinels use `999`, permanently distant from the real 1-3 range). Confirmed NOT members of this class (noted for the next sweep so they aren't re-litigated): LSP wire-protocol `textDocument.version` fields (staleness/ordering, unrelated to a persisted-cache gate), assertions on production-EMITTED event/latency-log payloads (`lens-events`, `index-integration`, `cache-observability` — a bump fails these loudly, not vacuously, since they assert output rather than feed a hand-written fixture through a gate), in-memory-only `ReviewGraph` object literals in `transitive-impact.test.ts`/`lens-map.test.ts` (never round-trip through the persisted-JSON version gate), and `tests/tools/lens-diagnostics*.test.ts` (the entire `project-diagnostics/cache.js` module is `vi.mock`'d, so the real gate never runs). The installer's `probe-cache.json` has no version field/schema gate at all (not a member). `session-state-store.ts`'s own `STATE_VERSION` gate has no dedicated fixture test at all — a coverage gap, not a vacuous-fixture bug, left as a follow-up rather than expanded into new-test-authoring scope. SHAPE-gated fixtures (a loader that rejects on missing/malformed fields before the code-under-test) were also noted where encountered — `project-snapshot`'s "missing seq" meta test, `read-guard`'s malformed-`reads`-shape tests (now additionally non-vacuous on the version axis via the `READ_GUARD_STATE_VERSION` conversion above), and `word-index`'s malformed-input test — all correctly exercise their shape gate today.
1425
+ - **`no-bare-except` no longer flags subscripted exception specs (closes [#1244](https://github.com/apmantza/pi-lens/issues/1244))** — both detection surfaces (the ast-grep `no-bare-except` rule and the tree-sitter `bare-except` rule's `bare_except_only` post-filter) false-fired on valid subscript exception specs like `except dict[str, int]:` and PEP 654's canonical `except BaseExceptionGroup[TypeError, ValueError]:`. The #1031 sweep fixed the dotted-attribute sibling and stopped one member short: subscript was never in either surface's spec set, so neither surface suppressed it. The ast-grep `not: any:` and the tree-sitter filter's child-kind set both now accept `subscript`, keeping the two surfaces' shape spaces in lockstep (the same parity the #1026→#1031→#1243 arc enforced). Valid-fixture regression cases for both subscript forms fail against the pre-fix rule on both engines (2/2 false fires on the ast-grep CLI; 2/2 on the tree-sitter filter, confirmed by reverting the filter and re-running).
1426
+
1427
+ - **Spawn failures now distinguish missing tools from invalid working directories (closes #1214)** — Auto-install and reinstall paths run only when the executable itself is missing, preventing futile reinstall loops for cwd and permission failures.
1428
+
1429
+ - **Per-issue close-keyword CI validation** — Rejects comma-separated GitHub close lists and verifies merged PR close targets.
1430
+
1431
+ - **Recover stale git-guard blocker records (closes #1084)** — Clear persisted blocker content only when complete, well-typed blocker-file provenance proves the last represented blocker was revalidated clean; incomplete or forged provenance remains blocked.
1432
+
1433
+ - **Cascade module source-file walk is memoized on the existing freshness seams (refs [#1137](https://github.com/apmantza/pi-lens/issues/1137))** — `getModuleSourceFiles` (the #1318 slice-2 remnant) ran a recursive `readdirSync` walk per downstream module on every per-edit cascade in monorepos; it now memoizes per module root, revalidating on the visited directories' `mtimeMs` stamps plus ignore-matcher object identity (so `.gitignore`/`.pi-lens.json` edits re-walk), and clears with the module-graph cache.
1434
+
1435
+ - **Review-graph supersession cleanup is deterministic (refs #1318)** -- a superseded generation's staged write is reaped synchronously (and fault-tolerantly) before completion becomes observable, so CI waiters can no longer observe a leftover stage file; the supersession lock test moved to the quiet timing-sensitive phase.
1436
+
1437
+ - **In-pi extension logging no longer corrupts the pi TUI layout (closes #1333)** — pi owns the terminal, so the 26 ungated and 20 verbose-gated `console.error`/`console.warn` sites under `clients/` were landing raw bytes mid-frame and desyncing pi's screen model. Every site now writes to a `createNdjsonLogger` sink (the subsystem's own `tree-sitter.log`/`review-graph.log`/`latency.log` where one exists, otherwise the new `~/.pi-lens/extension.log`), user-facing degradations (invalid config, offline grammar fetch, WASM abort) additionally surface through `ctx.ui.notify`, and `index.ts` installs a defensive console reroute so a transitively loaded dependency cannot write to the frame either.
1438
+
1439
+ - **npx formatter fallback honors style-preservation gating (closes #1345)** — Files that must be skipped by `SKIP_FORMATTING` no longer invoke the static Biome or Prettier `npx` fallback when a primary formatter command is unavailable.
1440
+
1441
+ - **Formatter failures now appear in the diagnostics widget (closes #1346)** — Failed formatter runs render with a distinct error indication, remain deduplicated per formatter and file, and disappear after a successful retry.
1442
+
1443
+ - **Instance-registry liveness tests no longer depend on arbitrary runner PIDs (refs #1352)** — synthetic dead PIDs are classified through deterministic test seams, preserving coverage of footprint exclusion, registry pruning, and vanished-instance wiring without relying on the CI process table.
1444
+
1445
+ - **Trust-gated observability (refs #1334, #1358)** — LSP install refusals now log with warn-once deduplication, tree-sitter trust-blocked grammar toasts are deduplicated per grammar, trust transitions are recorded, and `~/.pi-lens/extension.log` is documented.
1446
+
1447
+ - **BLIND WRITE no longer fires on self-authored files (closes #1364)** — A `write`/`edit` of the same normalized path earlier in the blind-write window now counts as file knowledge, so the common author-then-iterate loop (create a file with `write`, refine it with successive `edit` calls) stays quiet. A genuinely stale edit of a file not touched in the window still warns, and the two-write threshold, 5-call window, and thrashing detection are unchanged. Thanks to @snrogers for the field report and the fix.
1448
+
1449
+ - **Edit counts now match normalized file paths (closes #1369)** — `AgentBehaviorClient.getEditCount` now uses the same path normalization as edit recording, so mixed-separator and case-variant lookups return the recorded count.
1450
+
1451
+ - **Bound warm word-index memory (closes #1370, refs #1332)** — idle/LRU per-root eviction now releases inactive indexes safely, while persisted snapshot postings no longer remain duplicated in long-lived in-memory caches.
1452
+
1453
+ - **Latency-log hygiene (closes #1371)** — Ast-grep unsupported-language telemetry now dedupes per language for each session and emits only a bounded rule-ID sample; the log analyzer excludes synthetic temp/scratchpad/heap-corpus rows by default, supports repeatable `--exclude` globs, and reports excluded-row counts.
1454
+
1455
+ - **Review-graph size gates stop at the cap sentinel (closes #1372)** — the shared cooperative source walk now stops at `maxFileCount + 1`, reports the partial count honestly as “more than N files,” and emits a distinct near-miss telemetry event within 5% of the cap.
1456
+
1457
+ - **Keep LSP roots inside the session project and coalesce nested marker clients (closes #1373, refs #1328)** — Clamp marker-selected roots at the declared cwd, and reuse same-server ancestor clients for config-only nested roots while preserving real manifest/lockfile sub-projects.
1458
+
1459
+ - **Telemetry phase pairing and attribution (closes #1374)** — Deferred formatting now emits counted terminal phases; LSP warm-up and unavailable events reconcile correctly, diagnostic timeouts identify their server, and startup timing preserves the console guard's import-time position.
1460
+
1461
+ - **Re-assert widget mounts after host UI replacement (closes #1381)** — The diagnostics widget remounts on the live UI at turn start when needed, while preserving mode and user visibility gates; unsupported widget hosts now emit a log-once diagnostic.
1462
+
1463
+ - **Widget and inter-extension bus updates recover after session replacement (closes #1383)** — every `session_start`, including the #473 guarded in-process subagent path, now reclaims activation-scoped bus, notifier, and widget-render wiring before returning. Stale bus failures are logged and ledgered once per failure occurrence, with a successful publish re-arming observability for a later channel death.
1464
+
1465
+ - **Deferred formatting uses bounded concurrency (closes #1387)** — the `agent_end` drain runs at most three formatter subprocesses in flight, applies results and synchronous bookkeeping in admission order with `setImmediate` yields between files, preserves per-file failure isolation, and requeues claimed records that were not started when the ambient turn aborts.
1466
+
1467
+ - **Hardened codebase-model cache identity (closes #1388)** — persisted models now carry a version and canonical review-graph identity, stale caches invalidate on mismatch, and model file selection uses shared role and artifact filtering.
1468
+
1469
+ - **Prevented consistency drift in language fixtures and extension handling (refs #1388)** — call-graph fixtures now cover every symbol-query language, JS/TS facts share the canonical extension policy, and bash file-access tracking derives source extensions from `KIND_EXTENSIONS`.
1470
+
1471
+ - **Canonicalized LSP edit merge buckets and call-graph path comparisons (refs #1388)** — equivalent URI spellings now share conflict/deduplication buckets while preserving the first display URI, and call-graph symbol/reference indexes normalize file keys consistently.
1472
+
1473
+ - **Bound and idle-evict review-graph workspaces and authoritative project snapshots (refs #1389).**
1474
+
1475
+ - Bound and evict rebuildable Tier 2 reverse-dependency, tree-sitter query, and workspace-topology caches while retaining unconsumed ReadGuard records until their edit or session end. ReadGuard now applies a high sanity cap with oldest-to-re-read eviction for read-only sessions (refs #1389; widget-state and Tier 3 remain deferred).
1476
+
1477
+ - **Bound Tier-3 process caches, prune expired negative LSP entries, decay widget/agent state, and close the cache audit (closes #1389).**
1478
+
1479
+ - **Scope session-start smell-tail diagnostics to the current session boundary (closes #1398)** — historical failures in the bounded bus-events and latency log tails are no longer reported as current; rows must have a parseable UTC timestamp at or after session start.
1480
+
1481
+ - **Unconfigured Python files are formatted again (refs #1144)** — `ruff format` rejects the `--indent-style`/`--indent-width` flags the style-preserving defaults were passing, exiting 2 without touching the file; because ruff was not treated as a strict-exit formatter, every unconfigured Python file silently reported "already formatted" and was never reformatted. Style is now pinned through ruff's inline TOML overrides, and a nonzero ruff exit is surfaced as a formatting failure instead of a clean no-op.
1482
+ - **Ast-grep console-rule exclusions (closes [#965](https://github.com/apmantza/pi-lens/issues/965))** — stop flagging intentional console usage in CLI scripts and logger implementations.
1483
+ - **Nullish equality exemptions (closes [#966](https://github.com/apmantza/pi-lens/issues/966))** — allow intentional `== null` / `!= null` checks while retaining other strict-equality findings.
1484
+ - **Security-pin dependency handling (closes [#968](https://github.com/apmantza/pi-lens/issues/968))** — stop Knip from flagging dependencies used only through override/resolution security pins.
1485
+ - **Unsafe-boundary fallback detection (closes [#969](https://github.com/apmantza/pi-lens/issues/969))** — recognize catch blocks that recover through fallback assignments.
1486
+ - **Promise-returning async callbacks (closes [#970](https://github.com/apmantza/pi-lens/issues/970))** — avoid async-noise findings when an explicit Promise return type is required.
1487
+ - **Native zizmor suppressions (closes [#971](https://github.com/apmantza/pi-lens/issues/971))** — honor inline `zizmor: ignore[...]` directives.
1488
+ - **Named URL constants (closes [#964](https://github.com/apmantza/pi-lens/issues/964))** — avoid hardcoded-URL findings for deliberate `SCREAMING_SNAKE_CASE` endpoint constants.
1489
+ - **Opengrep LSP compatibility (closes [#974](https://github.com/apmantza/pi-lens/issues/974))** — stop advertising unused work-done-progress support that could trigger client crash loops.
1490
+ - **Workspace configuration responses (closes [#983](https://github.com/apmantza/pi-lens/issues/983))** — return one resolved value for each requested configuration section.
1491
+ - **Same-file diagnostic ordering (closes [#1198](https://github.com/apmantza/pi-lens/issues/1198))** — prevent late diagnostic work from restoring stale pending results.
1492
+ - **Bounded installer retries (refs [#1222](https://github.com/apmantza/pi-lens/issues/1222), [#1223](https://github.com/apmantza/pi-lens/issues/1223))** — cap failed install retries and preserve probe-cache updates across concurrent writes.
1493
+ - **Stabilize LSP workspace-edit occupancy (closes [#1091](https://github.com/apmantza/pi-lens/issues/1091))** — keep workspace edits consistent under concurrent occupancy.
1494
+ ### Security
1495
+
1496
+ - **Git guard command-injection hardening (refs #1084, PR #1395)** — canonicalizes shell IFS parameter-expansion separators once, quote-aware, before verb classification and treats any non-leading guarded `git` invocation as suspicious, including arbitrary unknown wrappers and run flags; literal text consumers remain non-blocking.
1497
+
1498
+
1499
+ - **`toPosix()` / `splitPathSegments()` path primitives ([#1193](https://github.com/apmantza/pi-lens/issues/1193))** — the sanctioned backslash→forward-slash fold and shape-safe segment split, the single canonical form of the `p.replace(/\\/g, "/")` / `p.split(/[\\/]+/)` idioms otherwise hand-rolled ~138× across the codebase. Pure separator normalization — no resolve/case/realpath (use `normalizeFilePath`/`normalizeMapKey`/`normalizeEphemeralMapKey` when a canonical key is needed). First step of the OS-agnostic transformation-layer consolidation: funnelling the scattered transform makes a shape-2 lint/ast-grep rule possible for the first time (today a bare inline `.replace(/\\/g, "/")` is byte-identical to a sanctioned use; once everything routes through `toPosix`, an un-migrated inline `.replace` becomes detectable — #1158). Adds the primitives + tests only; migrating the existing call sites is a tracked follow-up under #1193.
1500
+
1501
+ - **On-demand heap snapshots for retainer attribution ([#1126](https://github.com/apmantza/pi-lens/issues/1126))** — `PI_LENS_DEBUG_HEAP=1` makes `/lens-health` also write a V8 `.heapsnapshot` to `~/.pi-lens/` (plus a breadcrumb line in `heap-snapshots.log`), so the "which objects retain the bytes" follow-up to #1123's `memory_sample` trajectory is answerable on a live >1 GB instance without a fresh ad-hoc expedition. The flag is read once at startup and the writer mirrors `clients/debug-handles.ts`: zero cost + no file when unset, and the (synchronous, multi-second) snapshot is only ever triggered from the operator-invoked diagnostics command — never a hot path or timer. Snapshot files are pruned to the newest `SNAPSHOT_RETENTION` (3) after each write, bounding the growing on-disk axis (AGENTS.md shape 9). Auto-capture on an RSS threshold is a deliberately-deferred follow-up (it would reintroduce the pause onto an automatic path).
1502
+
1503
+ - **Guard hardening (refs #1084)** - git commit/push detection now covers normalized wrapper launchers (including path- and PATHEXT-qualified names), shell-escaped verbs, keyword, combined-flag, and continuation forms; shell substitutions are treated as execution even inside text consumers, while literal text remains allowed; bash read and ownership grants use quote-aware tokenization and are recorded only after successful tool results. Stale-record recovery remains deferred.
1504
+
1505
+ - **Honor the pi host's project-trust decision before installing or spawning anything (refs #1334)** — pi-lens now reads `ctx.isProjectTrusted()` at every `session_start`. When the host says a project is NOT trusted, tool auto-install degrades to discovery-only (nothing is downloaded or executed) and LSP servers are not spawned; in-process analysis, caches and tree-sitter continue unchanged. Hosts that expose no trust surface behave exactly as before.
1506
+
1507
+ ### Contributors
1508
+
1509
+ - Apostolos Mantzaris (@apmantza)
1510
+ - Nathan Cooke (@3choBoomer)
1511
+ - Eli Stark (@white-hat)
1512
+ - Mark Faga (@mjfaga)
1513
+ - Marvin Aziz (@marvtub)
1514
+ - Dominic (@aeturnal)
1515
+ - Kenneth McCormick
1516
+ - jiangmt
1517
+
13
1518
  ## [3.8.74] - 2026-08-01
14
1519
 
15
1520
  ### Added
@@ -17,1114 +1522,1093 @@ All notable changes to pi-lens will be documented in this file.
17
1522
  - **🙏 Huge thanks to @AngriestBird (Kenny McCormick), this round's standout community contributor** — five merged PRs landed this release: a single source of truth for CLI flags (#1011), secret redaction at the log-write boundary (#954), new slash commands (#906), tree-cache performance improvements (#877), and a test harness for rule-dispatch behavior (#871). Sustained, high-quality work spanning perf, security, config, and test infrastructure — thank you.
18
1523
 
19
1524
  - **Prompt-cache observability (refs #1018, closes #1018)** — two provider-independent
20
- signals now land in `~/.pi-lens/latency.log` as `type: "phase"` records.
21
- (1) Response-side: a defensively feature-detected `message_end` subscription
22
- (clients/agent-nudge.ts pattern — guarded, never throws on older hosts) logs one
23
- `cache_usage` record per assistant message that carries a `usage`, with
24
- `metadata: { provider, model, cacheRead, cacheWrite, input, output, cost }`
25
- (provider/model read straight off the assistant message; `cost` is the total).
26
- Messages with no usage (or non-assistant messages) are skipped rather than
27
- logged as zeros. (2) Request-side: the existing `context` handler now hashes
28
- `messages[0]` on every call and logs a `cache_prefix_break` record
29
- (`metadata: { turnIndex, previousHash, currentHash, sessionId, sessionRole }`,
30
- plus a baseline record on first observation) whenever that hash changes
31
- turn-over-turn — a regression guard that flags anything (pi-lens or otherwise)
32
- breaking the byte-stable prefix #1016 established. The baseline is keyed by the
33
- stable per-session id (`ctx.sessionManager.getSessionId()`) in a small bounded
34
- LRU, so a resume/reload keeps comparing against its own baseline while a
35
- new/fork or a concurrent in-process subagent (#473) gets an independent baseline
36
- instead of a spurious cross-session break; each record is tagged with the
37
- read-only #473 `sessionRole` classification, and a primary `session_shutdown`
38
- drops the ended session's entry. The hash is pure observation: it never changes
39
- the handler's injection behavior or return value, and runs even on non-injecting
40
- turns. New `clients/cache-observability.ts`; no new dependencies.
1525
+ signals now land in `~/.pi-lens/latency.log` as `type: "phase"` records.
1526
+ (1) Response-side: a defensively feature-detected `message_end` subscription
1527
+ (clients/agent-nudge.ts pattern — guarded, never throws on older hosts) logs one
1528
+ `cache_usage` record per assistant message that carries a `usage`, with
1529
+ `metadata: { provider, model, cacheRead, cacheWrite, input, output, cost }`
1530
+ (provider/model read straight off the assistant message; `cost` is the total).
1531
+ Messages with no usage (or non-assistant messages) are skipped rather than
1532
+ logged as zeros. (2) Request-side: the existing `context` handler now hashes
1533
+ `messages[0]` on every call and logs a `cache_prefix_break` record
1534
+ (`metadata: { turnIndex, previousHash, currentHash, sessionId, sessionRole }`,
1535
+ plus a baseline record on first observation) whenever that hash changes
1536
+ turn-over-turn — a regression guard that flags anything (pi-lens or otherwise)
1537
+ breaking the byte-stable prefix #1016 established. The baseline is keyed by the
1538
+ stable per-session id (`ctx.sessionManager.getSessionId()`) in a small bounded
1539
+ LRU, so a resume/reload keeps comparing against its own baseline while a
1540
+ new/fork or a concurrent in-process subagent (#473) gets an independent baseline
1541
+ instead of a spurious cross-session break; each record is tagged with the
1542
+ read-only #473 `sessionRole` classification, and a primary `session_shutdown`
1543
+ drops the ended session's entry. The hash is pure observation: it never changes
1544
+ the handler's injection behavior or return value, and runs even on non-injecting
1545
+ turns. New `clients/cache-observability.ts`; no new dependencies.
41
1546
 
42
1547
  - **Word-index build/refresh/persist outcomes are now durably observable**
43
- (refs #958, #926, #533) — every word-index signal previously rode solely on
44
- the optional `dbg` callback, a documented no-op in the MCP host where
45
- `symbol_search` reads the index. The structured logger (`~/.pi-lens/
46
- word-index.log`, shared `createNdjsonLogger` infra) now records, independent
47
- of `dbg`: the full-rebuild-vs-incremental decision plus honest coverage
48
- (`indexedFileCount`/`truncated` and `refreshed`/`dropped`/`skipped`/`reused`
49
- counts) for both the session-start and cold-query (MCP) build paths; a
50
- swallowed snapshot **persist failure** as `persist_failed` (a silent stale
51
- index otherwise leaves no trace); and the safety refusal for a root at/above
52
- `$HOME`. The full-build collector (`collectWordIndexDocs`) now also returns a
53
- `skipped` count for files it enumerated but could not index (over the byte
54
- cap / unreadable), so a partial index is never reported as complete. The
55
- fragile string-parsing `dbg` adapter this replaces is removed.
56
-
57
- - **Closed four observability gaps in recently-changed typos-config and
58
- project-snapshot code** (refs #533) — none of these change behavior, only
59
- what's now logged: (1) the typos LSP's inject-vs-step-aside decision
60
- (#967) now logs a `typos_config_resolved` phase in `latency.log` /
61
- `sessionstart.log` with `mode: "project_config" | "injected_default"` and
62
- the resolved `configPath`, so which typos config is actually active is no
63
- longer a guess; (2) a project-snapshot body persist that falls back to the
64
- synchronous main-thread gzip because the persist worker died/was
65
- unavailable now logs an explicit `project_snapshot_worker_fallback` phase
66
- with the `reason` (previously only visible via a test-only variable, or
67
- buried in an `offloaded:false` success line) — this is the +656MB-risk
68
- degraded path from #950; (3) a corrupt/truncated gzipped snapshot body
69
- (gunzip/JSON.parse failure) now logs `project_snapshot_body_corrupt`
70
- before failing open to a rebuild, instead of being indistinguishable from
71
- "no snapshot yet"; (4) dropping the in-process authoritative snapshot
72
- entry for an oversized (>24MB) body now logs
73
- `project_snapshot_authoritative_dropped_oversized` — low-priority, but no
74
- longer silent.
1548
+ (refs #958, #926, #533) — every word-index signal previously rode solely on
1549
+ the optional `dbg` callback, a documented no-op in the MCP host where
1550
+ `symbol_search` reads the index. The structured logger (`~/.pi-lens/
1551
+ word-index.log`, shared `createNdjsonLogger` infra) now records, independent
1552
+ of `dbg`: the full-rebuild-vs-incremental decision plus honest coverage
1553
+ (`indexedFileCount`/`truncated` and `refreshed`/`dropped`/`skipped`/`reused`
1554
+ counts) for both the session-start and cold-query (MCP) build paths; a
1555
+ swallowed snapshot **persist failure** as `persist_failed` (a silent stale
1556
+ index otherwise leaves no trace); and the safety refusal for a root at/above
1557
+ `$HOME`. The full-build collector (`collectWordIndexDocs`) now also returns a
1558
+ `skipped` count for files it enumerated but could not index (over the byte
1559
+ cap / unreadable), so a partial index is never reported as complete. The
1560
+ fragile string-parsing `dbg` adapter this replaces is removed.
1561
+
1562
+ - **Closed four observability gaps in recently-changed typos-config and project-snapshot code (refs #533)** — none of these change behavior, only
1563
+ what's now logged: (1) the typos LSP's inject-vs-step-aside decision
1564
+ (#967) now logs a `typos_config_resolved` phase in `latency.log` /
1565
+ `sessionstart.log` with `mode: "project_config" | "injected_default"` and
1566
+ the resolved `configPath`, so which typos config is actually active is no
1567
+ longer a guess; (2) a project-snapshot body persist that falls back to the
1568
+ synchronous main-thread gzip because the persist worker died/was
1569
+ unavailable now logs an explicit `project_snapshot_worker_fallback` phase
1570
+ with the `reason` (previously only visible via a test-only variable, or
1571
+ buried in an `offloaded:false` success line) — this is the +656MB-risk
1572
+ degraded path from #950; (3) a corrupt/truncated gzipped snapshot body
1573
+ (gunzip/JSON.parse failure) now logs `project_snapshot_body_corrupt`
1574
+ before failing open to a rebuild, instead of being indistinguishable from
1575
+ "no snapshot yet"; (4) dropping the in-process authoritative snapshot
1576
+ entry for an oversized (>24MB) body now logs
1577
+ `project_snapshot_authoritative_dropped_oversized` — low-priority, but no
1578
+ longer silent.
75
1579
 
76
1580
  - **Review-graph checkpoint discards and write failures are now observable**
77
- (refs #936, #533) — a present resume checkpoint that's rejected now logs
78
- `checkpoint_discarded` with a `reason` (`corrupt`, `version_mismatch`,
79
- `not_in_progress`, `git_stamp_mismatch`, `ignored_ids_mismatch`,
80
- `removed_file`, `all_stale`) instead of silently falling back to a full cold
81
- rebuild, and a failed checkpoint write (worker error/death, promote failure,
82
- sync-write failure) logs `checkpoint_write_failed` — so "why isn't my
83
- checkpoint resuming / persisting?" is diagnosable from `review-graph.log`.
1581
+ (refs #936, #533) — a present resume checkpoint that's rejected now logs
1582
+ `checkpoint_discarded` with a `reason` (`corrupt`, `version_mismatch`,
1583
+ `not_in_progress`, `git_stamp_mismatch`, `ignored_ids_mismatch`,
1584
+ `removed_file`, `all_stale`) instead of silently falling back to a full cold
1585
+ rebuild, and a failed checkpoint write (worker error/death, promote failure,
1586
+ sync-write failure) logs `checkpoint_write_failed` — so "why isn't my
1587
+ checkpoint resuming / persisting?" is diagnosable from `review-graph.log`.
84
1588
 
85
1589
  - **The review-graph full build is now resumable across sessions** (refs #936
86
- limit 2) — a cold full build (walk + tree-sitter parse of every source file)
87
- previously restarted from scratch every session, so on a large repo with
88
- short-lived sessions it could never finish. The extraction loop now
89
- periodically checkpoints the PRE-resolution graph plus the exact set of files
90
- already folded into it (with content hashes) to a dedicated
91
- `review-graph.checkpoint.json.gz`, and a later session resumes from it,
92
- re-walking only files that changed/appeared since. The checkpoint lives in
93
- its own file (never the authoritative `review-graph.json.gz`) and its
94
- hydrated graph carries `persistCoverage.inProgress` on top of `partial`, so
95
- no reader (`getCachedReviewGraph`, `loadPersistedGraph`) can ever serve or
96
- launder a mid-build checkpoint as a complete graph (honesty doctrine, #533).
97
- Resume equivalence to a cold build is guaranteed by `addFileToGraph`'s
98
- per-file contribution being order-independent (all cross-file linking is
99
- deferred to `resolveDeferredSymbolEdges`): content-changed processed files
100
- are evicted and re-walked, orphaned placeholder nodes are pruned, and any
101
- removed file, ignored-id-set change, version bump, or git-identity mismatch
102
- fails open to a cold build rather than risking a wrong graph. Checkpoint
103
- stride/interval are tunable via `PI_LENS_GRAPH_CHECKPOINT_EVERY_FILES` /
104
- `PI_LENS_GRAPH_CHECKPOINT_MIN_INTERVAL_MS`.
1590
+ limit 2) — a cold full build (walk + tree-sitter parse of every source file)
1591
+ previously restarted from scratch every session, so on a large repo with
1592
+ short-lived sessions it could never finish. The extraction loop now
1593
+ periodically checkpoints the PRE-resolution graph plus the exact set of files
1594
+ already folded into it (with content hashes) to a dedicated
1595
+ `review-graph.checkpoint.json.gz`, and a later session resumes from it,
1596
+ re-walking only files that changed/appeared since. The checkpoint lives in
1597
+ its own file (never the authoritative `review-graph.json.gz`) and its
1598
+ hydrated graph carries `persistCoverage.inProgress` on top of `partial`, so
1599
+ no reader (`getCachedReviewGraph`, `loadPersistedGraph`) can ever serve or
1600
+ launder a mid-build checkpoint as a complete graph (honesty doctrine, #533).
1601
+ Resume equivalence to a cold build is guaranteed by `addFileToGraph`'s
1602
+ per-file contribution being order-independent (all cross-file linking is
1603
+ deferred to `resolveDeferredSymbolEdges`): content-changed processed files
1604
+ are evicted and re-walked, orphaned placeholder nodes are pruned, and any
1605
+ removed file, ignored-id-set change, version bump, or git-identity mismatch
1606
+ fails open to a cold build rather than risking a wrong graph. Checkpoint
1607
+ stride/interval are tunable via `PI_LENS_GRAPH_CHECKPOINT_EVERY_FILES` /
1608
+ `PI_LENS_GRAPH_CHECKPOINT_MIN_INTERVAL_MS`.
105
1609
 
106
1610
  - **Bundled default typos allowlist for projects without their own config**
107
- (fixes #967) — the `typos` auxiliary LSP now injects a small pi-lens-shipped
108
- `_typos.toml` (`rules/typos/_typos.toml`, `[default.extend-identifiers]`
109
- only: `dito`, `unparseable`) via `initializationOptions.config` whenever a
110
- project has no `typos.toml`/`_typos.toml`/`.typos.toml` of its own. A
111
- project's own config still always wins outright: when one is found,
112
- pi-lens injects nothing at all (rather than letting typos-lsp merge ours
113
- on top and risk our defaults outranking the team's own allowlist on key
114
- collisions).
1611
+ (fixes #967) — the `typos` auxiliary LSP now injects a small pi-lens-shipped
1612
+ `_typos.toml` (`rules/typos/_typos.toml`, `[default.extend-identifiers]`
1613
+ only: `dito`, `unparseable`) via `initializationOptions.config` whenever a
1614
+ project has no `typos.toml`/`_typos.toml`/`.typos.toml` of its own. A
1615
+ project's own config still always wins outright: when one is found,
1616
+ pi-lens injects nothing at all (rather than letting typos-lsp merge ours
1617
+ on top and risk our defaults outranking the team's own allowlist on key
1618
+ collisions).
115
1619
 
116
1620
  - **Session-start latency is attributable end to end** (refs #948) — latency
117
- telemetry now separates host boot from pi-lens evaluation and records quick
118
- and full session-start totals, pre-handler/bootstrap work, runtime reset,
119
- log cleanup, LSP reset, sequence/snapshot reads (including snapshot bytes),
120
- and delayed warmup scan/profile/index phases.
1621
+ telemetry now separates host boot from pi-lens evaluation and records quick
1622
+ and full session-start totals, pre-handler/bootstrap work, runtime reset,
1623
+ log cleanup, LSP reset, sequence/snapshot reads (including snapshot bytes),
1624
+ and delayed warmup scan/profile/index phases.
121
1625
 
122
1626
  - **Review-graph LSP fallback nodes** (refs #307) — when tree-sitter yields
123
- zero declarations, the builder may use `documentSymbol` from an already-live,
124
- already-open capable server. Nodes carry `provenance: "lsp"`, hierarchical
125
- containment survives persistence (including flat native-TypeScript-7 results
126
- reconstructed through `containerName`), productive tree-sitter files never
127
- pay the request, and unavailable/failed fallback attempts degrade without
128
- opening or spawning while remaining visible in `review-graph.log`.
1627
+ zero declarations, the builder may use `documentSymbol` from an already-live,
1628
+ already-open capable server. Nodes carry `provenance: "lsp"`, hierarchical
1629
+ containment survives persistence (including flat native-TypeScript-7 results
1630
+ reconstructed through `containerName`), productive tree-sitter files never
1631
+ pay the request, and unavailable/failed fallback attempts degrade without
1632
+ opening or spawning while remaining visible in `review-graph.log`.
129
1633
 
130
1634
  - **Standalone out-of-band review-graph build CLI** (refs #924) — `npx pi-lens
131
- build-graph [--cwd <dir>]` reuses the session builder and queued atomic
132
- persist path for CI/cron, forces the debounced snapshot write before exit,
133
- and prints file/node/edge/element counts, JSON bytes, and duration. Unsafe
134
- roots, build errors/skips, persist failures, and persist-cap trips exit
135
- non-zero with their reason instead of silently leaving no snapshot.
1635
+ build-graph [--cwd <dir>]` reuses the session builder and queued atomic
1636
+ persist path for CI/cron, forces the debounced snapshot write before exit,
1637
+ and prints file/node/edge/element counts, JSON bytes, and duration. Unsafe
1638
+ roots, build errors/skips, persist failures, and persist-cap trips exit
1639
+ non-zero with their reason instead of silently leaving no snapshot.
136
1640
 
137
1641
  - **`/lens-perf` surfaces slow phases in-session** (closes #767) — the command
138
- shows independent top-five p50 and p99 rankings with sample counts for both
139
- the current process session and the machine-wide active `latency.log` window.
140
- It flushes pending writes, streams a tail bounded by the log rotation
141
- threshold (`PI_LENS_MAX_LOG_SIZE_MB`, 10MB by default), caps retained samples,
142
- and reports malformed/truncated input instead of silently reading it as clean.
143
- Session startup total and scan-context computation are now logged as phases so
144
- the startup regressions that motivated the command are visible there too.
1642
+ shows independent top-five p50 and p99 rankings with sample counts for both
1643
+ the current process session and the machine-wide active `latency.log` window.
1644
+ It flushes pending writes, streams a tail bounded by the log rotation
1645
+ threshold (`PI_LENS_MAX_LOG_SIZE_MB`, 10MB by default), caps retained samples,
1646
+ and reports malformed/truncated input instead of silently reading it as clean.
1647
+ Session startup total and scan-context computation are now logged as phases so
1648
+ the startup regressions that motivated the command are visible there too.
145
1649
 
146
1650
  ### Changed
147
1651
 
148
1652
  - **Session-start perf: bound the change-log replay + defer log cleanup (closes #1019)** —
149
- the interactive session-start path recomputed the project sequence by reading
150
- the ENTIRE append-only change log and folding every line — and each fold does a
151
- `normalizeMapKey`/`realpathSync.native()` syscall per historical entry, so the
152
- `session_start_sequence_read` phase (measured ~94ms, ~47% of a 200ms warm start)
153
- grew unbounded with total log length, not the working set. The project snapshot
154
- now embeds the DERIVED sequence index (`projectSeq` + per-file `fileSeqByPath`)
155
- as of its `seq` (`clients/project-snapshot.ts`), mirrored into the tiny
156
- `project-snapshot.meta.json` sidecar so session-start can hydrate it WITHOUT
157
- parsing the 40-112MB body (preserving the #947 skip-stale optimization).
158
- `readLatestProjectSequence` (`clients/project-changes.ts`) gained an optional
159
- base param: it hydrates that index (O(files-in-snapshot), keys already
160
- normalized → no per-key `realpath`) and folds ONLY entries with
161
- `seq > snapshot.seq` — O(changes-since-snapshot). The fold uses the SAME
162
- `Math.max`/`normalizeMapKey` the full replay does, so the result is
163
- byte-identical to a full replay (proven by an equivalence test suite covering
164
- no-new-entries, new/existing files, deletes, gaps/out-of-order, and the empty
165
- log) and order-independent. Correctness-first fallbacks to a full replay: a
166
- legacy/missing meta with no embedded index, a version-mismatched meta, and a
167
- snapshot whose `seq` is AHEAD of the log (truncation/rotation) — it never serves
168
- a wrong seq. Wired into BOTH the quick/interactive and the full session-start
169
- paths. Secondary: `log_cleanup` (~7ms) was moved off the synchronous critical
170
- path into a deferred `setImmediate` (it still runs every session and notifies
171
- async — nothing on the hot path consumed its result).
1653
+ the interactive session-start path recomputed the project sequence by reading
1654
+ the ENTIRE append-only change log and folding every line — and each fold does a
1655
+ `normalizeMapKey`/`realpathSync.native()` syscall per historical entry, so the
1656
+ `session_start_sequence_read` phase (measured ~94ms, ~47% of a 200ms warm start)
1657
+ grew unbounded with total log length, not the working set. The project snapshot
1658
+ now embeds the DERIVED sequence index (`projectSeq` + per-file `fileSeqByPath`)
1659
+ as of its `seq` (`clients/project-snapshot.ts`), mirrored into the tiny
1660
+ `project-snapshot.meta.json` sidecar so session-start can hydrate it WITHOUT
1661
+ parsing the 40-112MB body (preserving the #947 skip-stale optimization).
1662
+ `readLatestProjectSequence` (`clients/project-changes.ts`) gained an optional
1663
+ base param: it hydrates that index (O(files-in-snapshot), keys already
1664
+ normalized → no per-key `realpath`) and folds ONLY entries with
1665
+ `seq > snapshot.seq` — O(changes-since-snapshot). The fold uses the SAME
1666
+ `Math.max`/`normalizeMapKey` the full replay does, so the result is
1667
+ byte-identical to a full replay (proven by an equivalence test suite covering
1668
+ no-new-entries, new/existing files, deletes, gaps/out-of-order, and the empty
1669
+ log) and order-independent. Correctness-first fallbacks to a full replay: a
1670
+ legacy/missing meta with no embedded index, a version-mismatched meta, and a
1671
+ snapshot whose `seq` is AHEAD of the log (truncation/rotation) — it never serves
1672
+ a wrong seq. Wired into BOTH the quick/interactive and the full session-start
1673
+ paths. Secondary: `log_cleanup` (~7ms) was moved off the synchronous critical
1674
+ path into a deferred `setImmediate` (it still runs every session and notifies
1675
+ async — nothing on the hot path consumed its result).
172
1676
 
173
1677
  - **Config-consistency pass on the #166 flag registry (refs #166, #533)** — the
174
- #883 registry core (scope split, precedence, negation) was audited consistent
175
- and left untouched; the gaps were all in #533 malformed/unknown-input
176
- signalling, asymmetric between the global and project loaders. (1) The
177
- project loader (`.pi-lens.json`) now warns once on an unrecognized top-level
178
- key — a typo like `maxProjectFile` or `lps` previously did nothing silently —
179
- while tolerating the foreign namespaces the shared file legitimately carries
180
- for the LSP loader (`servers`, `serverOverrides`, `disabledServers`,
181
- `warmFiles`, plus `$schema`); a user-level-only lens key placed at project
182
- scope (e.g. `lsp`, `tests`, `delta`) gets a distinct "not honored at project
183
- scope" signal instead of being lumped in with typos. (2) The recognized-key
184
- catalogs are single-sourced (#883): `GLOBAL_NON_FLAG_CONFIG_SECTIONS` and
185
- `PROJECT_FOREIGN_CONFIG_NAMESPACES` are declared once beside `LENS_FLAGS`, and
186
- both loaders derive flag sections from the registry, so adding a namespace is
187
- a one-line edit and adding a flag needs none — replacing a drift-prone inline
188
- literal set in the global loader. (3) Three global scalars
189
- (`dispatch.runnerTimeoutFloorMs`, `widget.visible`, `format.mode`) that
190
- silently coerced a present-but-malformed value to `undefined` now warn on
191
- invalid input through the same path as `actionableWarnings.autoFix.maxFixes`,
192
- while staying silent when the key is absent (no false warnings). (4) Removed
193
- five dead global-only accessors that bypassed the precedence chain
194
- (`getGlobalAutoformatEnabled`, `getGlobalAutofixEnabled`,
195
- `getGlobalImmediateFormatDefault`, `getGlobalContextInjectionEnabled`,
196
- `getGlobalTurnSummaryEnabled`) — no non-test callers existed. (5) Updated
197
- `docs/globalconfig.md`, whose "unknown keys are ignored" claim was stale.
1678
+ #883 registry core (scope split, precedence, negation) was audited consistent
1679
+ and left untouched; the gaps were all in #533 malformed/unknown-input
1680
+ signalling, asymmetric between the global and project loaders. (1) The
1681
+ project loader (`.pi-lens.json`) now warns once on an unrecognized top-level
1682
+ key — a typo like `maxProjectFile` or `lps` previously did nothing silently —
1683
+ while tolerating the foreign namespaces the shared file legitimately carries
1684
+ for the LSP loader (`servers`, `serverOverrides`, `disabledServers`,
1685
+ `warmFiles`, plus `$schema`); a user-level-only lens key placed at project
1686
+ scope (e.g. `lsp`, `tests`, `delta`) gets a distinct "not honored at project
1687
+ scope" signal instead of being lumped in with typos. (2) The recognized-key
1688
+ catalogs are single-sourced (#883): `GLOBAL_NON_FLAG_CONFIG_SECTIONS` and
1689
+ `PROJECT_FOREIGN_CONFIG_NAMESPACES` are declared once beside `LENS_FLAGS`, and
1690
+ both loaders derive flag sections from the registry, so adding a namespace is
1691
+ a one-line edit and adding a flag needs none — replacing a drift-prone inline
1692
+ literal set in the global loader. (3) Three global scalars
1693
+ (`dispatch.runnerTimeoutFloorMs`, `widget.visible`, `format.mode`) that
1694
+ silently coerced a present-but-malformed value to `undefined` now warn on
1695
+ invalid input through the same path as `actionableWarnings.autoFix.maxFixes`,
1696
+ while staying silent when the key is absent (no false warnings). (4) Removed
1697
+ five dead global-only accessors that bypassed the precedence chain
1698
+ (`getGlobalAutoformatEnabled`, `getGlobalAutofixEnabled`,
1699
+ `getGlobalImmediateFormatDefault`, `getGlobalContextInjectionEnabled`,
1700
+ `getGlobalTurnSummaryEnabled`) — no non-test callers existed. (5) Updated
1701
+ `docs/globalconfig.md`, whose "unknown keys are ignored" claim was stale.
198
1702
 
199
1703
  - **Normalized call-graph impact checks into structured `ProjectDiagnostic`s**
200
- (refs #179) — the last remaining adapter gap in #179's normalization sweep.
201
- `runtime-turn.ts`'s turn-end call-graph impact check (WillBreak/MayBreak
202
- callers of symbols edited this turn, via `impact()`/`call-graph.ts`) was
203
- advisory-text only, unlike the other 9 runners already normalized through
204
- `runner-adapters/*.ts`. Added `callGraphImpactToProjectDiagnostics`
205
- (`project-diagnostics/runner-adapters/call-graph-impact.ts`), a pure mapper
206
- attributing each diagnostic to the CALLER's file (no line — `impact()`'s
207
- `ImpactResult` never carries one, and the underlying call-graph symbol
208
- table only has a placeholder `line: 1`, so inventing one would be a fake
209
- location). Severity is deliberately capped below this codebase's
210
- "blocking" hard-stop tier for BOTH WillBreak and MayBreak (#533 honesty):
211
- `impact()` is a name-resolution heuristic with no type information, so a
212
- resolved caller is never a confirmed break. Wired at turn-end (not
213
- `fresh-fetch.ts`/`ANALYZER_IDS`) since impact analysis is inherently
214
- per-turn-edit, not a whole-project scan — same reasoning that keeps
215
- test-runner's own turn-end fire out of that registry. The existing
216
- human-readable advisory string is kept as-is (separate surface, same
217
- dual-surface pattern already used by knip's turn-end delta). The call-graph
218
- block now runs BEFORE the single `writeProjectDiagnosticsDeltaReport`
219
- serialization point (alongside knip's delta push) rather than after it —
220
- previously it pushed into `projectDiagnosticsDelta`/`projectDiagnosticsSources`
221
- past the one-shot write, so a call-graph-only turn persisted nothing and a
222
- mixed turn dropped the call-graph entries, leaving `lens_diagnostics` (a
223
- pure reader of the persisted report) unable to ever surface the findings
224
- (#533). Covered by a new `handleTurnEnd`-level regression test asserting the
225
- persisted report for both call-graph-only and mixed turns.
1704
+ (refs #179) — the last remaining adapter gap in #179's normalization sweep.
1705
+ `runtime-turn.ts`'s turn-end call-graph impact check (WillBreak/MayBreak
1706
+ callers of symbols edited this turn, via `impact()`/`call-graph.ts`) was
1707
+ advisory-text only, unlike the other 9 runners already normalized through
1708
+ `runner-adapters/*.ts`. Added `callGraphImpactToProjectDiagnostics`
1709
+ (`project-diagnostics/runner-adapters/call-graph-impact.ts`), a pure mapper
1710
+ attributing each diagnostic to the CALLER's file (no line — `impact()`'s
1711
+ `ImpactResult` never carries one, and the underlying call-graph symbol
1712
+ table only has a placeholder `line: 1`, so inventing one would be a fake
1713
+ location). Severity is deliberately capped below this codebase's
1714
+ "blocking" hard-stop tier for BOTH WillBreak and MayBreak (#533 honesty):
1715
+ `impact()` is a name-resolution heuristic with no type information, so a
1716
+ resolved caller is never a confirmed break. Wired at turn-end (not
1717
+ `fresh-fetch.ts`/`ANALYZER_IDS`) since impact analysis is inherently
1718
+ per-turn-edit, not a whole-project scan — same reasoning that keeps
1719
+ test-runner's own turn-end fire out of that registry. The existing
1720
+ human-readable advisory string is kept as-is (separate surface, same
1721
+ dual-surface pattern already used by knip's turn-end delta). The call-graph
1722
+ block now runs BEFORE the single `writeProjectDiagnosticsDeltaReport`
1723
+ serialization point (alongside knip's delta push) rather than after it —
1724
+ previously it pushed into `projectDiagnosticsDelta`/`projectDiagnosticsSources`
1725
+ past the one-shot write, so a call-graph-only turn persisted nothing and a
1726
+ mixed turn dropped the call-graph entries, leaving `lens_diagnostics` (a
1727
+ pure reader of the persisted report) unable to ever surface the findings
1728
+ (#533). Covered by a new `handleTurnEnd`-level regression test asserting the
1729
+ persisted report for both call-graph-only and mixed turns.
226
1730
 
227
1731
  - **Parallelized the per-turn madge dependency check** (refs #766) — the
228
- turn-end circular-dependency pass previously ran one `await checkFile()`
229
- per import-changed file in a sequential `for…await` loop, serializing N
230
- madge subprocess spawns. `DependencyChecker.checkFilesBatch()` now runs
231
- those spawns concurrently (bounded to 6 in flight) via a new turn-end
232
- entry point. `DependencyChecker` keeps its circular-dep findings
233
- (`lastCircular`/`circularFiles`) as shared instance state that a single-file
234
- check overwrites wholesale, so naively parallelizing risked one file's
235
- spawn clobbering a sibling's write depending on subprocess completion
236
- order. The fix keeps every spawn's result local until all have settled,
237
- then folds them into the shared state in original file order — matching
238
- the sequential loop's file-by-file overwrite exactly, just without waiting
239
- for each subprocess before starting the next. Behavior-preserving: same
240
- per-file results, same final circular-dep state, same `dbg` logs — only
241
- wall-clock time changes.
242
-
243
- - **Every runtime toggle is now settable from BOTH the CLI and
244
- `~/.pi-lens/config.json`, driven by one declarative registry** (closes #166) —
245
- the flag/config mapping used to live in two disconnected places (twelve
246
- `pi.registerFlag` calls in `index.ts` and an if/else chain in
247
- `resolvePiLensFlagWithSource`), which let coverage gaps open and persist.
248
- Seven flags were registered on the CLI but fell straight through the
249
- resolver, so config could never set them: `--no-lens`, `--no-lsp`,
250
- `--no-tests`, `--no-delta`, `--lens-guard`, `--no-opengrep`, and
251
- `--no-read-guard`. They now read `lens.enabled`, `lsp.enabled`,
252
- `tests.enabled`, `delta.enabled`, `guard.enabled`, `opengrep.enabled`, and
253
- `readGuard.enabled` (positive keys — a `--no-*` flag is the key set `false`).
254
- The four actionable-warning toggles went the other way: they were
255
- config-only and are now registered CLI flags too
256
- (`--lens-actionable-warnings`, `--lens-actionable-warning-actions`,
257
- `--lens-actionable-warning-autofix`, `--lens-actionable-warning-all`).
258
- `clients/lens-flag-registry.ts` is the single source both surfaces read, so
259
- they cannot diverge again; the wiring test derives its expected flag set from
260
- it rather than restating it (the old hand-written list had already drifted,
261
- missing `lens-turn-summary`).
1732
+ turn-end circular-dependency pass previously ran one `await checkFile()`
1733
+ per import-changed file in a sequential `for…await` loop, serializing N
1734
+ madge subprocess spawns. `DependencyChecker.checkFilesBatch()` now runs
1735
+ those spawns concurrently (bounded to 6 in flight) via a new turn-end
1736
+ entry point. `DependencyChecker` keeps its circular-dep findings
1737
+ (`lastCircular`/`circularFiles`) as shared instance state that a single-file
1738
+ check overwrites wholesale, so naively parallelizing risked one file's
1739
+ spawn clobbering a sibling's write depending on subprocess completion
1740
+ order. The fix keeps every spawn's result local until all have settled,
1741
+ then folds them into the shared state in original file order — matching
1742
+ the sequential loop's file-by-file overwrite exactly, just without waiting
1743
+ for each subprocess before starting the next. Behavior-preserving: same
1744
+ per-file results, same final circular-dep state, same `dbg` logs — only
1745
+ wall-clock time changes.
1746
+
1747
+ - **Every runtime toggle is now settable from BOTH the CLI and `~/.pi-lens/config.json`, driven by one declarative registry (closes #166)** —
1748
+ the flag/config mapping used to live in two disconnected places (twelve
1749
+ `pi.registerFlag` calls in `index.ts` and an if/else chain in
1750
+ `resolvePiLensFlagWithSource`), which let coverage gaps open and persist.
1751
+ Seven flags were registered on the CLI but fell straight through the
1752
+ resolver, so config could never set them: `--no-lens`, `--no-lsp`,
1753
+ `--no-tests`, `--no-delta`, `--lens-guard`, `--no-opengrep`, and
1754
+ `--no-read-guard`. They now read `lens.enabled`, `lsp.enabled`,
1755
+ `tests.enabled`, `delta.enabled`, `guard.enabled`, `opengrep.enabled`, and
1756
+ `readGuard.enabled` (positive keys — a `--no-*` flag is the key set `false`).
1757
+ The four actionable-warning toggles went the other way: they were
1758
+ config-only and are now registered CLI flags too
1759
+ (`--lens-actionable-warnings`, `--lens-actionable-warning-actions`,
1760
+ `--lens-actionable-warning-autofix`, `--lens-actionable-warning-all`).
1761
+ `clients/lens-flag-registry.ts` is the single source both surfaces read, so
1762
+ they cannot diverge again; the wiring test derives its expected flag set from
1763
+ it rather than restating it (the old hand-written list had already drifted,
1764
+ missing `lens-turn-summary`).
262
1765
 
263
1766
  - **`actionableWarnings.autoFix.maxFixes` is now actually read** (#166) —
264
- documented in `globalconfig.md` since #792 but no loader ever parsed it, so
265
- `agent_end` always used the hardcoded default of 5 no matter what the config
266
- said. Same defect class as the seven flags above: a documented config key
267
- with nothing wiring it to the code that consumes it. Accepts any
268
- non-negative whole number; `0` keeps the warning report while applying
269
- nothing.
270
-
271
- - **Flag provenance gained an `env` tier and reports the global tier more
272
- accurately** (#166) — `PI_LENS_NO_CONTEXT_INJECTION` moved out of a
273
- hardcoded check in `index.ts` into the registry's `env` binding, so it
274
- resolves through the same chain as everything else (env → cli →
275
- nested-project → project → global → default) and reports `source: "env"`.
276
- The global tier now reports `source: "global"` whenever the key is present,
277
- not only when it happens to differ from the built-in default. Affects debug
278
- and mutation-skip log lines only, no resolved values change.
279
-
280
- - **Unknown top-level keys in `~/.pi-lens/config.json` now warn once instead of
281
- being dropped silently** (#166, refs #533) — a typo like `lps` for `lsp`
282
- previously did nothing with no signal. The recognized-key set is derived from
283
- the flag registry (`LENS_FLAGS`) plus the non-flag sections (`ignore`,
284
- `dispatch`, `actionableWarnings`, `widget`) and `$schema`, so it stays in sync
285
- with the single source of truth; anything else logs one `[pi-lens] ignoring
286
- invalid global config …: unknown key "…"` line and is ignored.
287
-
288
- - **The review-graph resume checkpoint (#936) now offloads its gzip to the
289
- shared persist worker** (refs #958, #883) — mid-build checkpoint writes
290
- previously ran a synchronous `gzipSync` of the growing graph on the event
291
- loop; they now stream the stringify+gzip through the same worker the
292
- authoritative snapshot uses (via the newly-shared `writeGzipStageFile` core),
293
- generation-gated so a slow write can't clobber a newer checkpoint or
294
- resurrect one after the build completes and retires it, and falling back to a
295
- synchronous write when the worker is unavailable. Best-effort as before — a
296
- lost checkpoint only costs a cold rebuild.
1767
+ documented in `globalconfig.md` since #792 but no loader ever parsed it, so
1768
+ `agent_end` always used the hardcoded default of 5 no matter what the config
1769
+ said. Same defect class as the seven flags above: a documented config key
1770
+ with nothing wiring it to the code that consumes it. Accepts any
1771
+ non-negative whole number; `0` keeps the warning report while applying
1772
+ nothing.
1773
+
1774
+ - **Flag provenance gained an `env` tier and reports the global tier more accurately** (#166) — `PI_LENS_NO_CONTEXT_INJECTION` moved out of a
1775
+ hardcoded check in `index.ts` into the registry's `env` binding, so it
1776
+ resolves through the same chain as everything else (env → cli →
1777
+ nested-project → project → global → default) and reports `source: "env"`.
1778
+ The global tier now reports `source: "global"` whenever the key is present,
1779
+ not only when it happens to differ from the built-in default. Affects debug
1780
+ and mutation-skip log lines only, no resolved values change.
1781
+
1782
+ - **Unknown top-level keys in `~/.pi-lens/config.json` now warn once instead of being dropped silently** (#166, refs #533) — a typo like `lps` for `lsp`
1783
+ previously did nothing with no signal. The recognized-key set is derived from
1784
+ the flag registry (`LENS_FLAGS`) plus the non-flag sections (`ignore`,
1785
+ `dispatch`, `actionableWarnings`, `widget`) and `$schema`, so it stays in sync
1786
+ with the single source of truth; anything else logs one `[pi-lens] ignoring
1787
+ invalid global config …: unknown key "…"` line and is ignored.
1788
+
1789
+ - **The review-graph resume checkpoint (#936) now offloads its gzip to the shared persist worker (refs #958, #883)** — mid-build checkpoint writes
1790
+ previously ran a synchronous `gzipSync` of the growing graph on the event
1791
+ loop; they now stream the stringify+gzip through the same worker the
1792
+ authoritative snapshot uses (via the newly-shared `writeGzipStageFile` core),
1793
+ generation-gated so a slow write can't clobber a newer checkpoint or
1794
+ resurrect one after the build completes and retires it, and falling back to a
1795
+ synchronous write when the worker is unavailable. Best-effort as before — a
1796
+ lost checkpoint only costs a cold rebuild.
297
1797
 
298
1798
  - **Project snapshot body is now written gzipped by a worker thread** (refs
299
- #958 item 2) — the snapshot body (40-112MB observed) is persisted as
300
- `project-snapshot.json.gz`, with the `JSON.stringify` + gzip run on a worker
301
- thread off the save path, mirroring the review graph's
302
- `persist-worker.ts`/generation-gated-promotion pattern (gzip measured 5-10x
303
- on top of the #957 compaction win; the review-graph's own measurement was
304
- 60MB → 1.4MB). A slow worker write for generation N is discarded rather than
305
- promoted over a newer generation N+1 already on disk, and the loader still
306
- reads the previous uncompressed `project-snapshot.json` for one compatibility
307
- release so an upgrade never loses a snapshot. The save path deliberately does
308
- NOT sync-gzip (the #950 review measured a naïve sync gzip regressing host
309
- memory by +656MB); when the worker is unavailable/dies the pending body falls
310
- back to a synchronous main-thread gzip write, surfaced via the logger rather
311
- than silently presented as saved (#533). Read-your-writes across the async
312
- promotion is preserved by an in-process authoritative "latest write" that
313
- `loadProjectSnapshot` consults before disk, so the merge-write callers
314
- (`saveRuntimeProjectSnapshot`, word index, reverse deps) never observe a
315
- stale body in the promotion window.
1799
+ #958 item 2) — the snapshot body (40-112MB observed) is persisted as
1800
+ `project-snapshot.json.gz`, with the `JSON.stringify` + gzip run on a worker
1801
+ thread off the save path, mirroring the review graph's
1802
+ `persist-worker.ts`/generation-gated-promotion pattern (gzip measured 5-10x
1803
+ on top of the #957 compaction win; the review-graph's own measurement was
1804
+ 60MB → 1.4MB). A slow worker write for generation N is discarded rather than
1805
+ promoted over a newer generation N+1 already on disk, and the loader still
1806
+ reads the previous uncompressed `project-snapshot.json` for one compatibility
1807
+ release so an upgrade never loses a snapshot. The save path deliberately does
1808
+ NOT sync-gzip (the #950 review measured a naïve sync gzip regressing host
1809
+ memory by +656MB); when the worker is unavailable/dies the pending body falls
1810
+ back to a synchronous main-thread gzip write, surfaced via the logger rather
1811
+ than silently presented as saved (#533). Read-your-writes across the async
1812
+ promotion is preserved by an in-process authoritative "latest write" that
1813
+ `loadProjectSnapshot` consults before disk, so the merge-write callers
1814
+ (`saveRuntimeProjectSnapshot`, word index, reverse deps) never observe a
1815
+ stale body in the promotion window.
316
1816
 
317
1817
  - **Session warmup refreshes the word index incrementally** (refs #958) —
318
- the persisted serializer now carries per-file mtimes. Startup still performs
319
- the bounded source walk, but reuses unchanged postings, re-tokenizes only
320
- stale/new files, and drops deleted files; legacy indexes, refresh failures,
321
- and file-set churn above 30% fall back to a full rebuild. The current derived
322
- file cap and `truncated` state are re-evaluated on every refresh, and
323
- `warmup_word_index` telemetry records mode/refreshed/dropped/reused counts.
1818
+ the persisted serializer now carries per-file mtimes. Startup still performs
1819
+ the bounded source walk, but reuses unchanged postings, re-tokenizes only
1820
+ stale/new files, and drops deleted files; legacy indexes, refresh failures,
1821
+ and file-set churn above 30% fall back to a full rebuild. The current derived
1822
+ file cap and `truncated` state are re-evaluated on every refresh, and
1823
+ `warmup_word_index` telemetry records mode/refreshed/dropped/reused counts.
324
1824
 
325
1825
  - **Project scans feed compact structural IR into review-graph builds** (refs
326
- #939) — each fully completed scanner file publishes content-hash-bound
327
- imports, reexports, function summaries, symbols, and references. A following
328
- or overlapping graph build reuses only exact-hash, successful entries;
329
- stale, failed, absent, and cold one-shot paths parse normally. The handoff
330
- retains neither source content nor WASM trees, and cancelled scans expose
331
- only files completed before cancellation.
1826
+ #939) — each fully completed scanner file publishes content-hash-bound
1827
+ imports, reexports, function summaries, symbols, and references. A following
1828
+ or overlapping graph build reuses only exact-hash, successful entries;
1829
+ stale, failed, absent, and cold one-shot paths parse normally. The handoff
1830
+ retains neither source content nor WASM trees, and cancelled scans expose
1831
+ only files completed before cancellation.
332
1832
 
333
1833
  - **Session-start performance: LSP pre-warm, snapshot meta-gate, compact cached snapshot** (refs #947) — three
334
- measured startup wins: (1) the dominant-language LSP pre-warm was gated on
335
- full startup mode that the first-session heuristic never allows (82 quick
336
- vs 0 full starts in 31k dogfood log lines), so it now runs inside the
337
- quick-mode +2s background warmup pass — once per process,
338
- generation-guarded, honoring subagent light mode, warm-attach, the
339
- `no-lsp` flag, and the `canWarmCaches` guard, with a `warmup_lsp_prewarm`
340
- phase record; (2) session start no longer sync-parses the whole
341
- `project-snapshot.json` body (110-130ms at 40MB, ~0.5s at 112MB) before
342
- checking freshness — the tiny `project-snapshot.meta.json` sidecar is read
343
- first and a stale seq/version skips the body parse entirely
344
- (`session_start_snapshot_load` records `skippedStale: true`), with
345
- missing-meta installs falling back to the legacy parse; (3) the snapshot
346
- is serialized compactly (~30% smaller) and its parsed body is cached
347
- in-process per (path, mtime), so `saveRuntimeProjectSnapshot` and the
348
- session-start/word-index/scan-context consumers no longer re-parse a file
349
- pi-lens itself wrote seconds earlier.
1834
+ measured startup wins: (1) the dominant-language LSP pre-warm was gated on
1835
+ full startup mode that the first-session heuristic never allows (82 quick
1836
+ vs 0 full starts in 31k dogfood log lines), so it now runs inside the
1837
+ quick-mode +2s background warmup pass — once per process,
1838
+ generation-guarded, honoring subagent light mode, warm-attach, the
1839
+ `no-lsp` flag, and the `canWarmCaches` guard, with a `warmup_lsp_prewarm`
1840
+ phase record; (2) session start no longer sync-parses the whole
1841
+ `project-snapshot.json` body (110-130ms at 40MB, ~0.5s at 112MB) before
1842
+ checking freshness — the tiny `project-snapshot.meta.json` sidecar is read
1843
+ first and a stale seq/version skips the body parse entirely
1844
+ (`session_start_snapshot_load` records `skippedStale: true`), with
1845
+ missing-meta installs falling back to the legacy parse; (3) the snapshot
1846
+ is serialized compactly (~30% smaller) and its parsed body is cached
1847
+ in-process per (path, mtime), so `saveRuntimeProjectSnapshot` and the
1848
+ session-start/word-index/scan-context consumers no longer re-parse a file
1849
+ pi-lens itself wrote seconds earlier.
350
1850
 
351
1851
  - **Warm LSP names enrich tree-sitter read expansion** (refs #158) — partial
352
- read expansion keeps tree-sitter's line boundaries authoritative, but an
353
- already-open document with an already-active LSP can now replace the display
354
- name/kind from `documentSymbol` (including `Class.method` ancestry) within a
355
- 150 ms best-effort ceiling. Cold, closed, unsupported, timed-out, or failed
356
- servers retain the tree-sitter identity, and `ts_range_expanded` records
357
- whether enrichment succeeded.
1852
+ read expansion keeps tree-sitter's line boundaries authoritative, but an
1853
+ already-open document with an already-active LSP can now replace the display
1854
+ name/kind from `documentSymbol` (including `Class.method` ancestry) within a
1855
+ 150 ms best-effort ceiling. Cold, closed, unsupported, timed-out, or failed
1856
+ servers retain the tree-sitter identity, and `ts_range_expanded` records
1857
+ whether enrichment succeeded.
358
1858
 
359
1859
  - **Installer subprocesses are lifetime-coupled** (refs #945) — npm, pip, gem,
360
- and archive extraction now use the shared safe-spawn path, await full Windows
361
- process-tree termination on timeout, and synchronously clean registered
362
- installer children during parent exit/signals.
1860
+ and archive extraction now use the shared safe-spawn path, await full Windows
1861
+ process-tree termination on timeout, and synchronously clean registered
1862
+ installer children during parent exit/signals.
363
1863
 
364
1864
  - **Managed tool installs are cross-process serialized** (refs #945) — a
365
- dependency-free atomic lock protects the shared tools tree, verifies owners
366
- before stale recovery, bounds lock waits with an honest error, and rechecks
367
- discovery after acquisition to avoid duplicate package-manager runs.
1865
+ dependency-free atomic lock protects the shared tools tree, verifies owners
1866
+ before stale recovery, bounds lock waits with an honest error, and rechecks
1867
+ discovery after acquisition to avoid duplicate package-manager runs.
368
1868
 
369
1869
  - **Ordinary tests never install managed tools** (refs #945) — Vitest sets
370
- `PI_LENS_DISABLE_TOOL_INSTALL=1`, its prewarm step creates a local synthetic
371
- oxlint probe-cache entry without networking, and one-shot analysis explicitly
372
- awaits probe-cache persistence before exit.
1870
+ `PI_LENS_DISABLE_TOOL_INSTALL=1`, its prewarm step creates a local synthetic
1871
+ oxlint probe-cache entry without networking, and one-shot analysis explicitly
1872
+ awaits probe-cache persistence before exit.
373
1873
 
374
1874
  - **Installer orphan/locking regressions are process-tested** (refs #945) —
375
- fake package-manager coverage verifies Windows timeout tree-kill, exactly one
376
- install across concurrent processes, explicit install-disable refusal, and
377
- Vitest's default no-install environment.
1875
+ fake package-manager coverage verifies Windows timeout tree-kill, exactly one
1876
+ install across concurrent processes, explicit install-disable refusal, and
1877
+ Vitest's default no-install environment.
378
1878
 
379
- - **Downgrade TypeScript `unsafe-regex` to advisory and suppress escaped-before-
380
- assignment false positives** (refs #932) — the coarse dynamic `RegExp`
1879
+ - **Downgrade TypeScript `unsafe-regex` to advisory and suppress escaped-before- assignment false positives (refs #932)** — the coarse dynamic `RegExp`
381
1880
  heuristic no longer blocks edits and recognizes escape/replace calls in a
382
1881
  same-file identifier initializer; structural ReDoS detection remains with
383
1882
  the `redos-nested-quantifier` ast-grep rule.
384
1883
 
385
- - **Review-graph persistence no longer serializes or compresses on the event
386
- loop** (refs #939) — debounced snapshots are materialized in one lazy,
387
- unref'd worker and streamed through gzip into the new canonical
388
- `review-graph.json.gz` cache. The main thread promotes only the current
389
- generation's atomic staged file, so the synchronous CLI/exit flush can
390
- supersede an in-flight worker without a stale overwrite. Loads retain one
391
- release of fallback support for legacy uncompressed `review-graph.json`
392
- snapshots; worker failures are logged and degrade to a synchronous persist.
393
- Persist telemetry now records element count, raw/gzip bytes, serialization
394
- and write time, and whether the work was offloaded.
1884
+ - **Review-graph persistence no longer serializes or compresses on the event loop (refs #939)** — debounced snapshots are materialized in one lazy,
1885
+ unref'd worker and streamed through gzip into the new canonical
1886
+ `review-graph.json.gz` cache. The main thread promotes only the current
1887
+ generation's atomic staged file, so the synchronous CLI/exit flush can
1888
+ supersede an in-flight worker without a stale overwrite. Loads retain one
1889
+ release of fallback support for legacy uncompressed `review-graph.json`
1890
+ snapshots; worker failures are logged and degrade to a synchronous persist.
1891
+ Persist telemetry now records element count, raw/gzip bytes, serialization
1892
+ and write time, and whether the work was offloaded.
395
1893
 
396
1894
  - **Raise and make the review-graph persist ceiling useful** (refs #936) — the
397
- default `GRAPH_PERSIST_MAX_ELEMENTS` cap is now 500,000 (still overrideable
398
- through `PI_LENS_GRAPH_PERSIST_MAX_ELEMENTS`), matching measured startup
399
- load/reindex costs and allowing the ~208,000-element #919 repository to
400
- persist completely. Above the cap, persistence retains whole-file node groups
401
- in existing reverse-dependency-centrality order plus induced edges that fit,
402
- instead of dropping the snapshot. Exact total-versus-persisted node/edge
403
- counts are stored and surfaced; read-only queries may use the partial graph,
404
- while incremental builds reject it as a complete base.
1895
+ default `GRAPH_PERSIST_MAX_ELEMENTS` cap is now 500,000 (still overrideable
1896
+ through `PI_LENS_GRAPH_PERSIST_MAX_ELEMENTS`), matching measured startup
1897
+ load/reindex costs and allowing the ~208,000-element #919 repository to
1898
+ persist completely. Above the cap, persistence retains whole-file node groups
1899
+ in existing reverse-dependency-centrality order plus induced edges that fit,
1900
+ instead of dropping the snapshot. Exact total-versus-persisted node/edge
1901
+ counts are stored and surfaced; read-only queries may use the partial graph,
1902
+ while incremental builds reject it as a complete base.
405
1903
 
406
1904
  - **Logger hot paths now coalesce queued lines and rotate during long sessions** (refs #935) — contiguous NDJSON entries drain through one append up to each truncate boundary while retaining peek-then-remove exit safety and one-write cross-process atomicity. `sessionstart.log` now uses one shared asynchronous writer for ordinary diagnostics (with the crash-adjacent LSP launch write intentionally synchronous), and latency/cascade/tree-sitter/bus-event logs enforce the existing 10 MB cap in process.
407
1905
 
408
- - **Incremental review-graph updates avoid redundant whole-graph copies and
409
- index rebuilds** (refs #939) — file re-extraction now rebuilds derived indexes
410
- once, immutable edges are array-copied without cloning every edge object, the
411
- updated graph itself becomes the workspace snapshot, and debounced persistence
412
- defers its O(graph) array materialization until the quiet-window flush.
1906
+ - **Incremental review-graph updates avoid redundant whole-graph copies and index rebuilds (refs #939)** — file re-extraction now rebuilds derived indexes
1907
+ once, immutable edges are array-copied without cloning every edge object, the
1908
+ updated graph itself becomes the workspace snapshot, and debounced persistence
1909
+ defers its O(graph) array materialization until the quiet-window flush.
413
1910
 
414
1911
  - **Reverse-dependency indexes update at import-edge granularity** (refs #939)
415
- instead of rebuilding from every graph edge after a one-file edit. Body-only
416
- edits reuse the cached index without rewriting the project snapshot; import
417
- changes patch only the touched `imports` and `importedBy` buckets.
1912
+ instead of rebuilding from every graph edge after a one-file edit. Body-only
1913
+ edits reuse the cached index without rewriting the project snapshot; import
1914
+ changes patch only the touched `imports` and `importedBy` buckets.
418
1915
 
419
1916
  - **Review-graph file-cap degradation is now explicit and count-honest** (refs
420
- #921) — `project_report` says a capped project has “more than N files” instead
421
- of presenting the cap+1 early-exit sentinel as an exact count. `module_report`
422
- now marks graph-backed `usedBy`, blast-radius provenance, and
423
- `semantic.source` as `unavailable:file-cap` and emits an actionable warning
424
- with the cap plus both `.pi-lens.json#maxProjectFiles` and
425
- `PI_LENS_REVIEW_GRAPH_MAX_FILES` controls, keeping disabled data distinct from
426
- a genuinely empty/cold graph.
1917
+ #921) — `project_report` says a capped project has “more than N files” instead
1918
+ of presenting the cap+1 early-exit sentinel as an exact count. `module_report`
1919
+ now marks graph-backed `usedBy`, blast-radius provenance, and
1920
+ `semantic.source` as `unavailable:file-cap` and emits an actionable warning
1921
+ with the cap plus both `.pi-lens.json#maxProjectFiles` and
1922
+ `PI_LENS_REVIEW_GRAPH_MAX_FILES` controls, keeping disabled data distinct from
1923
+ a genuinely empty/cold graph.
427
1924
 
428
1925
  - **Project scans release every scan-local fact after each file** (refs #886,
429
- #939) instead of retaining source content, imports, summaries, and other
430
- derived per-file facts until the scan ends. The live dispatch store remains
431
- untouched; only the scanner-owned store is cleared.
1926
+ #939) instead of retaining source content, imports, summaries, and other
1927
+ derived per-file facts until the scan ends. The live dispatch store remains
1928
+ untouched; only the scanner-owned store is cleared.
432
1929
 
433
1930
  - Repair eight non-compiling Java, C++, CSS and PHP tree-sitter rules (refs #884).
434
1931
 
435
1932
  - Repair four non-compiling Go, Rust, and Kotlin tree-sitter rules (refs #884).
436
1933
 
437
1934
  - **Project diagnostics now use one file-major scan pass** (refs #896) —
438
- tree-sitter rules, fact rules, and bundled ast-grep share each eligible
439
- file's content read while retaining their individual extension/size gates,
440
- diagnostic ordering, cancellation behavior, and latency telemetry. Full
441
- review-graph builds likewise hash the bytes already read for extraction
442
- instead of rereading every file after the graph is built.
1935
+ tree-sitter rules, fact rules, and bundled ast-grep share each eligible
1936
+ file's content read while retaining their individual extension/size gates,
1937
+ diagnostic ordering, cancellation behavior, and latency telemetry. Full
1938
+ review-graph builds likewise hash the bytes already read for extraction
1939
+ instead of rereading every file after the graph is built.
443
1940
 
444
1941
  - **A project scan runs its rule set in one tree walk, not one walk per rule**
445
- (refs #675) — `runQueriesOnFile` compiles a language's rules into a single
446
- multi-pattern query and maps matches back to the owning rule, keeping per-rule
447
- metavars, predicates, post-filters, caps and ordering. Measured at 3.3× on
448
- identical matches; the scan's tree-sitter phase went **14.4s → 3.6s** and a
449
- full `mode=full` scan of pi-lens **24.4s → 12.2s**.
1942
+ (refs #675) — `runQueriesOnFile` compiles a language's rules into a single
1943
+ multi-pattern query and maps matches back to the owning rule, keeping per-rule
1944
+ metavars, predicates, post-filters, caps and ordering. Measured at 3.3× on
1945
+ identical matches; the scan's tree-sitter phase went **14.4s → 3.6s** and a
1946
+ full `mode=full` scan of pi-lens **24.4s → 12.2s**.
450
1947
 
451
1948
  - **Project scans parse each file once** (refs #675) — the tree-sitter rules and
452
- the fact rules both tree-sitter-parse the same files, and running them as two
453
- whole-project sweeps meant the second one re-parsed every file the first had
454
- already parsed and evicted (357 of 357 first touches missed on capacity in a
455
- 500-file `mode=full` scan). They now share one file-major pass: **722 parses →
456
- 367, 892ms → 437ms of parse time, zero capacity misses**, same diagnostics in
457
- the same order. The 50-entry cache is unchanged and was never the problem —
458
- within a phase it already ran at a 96.8% hit rate, any capacity short of the
459
- full working set saves nothing, and one big enough to hold it would cost
460
- 150-300MB of resident WASM trees.
1949
+ the fact rules both tree-sitter-parse the same files, and running them as two
1950
+ whole-project sweeps meant the second one re-parsed every file the first had
1951
+ already parsed and evicted (357 of 357 first touches missed on capacity in a
1952
+ 500-file `mode=full` scan). They now share one file-major pass: **722 parses →
1953
+ 367, 892ms → 437ms of parse time, zero capacity misses**, same diagnostics in
1954
+ the same order. The 50-entry cache is unchanged and was never the problem —
1955
+ within a phase it already ran at a 96.8% hit rate, any capacity short of the
1956
+ full working set saves nothing, and one big enough to hold it would cost
1957
+ 150-300MB of resident WASM trees.
461
1958
 
462
1959
  - **Tree-sitter cache pressure is measurable** (refs #675): every production
463
- consumer, including partial-read expansion, uses the shared process-wide
464
- client, and scope-isolated cache counters plus parser timing are emitted for
465
- project scans and full review-graph builds.
1960
+ consumer, including partial-read expansion, uses the shared process-wide
1961
+ client, and scope-isolated cache counters plus parser timing are emitted for
1962
+ project scans and full review-graph builds.
466
1963
 
467
1964
  - **Dead incremental-parse cache API removed.** `TreeCache.incrementalUpdate`,
468
- `calculateEdit`, and `invalidate` had no callers; the header's "10-100×
469
- speedup on edits" was advertising a path nothing ever took.
1965
+ `calculateEdit`, and `invalidate` had no callers; the header's "10-100×
1966
+ speedup on edits" was advertising a path nothing ever took.
470
1967
 
471
1968
  - **Faster LSP teardown and tool probes** (refs #448) — `killProcessTree`
472
- resolves on the child's exit event instead of sleeping the full 1.5s
473
- SIGTERM→SIGKILL escalation window (saves ~1.5s per graceful client
474
- shutdown); tool availability checks skip `--version` spawns for binaries
475
- that aren't on disk and no longer re-verify a probe-cache-validated managed
476
- install on every analyze.
1969
+ resolves on the child's exit event instead of sleeping the full 1.5s
1970
+ SIGTERM→SIGKILL escalation window (saves ~1.5s per graceful client
1971
+ shutdown); tool availability checks skip `--version` spawns for binaries
1972
+ that aren't on disk and no longer re-verify a probe-cache-validated managed
1973
+ install on every analyze.
477
1974
 
478
1975
  ### Fixed
479
1976
 
480
1977
  - **Read-before-edit guard now survives a session resume (#1041)** — the
481
- guard's read-set was in-memory only, so a `pi --session <id>` resume (which
482
- resets the runtime to a fresh empty guard) falsely `zero_read`-blocked the
483
- first edit of any file read in the prior session. The read-set now rides the
484
- same #190 `PersistedSessionState` save/load path that widget diagnostics use:
485
- `ReadGuard.exportState()`/`importState()` persist and rehydrate `reads`,
486
- reconciling each read against current disk (line-hash verified) so a resume
487
- drops any read whose file changed or vanished — a rehydrated read never masks
488
- a real staleness. Forked sessions adopt the parent's read-set via the same
489
- in-memory hand-off as widget state. Backward-compatible: the field is optional
490
- and pre-#1041 persisted sessions load cleanly as "no prior reads".
1978
+ guard's read-set was in-memory only, so a `pi --session <id>` resume (which
1979
+ resets the runtime to a fresh empty guard) falsely `zero_read`-blocked the
1980
+ first edit of any file read in the prior session. The read-set now rides the
1981
+ same #190 `PersistedSessionState` save/load path that widget diagnostics use:
1982
+ `ReadGuard.exportState()`/`importState()` persist and rehydrate `reads`,
1983
+ reconciling each read against current disk (line-hash verified) so a resume
1984
+ drops any read whose file changed or vanished — a rehydrated read never masks
1985
+ a real staleness. Forked sessions adopt the parent's read-set via the same
1986
+ in-memory hand-off as widget state. Backward-compatible: the field is optional
1987
+ and pre-#1041 persisted sessions load cleanly as "no prior reads".
491
1988
 
492
1989
  - **`turn_summary_emit` quiet-window task no longer fails on a stale pi ctx (the #483 quiet window's most frequent live-dogfood error — 55×)** —
493
- the `agent_settled` quiet-window task `turn_summary_emit` reads the
494
- `lens-turn-summary` flag through `pi.getFlag()` (via `getLensFlag`). The task
495
- holder's `pi` is refreshed on every activation, but the quiet window is fired
496
- fire-and-forget from `agent_settled`, so an interim session
497
- replacement/reload (`ctx.newSession/fork/switchSession/reload` — common in the
498
- in-process subagent flow #473) invalidates that captured `pi`; the very next
499
- `pi.getFlag()` then throws the SDK's stale-ctx guard. That throw sat OUTSIDE
500
- the existing `sendMessage` try/catch, so it escaped the task and the scheduler
501
- logged `quiet_window: task "turn_summary_emit" failed: … stale after session
502
- replacement or reload` — 55 times across two weeks of dogfooding, the single
503
- most frequent error in `~/.pi-lens/sessionstart.log`, and it fired even with
504
- the feature OFF (the flag READ throws before the flag value is known). Fix:
505
- the task now recognises the stale-ctx throw (new `isStaleExtensionCtxError`
506
- message matcher) at both pi touch points (flag read + emit) and degrades to a
507
- logged no-op — the session that run's summary belonged to is gone, so there is
508
- nothing to emit into — while a genuine error still propagates to the scheduler
509
- (recorded `ok:false`). User impact was minimal (a lost summary for the exact
510
- settle that raced a session swap, plus log noise), not silent feature
511
- degradation: in steady state the task runs `ok:true`. Separately, the
512
- quiet-window runner now logs the failing task's **stack** (not just `${err}`'s
513
- message) so the next failure is diagnosable from the log alone; it remains
514
- strictly non-fatal (per-task try/catch, `runQuietWindow` never rethrows, and
515
- `agent_settled` invokes it fire-and-forget — a failing task can never break the
516
- turn). Regression test drives the registered task against a `pi` whose
517
- `getFlag` throws the stale-ctx error and asserts a no-op (no throw, no emit);
518
- it fails pre-fix.
1990
+ the `agent_settled` quiet-window task `turn_summary_emit` reads the
1991
+ `lens-turn-summary` flag through `pi.getFlag()` (via `getLensFlag`). The task
1992
+ holder's `pi` is refreshed on every activation, but the quiet window is fired
1993
+ fire-and-forget from `agent_settled`, so an interim session
1994
+ replacement/reload (`ctx.newSession/fork/switchSession/reload` — common in the
1995
+ in-process subagent flow #473) invalidates that captured `pi`; the very next
1996
+ `pi.getFlag()` then throws the SDK's stale-ctx guard. That throw sat OUTSIDE
1997
+ the existing `sendMessage` try/catch, so it escaped the task and the scheduler
1998
+ logged `quiet_window: task "turn_summary_emit" failed: … stale after session
1999
+ replacement or reload` — 55 times across two weeks of dogfooding, the single
2000
+ most frequent error in `~/.pi-lens/sessionstart.log`, and it fired even with
2001
+ the feature OFF (the flag READ throws before the flag value is known). Fix:
2002
+ the task now recognises the stale-ctx throw (new `isStaleExtensionCtxError`
2003
+ message matcher) at both pi touch points (flag read + emit) and degrades to a
2004
+ logged no-op — the session that run's summary belonged to is gone, so there is
2005
+ nothing to emit into — while a genuine error still propagates to the scheduler
2006
+ (recorded `ok:false`). User impact was minimal (a lost summary for the exact
2007
+ settle that raced a session swap, plus log noise), not silent feature
2008
+ degradation: in steady state the task runs `ok:true`. Separately, the
2009
+ quiet-window runner now logs the failing task's **stack** (not just `${err}`'s
2010
+ message) so the next failure is diagnosable from the log alone; it remains
2011
+ strictly non-fatal (per-task try/catch, `runQuietWindow` never rethrows, and
2012
+ `agent_settled` invokes it fire-and-forget — a failing task can never break the
2013
+ turn). Regression test drives the registered task against a `pi` whose
2014
+ `getFlag` throws the stale-ctx error and asserts a no-op (no throw, no emit);
2015
+ it fails pre-fix.
519
2016
 
520
2017
  - **Word index no longer serves stale postings after a case/separator-divergent edit (refs #1025, closes #1025 item #2)** —
521
- the word index's `docLengths`/`forward`/`fileMtimes` maps are keyed on file
522
- paths, but the full/incremental BUILD keyed on the on-disk casing the
523
- `readdirSync` walk reports while the per-edit UPDATE seams
524
- (`clients/dispatch/integration.ts`, `clients/mcp/analyze.ts`) key via
525
- `path.resolve()` on the raw tool-input path. When those forms differed only by
526
- separator (`\` vs `/`, always) or case (on a case-insensitive FS), the plain-`Map`
527
- keying made `forward.has(doc.path)` MISS, so the stale build-form entry was never
528
- removed — a duplicate doc entry with stale postings served until a full rebuild,
529
- silently degrading `symbol_search` relevance (the path-key divergence class,
530
- #1020/#210). Fix: a new typed **`PathKeyedMap<V>`** primitive
531
- (`clients/path-keyed-map.ts`) folds every key through a caller-supplied
532
- normalizer INTERNALLY, so keying a raw path is structurally impossible; the word
533
- index now routes all three path maps through it using the shared, exported
534
- `wordIndexKey` (= `normalizeEphemeralMapKey` — cheap slash-fold + win32-lowercase,
535
- NO `realpathSync`, so the hot BM25 path adds no filesystem I/O), and the
536
- token-keyed `postings` cleanup compares `WordHit.file` through the same
537
- normalizer, and the incremental-refresh set-difference
538
- (`refreshWordIndexIncrementally`) now computes its `current`/`oldSet` keys in
539
- that same normalized space (previously raw-keyed — a file whose stored display
540
- path became the edit form would have been double-counted as churn and
541
- dropped-then-re-added, defeating the incremental path and opening a
542
- drop-before-readd regression window). `PathKeyedMap` stores each value's
543
- ORIGINAL display path, so `symbol_search`/rendering surfaces still see the
544
- on-disk form, not the folded key. Build-form, edit-form, and refresh keys now
545
- converge on ONE entry regardless of casing/separator. The runtime-coordinator suspect maps (#1025 item #4) are left
546
- for a follow-up (unproven trigger, and their correct normalizer is the
547
- realpath-based `normalizeMapKey` matching the file's own `_fileSeq` convention —
548
- a larger, higher-risk change across the concurrent-session drain logic).
2018
+ the word index's `docLengths`/`forward`/`fileMtimes` maps are keyed on file
2019
+ paths, but the full/incremental BUILD keyed on the on-disk casing the
2020
+ `readdirSync` walk reports while the per-edit UPDATE seams
2021
+ (`clients/dispatch/integration.ts`, `clients/mcp/analyze.ts`) key via
2022
+ `path.resolve()` on the raw tool-input path. When those forms differed only by
2023
+ separator (`\` vs `/`, always) or case (on a case-insensitive FS), the plain-`Map`
2024
+ keying made `forward.has(doc.path)` MISS, so the stale build-form entry was never
2025
+ removed — a duplicate doc entry with stale postings served until a full rebuild,
2026
+ silently degrading `symbol_search` relevance (the path-key divergence class,
2027
+ #1020/#210). Fix: a new typed **`PathKeyedMap<V>`** primitive
2028
+ (`clients/path-keyed-map.ts`) folds every key through a caller-supplied
2029
+ normalizer INTERNALLY, so keying a raw path is structurally impossible; the word
2030
+ index now routes all three path maps through it using the shared, exported
2031
+ `wordIndexKey` (= `normalizeEphemeralMapKey` — cheap slash-fold + win32-lowercase,
2032
+ NO `realpathSync`, so the hot BM25 path adds no filesystem I/O), and the
2033
+ token-keyed `postings` cleanup compares `WordHit.file` through the same
2034
+ normalizer, and the incremental-refresh set-difference
2035
+ (`refreshWordIndexIncrementally`) now computes its `current`/`oldSet` keys in
2036
+ that same normalized space (previously raw-keyed — a file whose stored display
2037
+ path became the edit form would have been double-counted as churn and
2038
+ dropped-then-re-added, defeating the incremental path and opening a
2039
+ drop-before-readd regression window). `PathKeyedMap` stores each value's
2040
+ ORIGINAL display path, so `symbol_search`/rendering surfaces still see the
2041
+ on-disk form, not the folded key. Build-form, edit-form, and refresh keys now
2042
+ converge on ONE entry regardless of casing/separator. The runtime-coordinator suspect maps (#1025 item #4) are left
2043
+ for a follow-up (unproven trigger, and their correct normalizer is the
2044
+ realpath-based `normalizeMapKey` matching the file's own `_fileSeq` convention —
2045
+ a larger, higher-risk change across the concurrent-session drain logic).
549
2046
 
550
2047
  - **Cascade honesty: degraded/uncomputed impact no longer renders as clean (refs #1023, closes #1023)** —
551
- the cascade impact subsystem previously emitted an all-clear that was
552
- indistinguishable from "genuinely nothing impacted" whenever it could NOT
553
- compute impact: the review graph was size-skipped (`too_many_files` over
554
- `PI_LENS_REVIEW_GRAPH_MAX_FILES`) or root-skipped (`unsafe_root`), the changed
555
- file had no graph node, or the deferred compute threw — a silent under-report
556
- exactly where downstream breakage is most likely (large/over-cap monorepos)
557
- (#533). Impact results now carry a first-class `indeterminate` marker
558
- (`clients/review-graph/types.ts`) set ONLY on a degraded/cold/errored/
559
- missing-node compute; the already-known degraded state is threaded from
560
- `getLastGraphBuildInfo().mode`/`skipReason` at the compute call site (no
561
- re-derivation), a `missing_node` marker comes straight from
562
- `computeImpactCascade`, and a thrown compute is tagged in the pipeline catch. A
563
- new `indeterminate` `CascadeSkipReason` flows to the EXISTING turn-end seam
564
- (`clients/runtime-turn.ts`), which renders a short honest note in the ADVISORY
565
- tier ("Cascade could not compute downstream impact … a clean cascade result
566
- does not cover them") instead of silence. The advisory tier (not the blocker
567
- tier) is deliberate: an over-cap monorepo skips the graph on every edit, so a
568
- blocker would fire hard and never clear turn state every turn (over-escalation,
569
- the mirror of the silent-all-clear bug). A HEALTHY graph with a genuinely empty
570
- dependent set stays silent (keyed strictly off the degraded mode/marker, never
571
- off `neighbors.length === 0` alone — no crying wolf). Companion: the Tier-3 quiet-window reconcile
572
- (`clients/lsp/cascade-tier.ts`) now RE-INJECTS a `resolved-found` cold-neighbor
573
- error through the same neighbor→turn-end formatting via an `onResolvedFound`
574
- callback wired in `index.ts`, instead of leaving it logs-only. The
575
- neighbor-touch/freshness pipeline (neighborTouchCache, recentlyCleanNeighborCache,
576
- the 40-neighbor budget, tier-aware touch model, deferred cascade flow) is
577
- untouched.
2048
+ the cascade impact subsystem previously emitted an all-clear that was
2049
+ indistinguishable from "genuinely nothing impacted" whenever it could NOT
2050
+ compute impact: the review graph was size-skipped (`too_many_files` over
2051
+ `PI_LENS_REVIEW_GRAPH_MAX_FILES`) or root-skipped (`unsafe_root`), the changed
2052
+ file had no graph node, or the deferred compute threw — a silent under-report
2053
+ exactly where downstream breakage is most likely (large/over-cap monorepos)
2054
+ (#533). Impact results now carry a first-class `indeterminate` marker
2055
+ (`clients/review-graph/types.ts`) set ONLY on a degraded/cold/errored/
2056
+ missing-node compute; the already-known degraded state is threaded from
2057
+ `getLastGraphBuildInfo().mode`/`skipReason` at the compute call site (no
2058
+ re-derivation), a `missing_node` marker comes straight from
2059
+ `computeImpactCascade`, and a thrown compute is tagged in the pipeline catch. A
2060
+ new `indeterminate` `CascadeSkipReason` flows to the EXISTING turn-end seam
2061
+ (`clients/runtime-turn.ts`), which renders a short honest note in the ADVISORY
2062
+ tier ("Cascade could not compute downstream impact … a clean cascade result
2063
+ does not cover them") instead of silence. The advisory tier (not the blocker
2064
+ tier) is deliberate: an over-cap monorepo skips the graph on every edit, so a
2065
+ blocker would fire hard and never clear turn state every turn (over-escalation,
2066
+ the mirror of the silent-all-clear bug). A HEALTHY graph with a genuinely empty
2067
+ dependent set stays silent (keyed strictly off the degraded mode/marker, never
2068
+ off `neighbors.length === 0` alone — no crying wolf). Companion: the Tier-3 quiet-window reconcile
2069
+ (`clients/lsp/cascade-tier.ts`) now RE-INJECTS a `resolved-found` cold-neighbor
2070
+ error through the same neighbor→turn-end formatting via an `onResolvedFound`
2071
+ callback wired in `index.ts`, instead of leaving it logs-only. The
2072
+ neighbor-touch/freshness pipeline (neighborTouchCache, recentlyCleanNeighborCache,
2073
+ the 40-neighbor budget, tier-aware touch model, deferred cascade flow) is
2074
+ untouched.
578
2075
 
579
2076
  - **Disposition marks under a nested monorepo language root are no longer silently dropped (refs #1030, closes #1030; pairs with #1024)** —
580
- a `false-positive`/`flagged`/`defer` mark recorded via `lens_diagnostic_mark`
581
- was invisible to the per-edit dispatch filter for every file living under a
582
- nested language-root marker (`packages/app/tsconfig.json`, a nested
583
- `package.json`/`pyproject.toml`/`go.mod`/etc.) in a monorepo. The mark tool
584
- writes dispositions keyed on the **project root** (`runtime.projectRoot`), but
585
- `dispatcher.ts`'s `applyDispositions` read keyed on `ctx.cwd`, which
586
- `createDispatchContext` resolves to the nearest **language root** via
587
- `resolveLanguageRootForFile` (a dispatch-internal tool/config-resolution
588
- detail). Because both the anchor AND the persisted store location
589
- (`getProjectDataDir`, keyed on `cwd`) derive from that base, write and read
590
- computed different anchors AND opened entirely different
591
- `diagnostic-dispositions.json` files — a total, deterministic failure (not
592
- probabilistic) for every file under a nested marker. The tool returned success
593
- while dispatch never saw the mark (a #533 honesty failure); `suppress` survived
594
- only via its independent inline `pi-lens-ignore` comment. Fixed by reading the
595
- disposition layer from the project root (`ctx.projectRoot`, already computed in
596
- `createDispatchContext`), matching the write side — anchor base and store base
597
- now agree on both sides. `resolveLanguageRootForFile` is untouched and still
598
- drives actual dispatch/tool-root resolution. Orthogonal to #1024 (which fixes
599
- the path *form* of the anchor); the two compose for a monorepo-stable anchor.
600
- No new dependencies.
2077
+ a `false-positive`/`flagged`/`defer` mark recorded via `lens_diagnostic_mark`
2078
+ was invisible to the per-edit dispatch filter for every file living under a
2079
+ nested language-root marker (`packages/app/tsconfig.json`, a nested
2080
+ `package.json`/`pyproject.toml`/`go.mod`/etc.) in a monorepo. The mark tool
2081
+ writes dispositions keyed on the **project root** (`runtime.projectRoot`), but
2082
+ `dispatcher.ts`'s `applyDispositions` read keyed on `ctx.cwd`, which
2083
+ `createDispatchContext` resolves to the nearest **language root** via
2084
+ `resolveLanguageRootForFile` (a dispatch-internal tool/config-resolution
2085
+ detail). Because both the anchor AND the persisted store location
2086
+ (`getProjectDataDir`, keyed on `cwd`) derive from that base, write and read
2087
+ computed different anchors AND opened entirely different
2088
+ `diagnostic-dispositions.json` files — a total, deterministic failure (not
2089
+ probabilistic) for every file under a nested marker. The tool returned success
2090
+ while dispatch never saw the mark (a #533 honesty failure); `suppress` survived
2091
+ only via its independent inline `pi-lens-ignore` comment. Fixed by reading the
2092
+ disposition layer from the project root (`ctx.projectRoot`, already computed in
2093
+ `createDispatchContext`), matching the write side — anchor base and store base
2094
+ now agree on both sides. `resolveLanguageRootForFile` is untouched and still
2095
+ drives actual dispatch/tool-root resolution. Orthogonal to #1024 (which fixes
2096
+ the path *form* of the anchor); the two compose for a monorepo-stable anchor.
2097
+ No new dependencies.
601
2098
 
602
2099
  - **Disposition anchor path-form stability (refs #1024, closes #1024)** — the
603
- `dd:`/`ddw:` disposition anchors (clients/diagnostic-dispositions.ts) are now
604
- derived from a single canonical path form, closing a #533-adjacent
605
- dropped-signal bug (sibling of #210/#1020). `relativeFile` — the chokepoint
606
- both `computeStrictAnchor` and `computeWeakAnchor` route through — computed
607
- `path.relative` on whichever raw path form the caller happened to hold: the
608
- mark tool (tools/lens-diagnostic-mark.ts) passes a RAW `cwd` /
609
- `path.resolve(cwd, arg)`, while the dispatch read side
610
- (clients/dispatch/dispatcher.ts `createDispatchContext`) passes
611
- `normalizeMapKey`-canonicalized (realpath'd) cwd/filePath. When the two forms
612
- diverged (Windows drive/segment case, symlink/realpath), the agent's own
613
- `false-positive`/`flagged` mark anchored under one id and the later
614
- `applyDispositions` lookup under another, so the mark was invisible and the
615
- "resolved" diagnostic kept re-firing / re-blocking every turn. Fix routes
616
- BOTH `cwd` and `filePath` through `normalizeMapKey` inside `relativeFile`
617
- (the same normalizer the read side already relies on — the #210 "every guard
618
- map keys through `normalizeFilePath`" invariant), so write and read derive
619
- identical anchors regardless of the form the caller passed. Anchor semantics
620
- are unchanged: strict (`dd:`, line-hash) vs weak (`ddw:`) split, the prefixes,
621
- and the `..`-escape fallback all behave exactly as before — only the path
622
- component is now form-stable. Existing on-disk marks written under the old raw
623
- form re-anchor to the canonical id; a stale orphan is re-derivable and simply
624
- reappears once for the agent to re-mark (identical to today's pre-fix
625
- behavior), so no read-time legacy fallback is needed.
2100
+ `dd:`/`ddw:` disposition anchors (clients/diagnostic-dispositions.ts) are now
2101
+ derived from a single canonical path form, closing a #533-adjacent
2102
+ dropped-signal bug (sibling of #210/#1020). `relativeFile` — the chokepoint
2103
+ both `computeStrictAnchor` and `computeWeakAnchor` route through — computed
2104
+ `path.relative` on whichever raw path form the caller happened to hold: the
2105
+ mark tool (tools/lens-diagnostic-mark.ts) passes a RAW `cwd` /
2106
+ `path.resolve(cwd, arg)`, while the dispatch read side
2107
+ (clients/dispatch/dispatcher.ts `createDispatchContext`) passes
2108
+ `normalizeMapKey`-canonicalized (realpath'd) cwd/filePath. When the two forms
2109
+ diverged (Windows drive/segment case, symlink/realpath), the agent's own
2110
+ `false-positive`/`flagged` mark anchored under one id and the later
2111
+ `applyDispositions` lookup under another, so the mark was invisible and the
2112
+ "resolved" diagnostic kept re-firing / re-blocking every turn. Fix routes
2113
+ BOTH `cwd` and `filePath` through `normalizeMapKey` inside `relativeFile`
2114
+ (the same normalizer the read side already relies on — the #210 "every guard
2115
+ map keys through `normalizeFilePath`" invariant), so write and read derive
2116
+ identical anchors regardless of the form the caller passed. Anchor semantics
2117
+ are unchanged: strict (`dd:`, line-hash) vs weak (`ddw:`) split, the prefixes,
2118
+ and the `..`-escape fallback all behave exactly as before — only the path
2119
+ component is now form-stable. Existing on-disk marks written under the old raw
2120
+ form re-anchor to the canonical id; a stale orphan is re-derivable and simply
2121
+ reappears once for the agent to re-mark (identical to today's pre-fix
2122
+ behavior), so no read-time legacy fallback is needed.
626
2123
 
627
2124
  - **`lens_diagnostics mode=all` no longer replays a resolved blocker (refs #1020, closes #1020)** —
628
- the widget-state `files` map was keyed by the raw, non-normalized path string,
629
- so the SAME file could land under two different key forms in one session:
630
- the forward-slash form (`C:/…/x.ts`) the LSP client + cascade fold produce via
631
- `normalizeFilePath`, and the backslash form (`C:\…\x.ts`) that `mode=full`'s
632
- clean reconcile (`result.filePath`) and `path.resolve`/event inputs produce on
633
- Windows. A stale blocking entry and the fresh clean entry then coexisted as two
634
- separate map entries. `mode=full` rendered clean because its merge re-keys every
635
- summary through `path.resolve`, but `mode=all`'s `formatAllMode` reads
636
- `getFileDiagnosticSummaries()` verbatim with no dedup, so it saw both and
637
- rendered the stale entry's `blocking:1` as a 🔴 — a resolved state that replayed
638
- as still-broken on every `mode=all` (worst on Windows + resumed sessions), and a
639
- #533 honesty failure since the tool prompt tells agents to use `mode=all` to
640
- verify no blockers remain. Fixed at source: every write/read seam on the `files`
641
- map (and the `diagnosticsWriteGuard`) now folds its key through one normalizer,
642
- `normalizeEphemeralMapKey` (slash-fold + win32-lowercase, no filesystem I/O —
643
- chosen over the `realpathSync`-backed `normalizeMapKey` because this is a hot
644
- write path), and `importWidgetState` folds persisted keys on rehydrate so a
645
- `/`-key snapshot and a fresh `\`-key write collapse across a resumed session.
646
- The human-readable display path on each record is preserved verbatim (rendering
647
- and path-relative math unchanged). No new dependencies.
2125
+ the widget-state `files` map was keyed by the raw, non-normalized path string,
2126
+ so the SAME file could land under two different key forms in one session:
2127
+ the forward-slash form (`C:/…/x.ts`) the LSP client + cascade fold produce via
2128
+ `normalizeFilePath`, and the backslash form (`C:\…\x.ts`) that `mode=full`'s
2129
+ clean reconcile (`result.filePath`) and `path.resolve`/event inputs produce on
2130
+ Windows. A stale blocking entry and the fresh clean entry then coexisted as two
2131
+ separate map entries. `mode=full` rendered clean because its merge re-keys every
2132
+ summary through `path.resolve`, but `mode=all`'s `formatAllMode` reads
2133
+ `getFileDiagnosticSummaries()` verbatim with no dedup, so it saw both and
2134
+ rendered the stale entry's `blocking:1` as a 🔴 — a resolved state that replayed
2135
+ as still-broken on every `mode=all` (worst on Windows + resumed sessions), and a
2136
+ #533 honesty failure since the tool prompt tells agents to use `mode=all` to
2137
+ verify no blockers remain. Fixed at source: every write/read seam on the `files`
2138
+ map (and the `diagnosticsWriteGuard`) now folds its key through one normalizer,
2139
+ `normalizeEphemeralMapKey` (slash-fold + win32-lowercase, no filesystem I/O —
2140
+ chosen over the `realpathSync`-backed `normalizeMapKey` because this is a hot
2141
+ write path), and `importWidgetState` folds persisted keys on rehydrate so a
2142
+ `/`-key snapshot and a fresh `\`-key write collapse across a resumed session.
2143
+ The human-readable display path on each record is preserved verbatim (rendering
2144
+ and path-relative math unchanged). No new dependencies.
648
2145
 
649
2146
  - **Cache-friendly ephemeral context injection (refs #1016, closes #1016)** — the
650
- `context` event handler in `index.ts` now splices pi-lens's ephemeral turn-end
651
- findings in **immediately before the final message** instead of prepending them
652
- at message index 0. Prepending flipped `messages[0]` every turn, which
653
- invalidated the entire prompt-cache prefix on every prefix-caching provider
654
- (Anthropic, Bedrock, and OpenAI — all key the cache on the exact token prefix).
655
- Inserting before the last message keeps `messages[0]` (the real first user turn)
656
- byte-stable so the prior conversation stays cached, while still keeping the real
657
- user prompt as the trailing message — preserving the trailing-`user` cache
658
- breakpoint and the historical `fe0ed5da` guarantee that input is never empty (the
659
- existing transcript is always preserved, never dropped; an empty transcript falls
660
- back to the prior prepend-only behavior). Because the `context` event fires
661
- before **every** provider call (not just at turn boundaries), a trailing-role
662
- guard (`isPlainUserPrompt`) only splices before the last message when it is a
663
- plain user prompt; when the tail is a mid-loop `tool_result` (or assistant/tool
664
- message) the findings are **appended** after the whole transcript instead — a
665
- pure append that both preserves the `tool_use`↔`tool_result` adjacency all three
666
- providers require (splicing between them is an HTTP 400) and keeps the entire
667
- prior transcript as an untouched cache prefix. Changes shipped `dist` behavior
668
- (the handler lives in the bundled dist path).
669
-
670
- - **Fixed: `ts-ssrf` no longer flags fixed/const endpoint URLs built with
671
- `new URL(...)` as SSRF sinks, while still catching tainted URLs** (closes
672
- #1000, refs #533, #963) — a project-wide pi-free scan flagged fixed outbound
673
- OAuth/profile endpoints as SSRF: `fetch(authUrl.toString())` where
674
- `authUrl = new URL("auth/authorize", \`${BASE_URL_CLINE}/\`)` with a fixed
675
- (module-const or imported) base. The `ts_ssrf_sink` post-filter already
676
- exempted a `fetch()` of a file-local `const` literal string (#963) but had no
677
- awareness of the `new URL(literalPath, fixedBase)` shape, so
678
- `url.toString()`/`url.href` fell through to the broad taint heuristic. Extended
679
- the post-filter to also exempt `fetch(u.toString())`/`fetch(u.href)` when `u`
680
- resolves (same file) to a clean `const u = new URL(<literalPath>, <fixedBase>)`
681
- — literal path plus a base that is a literal, a file-local literal `const`, or
682
- an import binding (the `URL` ctor may be aliased, e.g. `NodeURL`). Query params
683
- added later via `searchParams.set(...)` don't control origin/path and never
684
- taint the destination. Still fires (regression fixtures added) when the base
685
- or path comes from a function parameter, `process.env`/config (including a
686
- const initialized from `process.env`), request data, or a parsed
687
- redirect/`location` value — a missed SSRF stays far worse than one FP.
688
- Adversarial review then closed two bypasses in that exemption: (1) a
689
- post-construction origin mutation (`u.host = req.x` / `u.href = …` /
690
- `u["host"] = …`, incl. augmented assignments) now re-taints the receiver and
691
- fails closed — only query-only writes (`u.search`/`u.searchParams`) stay
692
- exempt; (2) a request-tainted function *parameter* base is no longer exempted
693
- just because an unrelated same-named module-level `const` literal exists
694
- (scope-aware shadow check; imported bases stay trusted).
695
-
696
- - **Fixed: `no-javascript-url`/`no-javascript-url-js` no longer flag defensive
697
- `javascript:`-URL filters** (refs #533) — a dogfood run flagged code that was
698
- *rejecting* `javascript:` links (e.g. `url.startsWith("javascript:")` inside a
699
- guard) as if it were introducing one. The rule matched the `javascript:`
700
- string literal alone with no way to tell "used as a sink" from "used to
701
- detect/block it". Narrowed both rules to also exclude the literal when it's
702
- the needle in `.startsWith`/`.endsWith`/`.includes`/`.indexOf`/`.search`/
703
- `.match`, or one side of an `===`/`!==`/`==`/`!=` comparison — deliberately
704
- NOT `.replace`/`.replaceAll`, since the literal there could be the malicious
705
- replacement argument rather than the defensive search argument, and
706
- excluding the whole call would hide that true positive. Both new exclusions
707
- deliberately avoid `stopBy: end` (unlike the pre-existing test-literal
708
- exclusion, which needs it) so the literal must be a DIRECT argument/operand
709
- of the defensive call/comparison, not merely somewhere in its subtree — an
710
- adversarial review caught that `stopBy: end` let a real sink smuggled
711
- inside the call/comparison (e.g. `candidates.includes(link.href =
712
- "javascript:...")`, a comma-operator sink, or `(a.href = "javascript:...")
713
- === expected`) go unflagged. Added valid fixtures reproducing the filter FP
714
- and invalid fixtures for the replace-based sink and all three smuggled-sink
715
- bypasses, confirmed against the real ast-grep 0.45.0 binary.
716
-
717
- - **Contained spawn/callback failures that could crash the host (pidusage bug
718
- class)** (refs #533) — a best-effort telemetry sampler recently killed a live
719
- pi host with `Error: spawn UNKNOWN` (uncaughtException) because a bundled
720
- dep's `child_process.spawn(...)` threw SYNCHRONOUSLY with no try/catch, from
721
- a detached context the caller's `try/await/catch` could not contain. Audited
722
- every spawn/detached-callback site in `clients/` and hardened the at-risk
723
- best-effort/background ones to the repo's existing guard shape (try/catch
724
- around the synchronous `spawn` + resolve gracefully, matching the async
725
- `'error'`-listener path): `safeSpawnAsync`'s core spawn (the shared wrapper
726
- dozens of best-effort callers rely on to NEVER reject), its Windows
727
- `killPidTreeSync` (runs from `process` exit/signal handlers, where a throw
728
- becomes an uncaughtException during shutdown; the POSIX branch was already
729
- guarded), `SgRunner.exec`'s three ast-grep spawn branches, all six installer
730
- tool-discovery/verify probes, the PSScriptAnalyzer runner's `spawnPs`, and
731
- the MCP `analyzeFileFresh` worker fork. A synchronous spawn throw now
732
- resolves the operation's normal failure value instead of escaping as an
733
- unhandledRejection/uncaughtException. Behavior-preserving otherwise.
734
-
735
- - **Fixed: opengrep security findings now surface project-wide in
736
- `lens_diagnostics mode=full`** (refs #585, #584, #533) — opengrep's
737
- whole-tree CLI scan ran at session-start and cached its findings, and the
738
- cache-only extractor registry (`project-diagnostics/extractors.ts`) even
739
- registered an `"opengrep"` row — but #585 replaced that registry in
740
- production with `fetchFreshProjectDiagnostics`
741
- (`project-diagnostics/fresh-fetch.ts`), whose `ANALYZER_IDS` list omitted
742
- opengrep. Net: opengrep scanned + cached, yet nothing production read it
743
- back, so semgrep-grade security findings (ERROR→blocking, CWE-tagged) never
744
- reached the agent for unedited/project-wide files — a scan-and-orphan
745
- honesty gap (#533). `fetchFreshProjectDiagnostics` now runs opengrep the
746
- same way it runs gitleaks/trivy: an availability probe (opengrep is
747
- structurally always-on, so no static project-type gate), then a fresh scan
748
- that JOINS the session-start scan of the same root via
749
- `SecurityScanClient.dedupeScan` rather than double-spawning a heavy scan,
750
- with the result written back to cache and adapted to project diagnostics.
751
- The per-edit aux-LSP push path (`clientScope: with-auxiliary`,
752
- `AUXILIARY_LSP_PROFILES`) was verified already-working and is untouched;
753
- opengrep stays excluded from the push-only LSP workspace sweep (#584). The
754
- now-dead cache-only `extractCachedProjectDiagnostics` reader — the parallel
755
- registry that shadowed `ANALYZER_IDS` and let opengrep silently diverge — was
756
- removed so a single source of truth (#883) remains.
757
-
758
- - **Fixed: test-runner findings now surface project-wide in `lens_diagnostics
759
- mode=full`** (closes #1004, refs #585, #533) — the same `ANALYZER_IDS`
760
- omission #585/#1003 fixed for opengrep also orphaned test-runner: the
761
- per-edit turn_end test fire (`runtime-turn.ts`) caches failures under
762
- `"test-runner-findings"`, but `fetchFreshProjectDiagnostics`
763
- (`project-diagnostics/fresh-fetch.ts`) never read that cache back, so test
764
- failures never reached the agent for unedited/project-wide `mode=full`
765
- calls. Unlike opengrep/gitleaks/trivy, this is wired as a CACHE-READ (not a
766
- fresh scan): test-runner has no "whole project" run to trigger — turn_end
767
- only ever runs the targeted, cascade-aware test files touched by that
768
- turn's edits — so `fetchFreshProjectDiagnostics` now peeks at the same
769
- `"test-runner-findings"` cache key (never re-running the suite, never
770
- writing back) and adapts it via the existing
771
- `testRunnerFindingsToProjectDiagnostics`. Added a coverage guardrail
772
- (`tests/clients/project-diagnostics/analyzer-coverage.test.ts`) that greps
773
- the real session-start (`runHeavyweightTask`) and EVERY turn_end
774
- `cacheManager.writeCache` call site in `runtime-turn.ts` (excluding a
775
- short, explicit bookkeeping exclude-list — `errorDebt`/`turn-end-findings`/
776
- `turn-end-findings-last` — that isn't analyzer-shaped) and asserts every
777
- remaining id is a member of `ANALYZER_IDS`, so this whole #585 bug class
778
- can't silently regress again for a future analyzer added to EITHER writer.
779
- Adversarial review also caught two honesty/fidelity gaps in the initial
780
- fix: (1) test-runner is cache-read, edit-scoped (the targeted test files
781
- touched by the most recent turn's edits), unlike every other analyzer's
782
- fresh whole-project scan — `mode=full` now emits an explicit "coverage is
783
- edit-scoped, NOT a full-project run" caveat whenever test-runner
784
- contributed findings (not only when cold), and a `stale` cached result
785
- (the turn advanced before the test run finished) is prefixed
786
- `[stale — from a prior turn]` in its diagnostic message, matching the
787
- one-shot turn-context message's own stale wording; (2) `TestFailure.location`
788
- (`"file.ts:42"`, set by the vitest/jest JSON parser in
789
- `test-runner-client.ts`) never reached `ProjectDiagnostic.line`, so every
790
- test-runner finding rendered as `L?:` — `runner-adapters/runner-findings.ts`
791
- now parses a numeric trailing `:line[:col]` out of `location` (left alone
792
- for pytest/mix's non-numeric `location` strings, which carry a test name or
793
- module, not a line).
794
-
795
- - **Guarded Windows CPU/RSS resource sampling so a best-effort sampler can no
796
- longer crash the pi host** (refs #620, #533) — `clients/resource-sampler.ts`
797
- sampled CPU%/RSS via `pidusage`, whose Windows path shells out to `gwmi`
798
- through an internal `spawn(..., { shell: "powershell.exe" })` that has no
799
- try/catch and runs from inside a ChildProcess `close` callback. Under real
800
- Windows handle/commit pressure that spawn can throw `spawn UNKNOWN`
801
- (errno -4094) **synchronously in that detached callback**, which the call
802
- site's `try { await pidusage() } catch {}` cannot catch → `uncaughtException`
803
- → the whole host process dies (observed live). pidusage 4.0.1 offers no
804
- option to avoid the gwmi path. The sampler now runs its OWN fully guarded
805
- `Get-CimInstance Win32_Process` query on Windows (never calling `pidusage`
806
- there), mirroring the existing `findDescendantPidsWindows` guard: a
807
- synchronous spawn throw, a child `error` event, and a non-zero/garbage exit
808
- all resolve to a partial/empty map — the sampler can now only ever lose a
809
- data point, never throw into the heartbeat/spawn path. RSS comes from
810
- `WorkingSetSize`; CPU% is preserved via the same KernelModeTime/UserModeTime
811
- delta-over-elapsed-wall-time computation gwmi uses (a small per-pid history
812
- tracks the prior cumulative CPU time). Linux/macOS keep using `pidusage`
813
- (procfs/`ps` — not the crash vector) unchanged. A pid absent from the
814
- returned map still means "unsampled this tick", never zero.
815
-
816
- - **Stabilized the two remaining flaky/environment-sensitive tests tracked in
817
- #902.** LSP workspace-diagnostics sweep flush-count assertions
818
- (`workspace-diagnostics-sweep-batch-open.test.ts`,
819
- `workspace-diagnostics-sweep-preopen-chunk.test.ts`) pinned an exact flush
820
- count, but the pre-open pass's real `fs.promises.readFile` genuinely races
821
- the real 100ms `WatchedFilesQueue` debounce timer — under full-suite
822
- scheduler contention that can legitimately fragment or merge a chunk's
823
- flush by one. Replaced the exact-count assertions with invariant checks
824
- (coalescing happened; nowhere near one flush per file; no lost/duplicated
825
- URIs) that tolerate that jitter without losing the #608/#621 regression
826
- coverage — a test-robustness fix, not a product bug. Separately, two
827
- ast-grep dispatch test harnesses
828
- (`ast-grep-rule-tests.test.ts`, `ast-grep-catalog-rules.test.ts`) shelled
829
- out to the real `ast-grep` CLI via a raw `execFileSync(..., { shell: true
830
- })` per call — an uncached cmd.exe wrapper on Windows whose own exit code
831
- can mask the real child's and which can intermittently fail to spawn at
832
- all under the process-creation pressure of a full parallel test run.
833
- Switched both to the already-hardened `safeSpawn` (`clients/safe-spawn.ts`,
834
- #817) — cached PATH+PATHEXT resolution, direct `.exe`/`.com` spawn with no
835
- shell involved — the same deterministic resolution production dispatch
836
- already relies on. The third tracked item
837
- (`startup-overhead.test.ts`'s "quick mode self-reports within 100ms")
838
- was already reworked to a generous fixed budget + `retry: 2` in an earlier
839
- pass; confirmed still adequate, no further change needed.
2147
+ `context` event handler in `index.ts` now splices pi-lens's ephemeral turn-end
2148
+ findings in **immediately before the final message** instead of prepending them
2149
+ at message index 0. Prepending flipped `messages[0]` every turn, which
2150
+ invalidated the entire prompt-cache prefix on every prefix-caching provider
2151
+ (Anthropic, Bedrock, and OpenAI — all key the cache on the exact token prefix).
2152
+ Inserting before the last message keeps `messages[0]` (the real first user turn)
2153
+ byte-stable so the prior conversation stays cached, while still keeping the real
2154
+ user prompt as the trailing message — preserving the trailing-`user` cache
2155
+ breakpoint and the historical `fe0ed5da` guarantee that input is never empty (the
2156
+ existing transcript is always preserved, never dropped; an empty transcript falls
2157
+ back to the prior prepend-only behavior). Because the `context` event fires
2158
+ before **every** provider call (not just at turn boundaries), a trailing-role
2159
+ guard (`isPlainUserPrompt`) only splices before the last message when it is a
2160
+ plain user prompt; when the tail is a mid-loop `tool_result` (or assistant/tool
2161
+ message) the findings are **appended** after the whole transcript instead — a
2162
+ pure append that both preserves the `tool_use`↔`tool_result` adjacency all three
2163
+ providers require (splicing between them is an HTTP 400) and keeps the entire
2164
+ prior transcript as an untouched cache prefix. Changes shipped `dist` behavior
2165
+ (the handler lives in the bundled dist path).
2166
+
2167
+ - **Fixed: `ts-ssrf` no longer flags fixed/const endpoint URLs built with `new URL(...)` as SSRF sinks, while still catching tainted URLs (closes #1000, refs #533, #963)** — a project-wide pi-free scan flagged fixed outbound
2168
+ OAuth/profile endpoints as SSRF: `fetch(authUrl.toString())` where
2169
+ `authUrl = new URL("auth/authorize", \`${BASE_URL_CLINE}/\`)` with a fixed
2170
+ (module-const or imported) base. The `ts_ssrf_sink` post-filter already
2171
+ exempted a `fetch()` of a file-local `const` literal string (#963) but had no
2172
+ awareness of the `new URL(literalPath, fixedBase)`shape, so
2173
+ `url.toString()`/`url.href` fell through to the broad taint heuristic. Extended
2174
+ the post-filter to also exempt `fetch(u.toString())`/`fetch(u.href)` when `u`
2175
+ resolves (same file) to a clean `const u = new URL(<literalPath>, <fixedBase>)`
2176
+ — literal path plus a base that is a literal, a file-local literal `const`, or
2177
+ an import binding (the`URL` ctor may be aliased, e.g. `NodeURL`). Query params
2178
+ added later via`searchParams.set(...)` don't control origin/path and never
2179
+ taint the destination. Still fires (regression fixtures added) when the base
2180
+ or path comes from a function parameter, `process.env`/config (including a
2181
+ const initialized from`process.env`), request data, or a parsed
2182
+ redirect/`location`value — a missed SSRF stays far worse than one FP.
2183
+ Adversarial review then closed two bypasses in that exemption: (1) a
2184
+ post-construction origin mutation (`u.host = req.x` / `u.href = …`/
2185
+ `u["host"] = …`, incl. augmented assignments) now re-taints the receiver and
2186
+ fails closed — only query-only writes (`u.search`/`u.searchParams`) stay
2187
+ exempt; (2) a request-tainted function *parameter* base is no longer exempted
2188
+ just because an unrelated same-named module-level`const` literal exists
2189
+ (scope-aware shadow check; imported bases stay trusted).
2190
+
2191
+ - **Fixed: `no-javascript-url`/`no-javascript-url-js` no longer flag defensive `javascript:`-URL filters (refs #533)** — a dogfood run flagged code that was
2192
+ *rejecting* `javascript:` links (e.g. `url.startsWith("javascript:")` inside a
2193
+ guard) as if it were introducing one. The rule matched the `javascript:`
2194
+ string literal alone with no way to tell "used as a sink" from "used to
2195
+ detect/block it". Narrowed both rules to also exclude the literal when it's
2196
+ the needle in `.startsWith`/`.endsWith`/`.includes`/`.indexOf`/`.search`/
2197
+ `.match`, or one side of an `===`/`!==`/`==`/`!=` comparison — deliberately
2198
+ NOT `.replace`/`.replaceAll`, since the literal there could be the malicious
2199
+ replacement argument rather than the defensive search argument, and
2200
+ excluding the whole call would hide that true positive. Both new exclusions
2201
+ deliberately avoid `stopBy: end` (unlike the pre-existing test-literal
2202
+ exclusion, which needs it) so the literal must be a DIRECT argument/operand
2203
+ of the defensive call/comparison, not merely somewhere in its subtree — an
2204
+ adversarial review caught that `stopBy: end` let a real sink smuggled
2205
+ inside the call/comparison (e.g. `candidates.includes(link.href =
2206
+ "javascript:...")`, a comma-operator sink, or `(a.href = "javascript:...")
2207
+ === expected`) go unflagged. Added valid fixtures reproducing the filter FP
2208
+ and invalid fixtures for the replace-based sink and all three smuggled-sink
2209
+ bypasses, confirmed against the real ast-grep 0.45.0 binary.
2210
+
2211
+ - **Contained spawn/callback failures that could crash the host (pidusage bug class) (refs #533)** — a best-effort telemetry sampler recently killed a live
2212
+ pi host with `Error: spawn UNKNOWN` (uncaughtException) because a bundled
2213
+ dep's `child_process.spawn(...)` threw SYNCHRONOUSLY with no try/catch, from
2214
+ a detached context the caller's `try/await/catch` could not contain. Audited
2215
+ every spawn/detached-callback site in `clients/` and hardened the at-risk
2216
+ best-effort/background ones to the repo's existing guard shape (try/catch
2217
+ around the synchronous `spawn` + resolve gracefully, matching the async
2218
+ `'error'`-listener path): `safeSpawnAsync`'s core spawn (the shared wrapper
2219
+ dozens of best-effort callers rely on to NEVER reject), its Windows
2220
+ `killPidTreeSync` (runs from `process` exit/signal handlers, where a throw
2221
+ becomes an uncaughtException during shutdown; the POSIX branch was already
2222
+ guarded), `SgRunner.exec`'s three ast-grep spawn branches, all six installer
2223
+ tool-discovery/verify probes, the PSScriptAnalyzer runner's `spawnPs`, and
2224
+ the MCP `analyzeFileFresh` worker fork. A synchronous spawn throw now
2225
+ resolves the operation's normal failure value instead of escaping as an
2226
+ unhandledRejection/uncaughtException. Behavior-preserving otherwise.
2227
+
2228
+ - **Fixed: opengrep security findings now surface project-wide in `lens_diagnostics mode=full` (refs #585, #584, #533)** — opengrep's
2229
+ whole-tree CLI scan ran at session-start and cached its findings, and the
2230
+ cache-only extractor registry (`project-diagnostics/extractors.ts`) even
2231
+ registered an `"opengrep"` row — but #585 replaced that registry in
2232
+ production with `fetchFreshProjectDiagnostics`
2233
+ (`project-diagnostics/fresh-fetch.ts`), whose `ANALYZER_IDS` list omitted
2234
+ opengrep. Net: opengrep scanned + cached, yet nothing production read it
2235
+ back, so semgrep-grade security findings (ERROR→blocking, CWE-tagged) never
2236
+ reached the agent for unedited/project-wide files — a scan-and-orphan
2237
+ honesty gap (#533). `fetchFreshProjectDiagnostics` now runs opengrep the
2238
+ same way it runs gitleaks/trivy: an availability probe (opengrep is
2239
+ structurally always-on, so no static project-type gate), then a fresh scan
2240
+ that JOINS the session-start scan of the same root via
2241
+ `SecurityScanClient.dedupeScan` rather than double-spawning a heavy scan,
2242
+ with the result written back to cache and adapted to project diagnostics.
2243
+ The per-edit aux-LSP push path (`clientScope: with-auxiliary`,
2244
+ `AUXILIARY_LSP_PROFILES`) was verified already-working and is untouched;
2245
+ opengrep stays excluded from the push-only LSP workspace sweep (#584). The
2246
+ now-dead cache-only `extractCachedProjectDiagnostics` reader — the parallel
2247
+ registry that shadowed `ANALYZER_IDS` and let opengrep silently diverge — was
2248
+ removed so a single source of truth (#883) remains.
2249
+
2250
+ - **Fixed: test-runner findings now surface project-wide in `lens_diagnostics mode=full` (closes #1004, refs #585, #533)** — the same `ANALYZER_IDS`
2251
+ omission #585/#1003 fixed for opengrep also orphaned test-runner: the
2252
+ per-edit turn_end test fire (`runtime-turn.ts`) caches failures under
2253
+ `"test-runner-findings"`, but `fetchFreshProjectDiagnostics`
2254
+ (`project-diagnostics/fresh-fetch.ts`) never read that cache back, so test
2255
+ failures never reached the agent for unedited/project-wide `mode=full`
2256
+ calls. Unlike opengrep/gitleaks/trivy, this is wired as a CACHE-READ (not a
2257
+ fresh scan): test-runner has no "whole project" run to trigger — turn_end
2258
+ only ever runs the targeted, cascade-aware test files touched by that
2259
+ turn's edits — so `fetchFreshProjectDiagnostics` now peeks at the same
2260
+ `"test-runner-findings"` cache key (never re-running the suite, never
2261
+ writing back) and adapts it via the existing
2262
+ `testRunnerFindingsToProjectDiagnostics`. Added a coverage guardrail
2263
+ (`tests/clients/project-diagnostics/analyzer-coverage.test.ts`) that greps
2264
+ the real session-start (`runHeavyweightTask`) and EVERY turn_end
2265
+ `cacheManager.writeCache` call site in `runtime-turn.ts` (excluding a
2266
+ short, explicit bookkeeping exclude-list — `errorDebt`/`turn-end-findings`/
2267
+ `turn-end-findings-last` — that isn't analyzer-shaped) and asserts every
2268
+ remaining id is a member of `ANALYZER_IDS`, so this whole #585 bug class
2269
+ can't silently regress again for a future analyzer added to EITHER writer.
2270
+ Adversarial review also caught two honesty/fidelity gaps in the initial
2271
+ fix: (1) test-runner is cache-read, edit-scoped (the targeted test files
2272
+ touched by the most recent turn's edits), unlike every other analyzer's
2273
+ fresh whole-project scan — `mode=full` now emits an explicit "coverage is
2274
+ edit-scoped, NOT a full-project run" caveat whenever test-runner
2275
+ contributed findings (not only when cold), and a `stale` cached result
2276
+ (the turn advanced before the test run finished) is prefixed
2277
+ `[stale — from a prior turn]` in its diagnostic message, matching the
2278
+ one-shot turn-context message's own stale wording; (2) `TestFailure.location`
2279
+ (`"file.ts:42"`, set by the vitest/jest JSON parser in
2280
+ `test-runner-client.ts`) never reached `ProjectDiagnostic.line`, so every
2281
+ test-runner finding rendered as `L?:` — `runner-adapters/runner-findings.ts`
2282
+ now parses a numeric trailing `:line[:col]` out of `location` (left alone
2283
+ for pytest/mix's non-numeric `location` strings, which carry a test name or
2284
+ module, not a line).
2285
+
2286
+ - **Guarded Windows CPU/RSS resource sampling so a best-effort sampler can no longer crash the pi host (refs #620, #533)** — `clients/resource-sampler.ts`
2287
+ sampled CPU%/RSS via `pidusage`, whose Windows path shells out to `gwmi`
2288
+ through an internal `spawn(..., { shell: "powershell.exe" })` that has no
2289
+ try/catch and runs from inside a ChildProcess `close` callback. Under real
2290
+ Windows handle/commit pressure that spawn can throw `spawn UNKNOWN`
2291
+ (errno -4094) **synchronously in that detached callback**, which the call
2292
+ site's `try { await pidusage() } catch {}` cannot catch → `uncaughtException`
2293
+ → the whole host process dies (observed live). pidusage 4.0.1 offers no
2294
+ option to avoid the gwmi path. The sampler now runs its OWN fully guarded
2295
+ `Get-CimInstance Win32_Process` query on Windows (never calling `pidusage`
2296
+ there), mirroring the existing `findDescendantPidsWindows` guard: a
2297
+ synchronous spawn throw, a child `error` event, and a non-zero/garbage exit
2298
+ all resolve to a partial/empty map — the sampler can now only ever lose a
2299
+ data point, never throw into the heartbeat/spawn path. RSS comes from
2300
+ `WorkingSetSize`; CPU% is preserved via the same KernelModeTime/UserModeTime
2301
+ delta-over-elapsed-wall-time computation gwmi uses (a small per-pid history
2302
+ tracks the prior cumulative CPU time). Linux/macOS keep using `pidusage`
2303
+ (procfs/`ps` — not the crash vector) unchanged. A pid absent from the
2304
+ returned map still means "unsampled this tick", never zero.
2305
+
2306
+ - **Stabilized the two remaining flaky/environment-sensitive tests tracked in #902.** LSP workspace-diagnostics sweep flush-count assertions
2307
+ (`workspace-diagnostics-sweep-batch-open.test.ts`,
2308
+ `workspace-diagnostics-sweep-preopen-chunk.test.ts`) pinned an exact flush
2309
+ count, but the pre-open pass's real `fs.promises.readFile` genuinely races
2310
+ the real 100ms `WatchedFilesQueue` debounce timer — under full-suite
2311
+ scheduler contention that can legitimately fragment or merge a chunk's
2312
+ flush by one. Replaced the exact-count assertions with invariant checks
2313
+ (coalescing happened; nowhere near one flush per file; no lost/duplicated
2314
+ URIs) that tolerate that jitter without losing the #608/#621 regression
2315
+ coverage — a test-robustness fix, not a product bug. Separately, two
2316
+ ast-grep dispatch test harnesses
2317
+ (`ast-grep-rule-tests.test.ts`, `ast-grep-catalog-rules.test.ts`) shelled
2318
+ out to the real `ast-grep` CLI via a raw `execFileSync(..., { shell: true
2319
+ })` per call — an uncached cmd.exe wrapper on Windows whose own exit code
2320
+ can mask the real child's and which can intermittently fail to spawn at
2321
+ all under the process-creation pressure of a full parallel test run.
2322
+ Switched both to the already-hardened `safeSpawn` (`clients/safe-spawn.ts`,
2323
+ #817) — cached PATH+PATHEXT resolution, direct `.exe`/`.com` spawn with no
2324
+ shell involved — the same deterministic resolution production dispatch
2325
+ already relies on. The third tracked item
2326
+ (`startup-overhead.test.ts`'s "quick mode self-reports within 100ms")
2327
+ was already reworked to a generous fixed budget + `retry: 2` in an earlier
2328
+ pass; confirmed still adequate, no further change needed.
840
2329
 
841
2330
  - **`pi-lens build-graph` honestly reports a capped, over-the-cap persist**
842
- (closes #924, refs #936 limit 3) — the CLI's build-attempt check no longer
843
- mistakes the benign "succeeded, but persisted a partial subgraph" reason
844
- (#960's over-cap circuit-breaker) for a hard failure: it now exits 0 and
845
- prints a `PARTIAL persist` line with `persistedNodes=X/Y`,
846
- `persistedEdges=X/Y`, and the cap that was hit, instead of either silently
847
- reporting "done" or failing loudly on a build/persist that actually
848
- succeeded. `flushReviewGraphPersist()` now returns the persisted
849
- `coverage` so any standalone caller can make the same distinction.
2331
+ (closes #924, refs #936 limit 3) — the CLI's build-attempt check no longer
2332
+ mistakes the benign "succeeded, but persisted a partial subgraph" reason
2333
+ (#960's over-cap circuit-breaker) for a hard failure: it now exits 0 and
2334
+ prints a `PARTIAL persist` line with `persistedNodes=X/Y`,
2335
+ `persistedEdges=X/Y`, and the cap that was hit, instead of either silently
2336
+ reporting "done" or failing loudly on a build/persist that actually
2337
+ succeeded. `flushReviewGraphPersist()` now returns the persisted
2338
+ `coverage` so any standalone caller can make the same distinction.
850
2339
 
851
2340
  - **`ts-ssrf` no longer trusts naming convention as proof of a fixed URL**
852
- (fixes #963) — the post-filter now resolves a bare `fetch(IDENT)` argument
853
- against the file's AST and exempts it only when `IDENT` provably resolves
854
- to a single `const` declarator initialized with a string literal (or a
855
- template literal with no `${...}` substitutions). SCREAMING_SNAKE_CASE
856
- naming is no longer sufficient by itself: `const TARGET_URL = req.query.url`
857
- still flags, since the identifier's *initializer* — not its name — is
858
- what's checked. Ambiguous/shadowed declarations, `let`/`var` bindings,
859
- reassigned identifiers, and unresolved identifiers all fall through to the
860
- existing broad heuristic and keep being flagged.
2341
+ (fixes #963) — the post-filter now resolves a bare `fetch(IDENT)` argument
2342
+ against the file's AST and exempts it only when `IDENT` provably resolves
2343
+ to a single `const` declarator initialized with a string literal (or a
2344
+ template literal with no `${...}` substitutions). SCREAMING_SNAKE_CASE
2345
+ naming is no longer sufficient by itself: `const TARGET_URL = req.query.url`
2346
+ still flags, since the identifier's *initializer* — not its name — is
2347
+ what's checked. Ambiguous/shadowed declarations, `let`/`var` bindings,
2348
+ reassigned identifiers, and unresolved identifiers all fall through to the
2349
+ existing broad heuristic and keep being flagged.
861
2350
 
862
2351
  - **Remaining test-suite spawns routed off `shell:true` to close the Windows-spawn flake class** (refs #902) —
863
- `tests/clients/ast-grep-rule-precedence-followups.test.ts` (CLI probe + `runCli`),
864
- `tests/clients/coderabbit-ast-grep-rules.test.ts` (vendored-catalog smoke),
865
- `tests/clients/dispatch/runners/ast-grep-playground-verify.test.ts` (async `runVerify`),
866
- `tests/clients/dead-code-client.test.ts` (vulture probe), and
867
- `tests/clients/lsp/clangd-lazy-indexing.test.ts` (clangd `where`/`which` probe) now spawn
868
- through the hardened `safeSpawn`/`safeSpawnAsync` (`clients/safe-spawn.ts`, #817) instead of
869
- a raw `execFileSync`/`spawnSync`/`spawn`/`execSync` with a fresh, uncached `cmd.exe` wrapper
870
- per call — the documented intermittent ENOENT/EAGAIN source under windows-latest CI's
871
- parallel process-creation pressure. Completes the sweep PR #993 started for the two
872
- ast-grep runner test files; `git` spawns are left as-is (no `.cmd` shim, so out of the flake
873
- class) and the `mcp/*` and MCP harness spawns that need bidirectional stdin streaming are
874
- left as-is (`safeSpawnAsync` only exposes a close-based result, not a live stdin/stdout pipe).
2352
+ `tests/clients/ast-grep-rule-precedence-followups.test.ts` (CLI probe + `runCli`),
2353
+ `tests/clients/coderabbit-ast-grep-rules.test.ts` (vendored-catalog smoke),
2354
+ `tests/clients/dispatch/runners/ast-grep-playground-verify.test.ts` (async `runVerify`),
2355
+ `tests/clients/dead-code-client.test.ts` (vulture probe), and
2356
+ `tests/clients/lsp/clangd-lazy-indexing.test.ts` (clangd `where`/`which` probe) now spawn
2357
+ through the hardened `safeSpawn`/`safeSpawnAsync` (`clients/safe-spawn.ts`, #817) instead of
2358
+ a raw `execFileSync`/`spawnSync`/`spawn`/`execSync` with a fresh, uncached `cmd.exe` wrapper
2359
+ per call — the documented intermittent ENOENT/EAGAIN source under windows-latest CI's
2360
+ parallel process-creation pressure. Completes the sweep PR #993 started for the two
2361
+ ast-grep runner test files; `git` spawns are left as-is (no `.cmd` shim, so out of the flake
2362
+ class) and the `mcp/*` and MCP harness spawns that need bidirectional stdin streaming are
2363
+ left as-is (`safeSpawnAsync` only exposes a close-based result, not a live stdin/stdout pipe).
875
2364
 
876
2365
  - **Tree-sitter post-filters no longer leave silently dead rules** (refs #879) —
877
- the 25 unknown filter references were resolved by implementing eight bounded,
878
- fail-open-on-filter-error AST checks, expressing two conditions directly in
879
- their queries, and removing fifteen rules whose promised semantic/framework
880
- analysis could not be supported honestly. Coarse resource, assertion, and
881
- sensitive-`memset` heuristics are advisory rather than blocking.
2366
+ the 25 unknown filter references were resolved by implementing eight bounded,
2367
+ fail-open-on-filter-error AST checks, expressing two conditions directly in
2368
+ their queries, and removing fifteen rules whose promised semantic/framework
2369
+ analysis could not be supported honestly. Coarse resource, assertion, and
2370
+ sensitive-`memset` heuristics are advisory rather than blocking.
882
2371
 
883
2372
  - **Capped word indexes disclose partial coverage** (refs #928) — snapshots now
884
- persist indexed-file count and truncation state, and both `symbol_search`
885
- surfaces report coverage instead of presenting capped zero-hit results as
886
- authoritative.
2373
+ persist indexed-file count and truncation state, and both `symbol_search`
2374
+ surfaces report coverage instead of presenting capped zero-hit results as
2375
+ authoritative.
887
2376
 
888
2377
  - **`pilens_health` keeps disabled LSP servers visible** (refs #927) —
889
- permanently broken server/root pairs now render with their failure count, and
890
- temporary circuit-breaker cooldowns expose their retry deadline.
2378
+ permanently broken server/root pairs now render with their failure count, and
2379
+ temporary circuit-breaker cooldowns expose their retry deadline.
891
2380
 
892
2381
  - **Cold `symbol_search` failures are now observable and honest** (refs #926) —
893
- unavailable results distinguish an active build, a safety refusal, and the
894
- last build's failure, while background build/persist errors reach a persistent
895
- NDJSON log.
2382
+ unavailable results distinguish an active build, a safety refusal, and the
2383
+ last build's failure, while background build/persist errors reach a persistent
2384
+ NDJSON log.
896
2385
 
897
2386
  - **Failed heavyweight analyzers no longer masquerade as clean runs** (refs #925) —
898
- unsuccessful results are reported distinctly, omitted from cache so the next
899
- session retries, and valid fix-worklog records survive neighboring corrupt lines.
900
-
901
- - **The footer refreshes as LSP servers come online during a cold
902
- `lens_diagnostics mode=full` sweep** (refs #798), instead of showing
903
- `LSP Inactive` until turn end. The repaint captures UI methods during the
904
- active tool event, so async warm-up never touches a stale session context.
905
-
906
- - **Tree-sitter WASM aborts are now visible instead of silently disabling
907
- structural analysis for the rest of the process** (refs #915). The shared
908
- runtime records a process-wide, timestamped `restart_required` health state,
909
- logs a one-time actionable error, exposes it through `pilens_health`, and
910
- marks project-scan responses with `treeSitterStatus`. A poisoned scan remains
911
- truncated and never replaces the last complete snapshot. In-process retry is
912
- deliberately unsafe: every new client imports the same cached `web-tree-sitter`
913
- ES module and therefore reuses its corrupted Emscripten heap; restarting the
914
- host is the isolation boundary.
2387
+ unsuccessful results are reported distinctly, omitted from cache so the next
2388
+ session retries, and valid fix-worklog records survive neighboring corrupt lines.
2389
+
2390
+ - **The footer refreshes as LSP servers come online during a cold `lens_diagnostics mode=full` sweep (refs #798)**, instead of showing
2391
+ `LSP Inactive` until turn end. The repaint captures UI methods during the
2392
+ active tool event, so async warm-up never touches a stale session context.
2393
+
2394
+ - **Tree-sitter WASM aborts are now visible instead of silently disabling structural analysis for the rest of the process (refs #915)**. The shared
2395
+ runtime records a process-wide, timestamped `restart_required` health state,
2396
+ logs a one-time actionable error, exposes it through `pilens_health`, and
2397
+ marks project-scan responses with `treeSitterStatus`. A poisoned scan remains
2398
+ truncated and never replaces the last complete snapshot. In-process retry is
2399
+ deliberately unsafe: every new client imports the same cached `web-tree-sitter`
2400
+ ES module and therefore reuses its corrupted Emscripten heap; restarting the
2401
+ host is the isolation boundary.
915
2402
 
916
2403
  - **`pilens_rebuild` can no longer destroy an npm-installed pi-lens** (refs
917
- #920) — rebuilds are refused before spawning a package script unless the
918
- package is a source checkout with `tsconfig.dist.json` outside
919
- `node_modules`; installed servers also omit the tool from `tools/list`, so
920
- subagent allowlists cannot discover it.
2404
+ #920) — rebuilds are refused before spawning a package script unless the
2405
+ package is a source checkout with `tsconfig.dist.json` outside
2406
+ `node_modules`; installed servers also omit the tool from `tools/list`, so
2407
+ subagent allowlists cannot discover it.
921
2408
 
922
2409
  - **Review-graph background failures are no longer silent** (refs #919) —
923
- `project_report`/`pilens_project_report` now surface the most recent build
924
- attempt and its terminal skip/failure reason, including the persistence
925
- element circuit-breaker. A dedicated `~/.pi-lens/review-graph.log` records
926
- build and persistence starts, successes, skips, and failures, so a rejected
927
- fire-and-forget build or an over-cap graph cannot look perpetually in progress.
2410
+ `project_report`/`pilens_project_report` now surface the most recent build
2411
+ attempt and its terminal skip/failure reason, including the persistence
2412
+ element circuit-breaker. A dedicated `~/.pi-lens/review-graph.log` records
2413
+ build and persistence starts, successes, skips, and failures, so a rejected
2414
+ fire-and-forget build or an over-cap graph cannot look perpetually in progress.
928
2415
 
929
2416
  - Resolve nested C# and F# project roots for dotnet builds (refs #895).
930
2417
 
931
- - **A mid-scan tree-sitter WASM abort no longer replaces the authoritative
932
- project-diagnostics snapshot with a silently truncated result** (refs #891).
933
- The partial scan is returned with `scanTruncated`, logs its completed/total
934
- file counts and abort point, and leaves the previous complete cache intact.
2418
+ - **A mid-scan tree-sitter WASM abort no longer replaces the authoritative project-diagnostics snapshot with a silently truncated result (refs #891)**.
2419
+ The partial scan is returned with `scanTruncated`, logs its completed/total
2420
+ file counts and abort point, and leaves the previous complete cache intact.
935
2421
 
936
2422
  - **Block-wrapped switch cases no longer report false fall-through errors**
937
- (refs #910) — `switch-case-termination` now follows trailing statement
938
- blocks to recognize a nested `break`, `return`, `throw`, or `continue`, while
939
- still flagging empty and non-terminating blocks.
2423
+ (refs #910) — `switch-case-termination` now follows trailing statement
2424
+ blocks to recognize a nested `break`, `return`, `throw`, or `continue`, while
2425
+ still flagging empty and non-terminating blocks.
940
2426
 
941
2427
  - **Project-wide enumeration now covers every registered file kind** (refs
942
- #894) — `ALL_SCANNABLE_EXTENSIONS`, `WARMUP_SOURCE_EXTS`, and
943
- `SUPPORTED_FILE_KINDS` now derive from the single `KIND_EXTENSIONS`
944
- authority instead of three drifting language lists. TODO scans, symbol
945
- search indexing, dominant-language LSP warmup, and language-profile
946
- detection can now see Java, Swift, C/C++, PHP, and every other supported
947
- kind. Code kinds keep priority over data/doc kinds (json/yaml/markdown/…)
948
- inside the existing caps: the dominant-language LSP warm ranks code kinds
949
- first, and the capped warmup/word-index walks fill code files before
950
- non-code files, so a locale/fixture pile can't starve real languages.
951
- Package-manager lockfiles (package-lock.json, pnpm-lock.yaml, …) are
952
- filtered as generated artifacts, and the TODO scanner caps per-file reads
953
- at 512 KiB. A coverage guard makes a newly registered kind automatically
954
- enumerable — and classified as code or non-code — on both project-wide
955
- paths.
2428
+ #894) — `ALL_SCANNABLE_EXTENSIONS`, `WARMUP_SOURCE_EXTS`, and
2429
+ `SUPPORTED_FILE_KINDS` now derive from the single `KIND_EXTENSIONS`
2430
+ authority instead of three drifting language lists. TODO scans, symbol
2431
+ search indexing, dominant-language LSP warmup, and language-profile
2432
+ detection can now see Java, Swift, C/C++, PHP, and every other supported
2433
+ kind. Code kinds keep priority over data/doc kinds (json/yaml/markdown/…)
2434
+ inside the existing caps: the dominant-language LSP warm ranks code kinds
2435
+ first, and the capped warmup/word-index walks fill code files before
2436
+ non-code files, so a locale/fixture pile can't starve real languages.
2437
+ Package-manager lockfiles (package-lock.json, pnpm-lock.yaml, …) are
2438
+ filtered as generated artifacts, and the TODO scanner caps per-file reads
2439
+ at 512 KiB. A coverage guard makes a newly registered kind automatically
2440
+ enumerable — and classified as code or non-code — on both project-wide
2441
+ paths.
956
2442
 
957
2443
  - **CMake files now reach a real LSP server** (refs #892) — the CMake policy's
958
- previous `lsp` fallback had no registered server and silently produced no
959
- diagnostics. `cmake-language-server` now covers both `.cmake` files and the
960
- canonical `CMakeLists.txt` basename, with managed pip installation.
2444
+ previous `lsp` fallback had no registered server and silently produced no
2445
+ diagnostics. `cmake-language-server` now covers both `.cmake` files and the
2446
+ canonical `CMakeLists.txt` basename, with managed pip installation.
961
2447
 
962
2448
  - **Fish LSP policy is no longer dead wiring** (refs #893) — `fish-lsp` is now
963
- registered for `.fish` files and auto-installed through npm; `fish_indent`
964
- continues to run alongside it.
2449
+ registered for `.fish` files and auto-installed through npm; `fish_indent`
2450
+ continues to run alongside it.
965
2451
 
966
2452
  - **Editing an inherited tree-sitter rule now invalidates the inheriting language's RuleCache** (refs #878) — the cache key fingerprinted only the language's OWN rules directory, but `tsx` also runs the `typescript` rule set (`queriesForLanguage`), so a typescript-rule edit left the tsx entry's hash unchanged and stale compiled rules kept being served from the on-disk cache until a tsx rule happened to change. The fingerprint now covers the full effective rule set via `ruleFilesForLanguage`, a new loader-owned seam that derives from the same rule-source composition as rule selection, so the cache key can't drift from what the runner actually runs. The runner's cache-miss path also forces the query loader past its in-memory memo (`loadQueries(root, { force: true })`) — a correct key alone wasn't enough: the memo ignores rule-file mtimes, so within one process a miss re-persisted the PRE-edit rules under the fresh fingerprint and the staleness then survived restarts. The client's compiled-batch cache is likewise now keyed on rule content instead of rule ids — ids are stable across edits, so the shared client kept serving the pre-edit compiled patterns (and messages) for the process lifetime even after the reload. `CACHE_VERSION` bumped to `v6`.
967
2453
 
968
2454
  - **Small edits no longer pay the entity-extraction cost** (refs #885) — the
969
- <5-line skip threshold only guarded the zero-diagnostics early return; a
970
- second `extractEntitySnapshot` block ran unconditionally, so trivial edits
971
- still spent ~500-800ms per dispatch and, on unsaved buffers, parsed stale
972
- disk content (thrashing the parse-cache entry). Extraction is now one
973
- threshold-guarded block that receives the same `file.content` override the
974
- diagnostics phase used.
2455
+ <5-line skip threshold only guarded the zero-diagnostics early return; a
2456
+ second `extractEntitySnapshot` block ran unconditionally, so trivial edits
2457
+ still spent ~500-800ms per dispatch and, on unsaved buffers, parsed stale
2458
+ disk content (thrashing the parse-cache entry). Extraction is now one
2459
+ threshold-guarded block that receives the same `file.content` override the
2460
+ diagnostics phase used.
975
2461
 
976
2462
  - **The per-edit tree-sitter runner walks the tree once, not once per rule**
977
- (refs #888) — the dispatch hot path ran ~30-40 `runQueryOnFile` walks per
978
- edit behind a concurrency limiter that could not parallelize synchronous
979
- WASM. It now calls `runQueriesOnFile` once (#675 batching) and distributes
980
- the per-rule results; the per-rule `maxResults(10)` cap and modified-ranges
981
- gating are unchanged.
2463
+ (refs #888) — the dispatch hot path ran ~30-40 `runQueryOnFile` walks per
2464
+ edit behind a concurrency limiter that could not parallelize synchronous
2465
+ WASM. It now calls `runQueriesOnFile` once (#675 batching) and distributes
2466
+ the per-rule results; the per-rule `maxResults(10)` cap and modified-ranges
2467
+ gating are unchanged.
982
2468
 
983
2469
  - **A transient grammar-load failure no longer disables batched rule scans for the process lifetime** (refs #889) — `compileQueryBatch` cached `null` on ANY `build()` failure, including a transient `loadLanguage()` miss (offline lazy grammar fetch, mid-scan load error), so every later scan fell back to the per-rule path and paid ~3.3× forever. Load failures are now distinguished from genuine batch-compile failures: they are not cached (the next scan retries the load and recovers), with retries bounded at 3 consecutive load failures per rule set before the miss is cached, so a grammar that never loads doesn't hot-loop. Deterministic compile failures still cache `null` permanently. The query/batch cache keys also switched from a collision-prone 32-bit hash to the full pattern text.
984
2470
 
985
2471
  - **module-report parses plain JS under the correct tree-sitter grammar** (closes
986
- #887) — `tsLangForFile` hand-rolled a local extension map that sent
987
- `.js`/`.mjs`/`.cjs` to the typescript grammar and `.jsx` to tsx, while every
988
- other tree-sitter consumer resolves those extensions to `javascript` via the
989
- shared `EXT_TO_LANG` (`clients/tree-sitter-shared.ts`), so each plain-JS file
990
- was parsed and cached twice under two grammars (TreeCache keys are
991
- `languageId:path`) and ran TS-grammar symbol queries on JS trees. It now
992
- routes the extension-split kinds (jsts, and the c-vs-cpp split for cxx)
993
- through the shared `resolveTreeSitterLanguage`, keeping the historical kind
994
- default only for extensions the shared map does not cover (`.svelte`/`.vue`,
995
- the C++ module-interface/Objective-C tail). The symbol extractor gains a
996
- dedicated `javascript` defs/refs/import query set: the TypeScript symbol
997
- queries do NOT compile against the javascript grammar
998
- (`interface_declaration`/`type_alias_declaration`/`type_identifier` do not
999
- exist there — a query naming them fails with "Bad node name"), so the
1000
- javascript set is the same queries minus the type-only patterns, with class
1001
- names matched as `(identifier)` instead of `(type_identifier)`.
2472
+ #887) — `tsLangForFile` hand-rolled a local extension map that sent
2473
+ `.js`/`.mjs`/`.cjs` to the typescript grammar and `.jsx` to tsx, while every
2474
+ other tree-sitter consumer resolves those extensions to `javascript` via the
2475
+ shared `EXT_TO_LANG` (`clients/tree-sitter-shared.ts`), so each plain-JS file
2476
+ was parsed and cached twice under two grammars (TreeCache keys are
2477
+ `languageId:path`) and ran TS-grammar symbol queries on JS trees. It now
2478
+ routes the extension-split kinds (jsts, and the c-vs-cpp split for cxx)
2479
+ through the shared `resolveTreeSitterLanguage`, keeping the historical kind
2480
+ default only for extensions the shared map does not cover (`.svelte`/`.vue`,
2481
+ the C++ module-interface/Objective-C tail). The symbol extractor gains a
2482
+ dedicated `javascript` defs/refs/import query set: the TypeScript symbol
2483
+ queries do NOT compile against the javascript grammar
2484
+ (`interface_declaration`/`type_alias_declaration`/`type_identifier` do not
2485
+ exist there — a query naming them fails with "Bad node name"), so the
2486
+ javascript set is the same queries minus the type-only patterns, with class
2487
+ names matched as `(identifier)` instead of `(type_identifier)`.
1002
2488
 
1003
2489
  - **A rule whose query fails to compile now warns once instead of silently reporting nothing** (refs #884) — both compile paths (`compileQueryBatch`'s per-rule drop and `compileRawQuery`, which every per-edit `runQueryOnFile` call falls back on) previously either `dbg()`-logged (invisible without verbose mode) or returned `null`/`[]` with no trail at all. They now call a shared `reportQueryCompileFailure`, mirroring the existing unimplemented-`post_filter` warning: one `console.error` per broken rule id, not per file. A new compile-guard test (`tests/clients/tree-sitter-rule-compile-guard.test.ts`) compiles every non-disabled shipped rule against its real grammar and caught the 32 rules #884 reports as currently broken (tracked there in a shrink-only `KNOWN_BROKEN` allowlist so follow-up fix PRs are forced to remove their entries, and the list can't grow or go stale unnoticed).
1004
2490
 
1005
2491
  - **TreeCache mtime false misses and FIFO eviction** (closes #890) — a content
1006
- hash match is now authoritative: a save-without-change (same bytes, newer
1007
- mtime) is a cache hit that refreshes the entry's stat metadata instead of
1008
- invalidating and re-parsing, and `get()` re-inserts hit entries so eviction
1009
- is true LRU — hot per-edit files are no longer evicted by scan traffic.
1010
-
1011
- - **Eight enabled typescript/javascript tree-sitter rules whose queries never
1012
- compiled** (refs #884) — each had been silently dead since authoring because
1013
- its query failed to compile against the real grammar, so it matched nothing
1014
- and reported no diagnostics. Repaired against the actual node/field names and
1015
- verified end-to-end (matches the bug, leaves correct code alone):
1016
- `empty-switch-case`, `switch-case-termination`, `switch-case-termination-js`
1017
- (switch cases carry their statements as direct `body:` children, not a
1018
- `consequence: (statement_block)`); `infinite-loop` (`while (true)` wraps the
1019
- condition in a `parenthesized_expression`; `for (;;)` has an `empty_statement`
1020
- condition, not `(null)`); `duplicate-function-arg` (typescript parameters are
1021
- `required_parameter`, not bare `(identifier)`; now also catches non-adjacent
1022
- duplicates); `mixed-async-styles` (no `async_modifier` node — match the `async`
1023
- token); `switch-non-case-labels` (JS) (a `labeled_statement` lives inside a
1024
- `switch_case`, not directly under `switch_body`); and `ts-insecure-random`
1025
- (the inline `(?i)` regex flag is invalid in JS `RegExp` — dropped the
1026
- redundant name predicate and let the post-filter do the case-insensitive
1027
- check, walking up from the `Math.random()` call so chained forms like
1028
- `Math.random().toString(36)` are still attributed to their binding). Also
1029
- implemented the four post-filters these rules referenced but that were never
1030
- defined (`is_empty_block`, `no_break_or_return_in_body`, `same_param_name`,
1031
- `no_terminating_statement`), which the batch runner had been failing closed on.
2492
+ hash match is now authoritative: a save-without-change (same bytes, newer
2493
+ mtime) is a cache hit that refreshes the entry's stat metadata instead of
2494
+ invalidating and re-parsing, and `get()` re-inserts hit entries so eviction
2495
+ is true LRU — hot per-edit files are no longer evicted by scan traffic.
2496
+
2497
+ - **Eight enabled typescript/javascript tree-sitter rules whose queries never compiled (refs #884)** — each had been silently dead since authoring because
2498
+ its query failed to compile against the real grammar, so it matched nothing
2499
+ and reported no diagnostics. Repaired against the actual node/field names and
2500
+ verified end-to-end (matches the bug, leaves correct code alone):
2501
+ `empty-switch-case`, `switch-case-termination`, `switch-case-termination-js`
2502
+ (switch cases carry their statements as direct `body:` children, not a
2503
+ `consequence: (statement_block)`); `infinite-loop` (`while (true)` wraps the
2504
+ condition in a `parenthesized_expression`; `for (;;)` has an `empty_statement`
2505
+ condition, not `(null)`); `duplicate-function-arg` (typescript parameters are
2506
+ `required_parameter`, not bare `(identifier)`; now also catches non-adjacent
2507
+ duplicates); `mixed-async-styles` (no `async_modifier` node — match the `async`
2508
+ token); `switch-non-case-labels` (JS) (a `labeled_statement` lives inside a
2509
+ `switch_case`, not directly under `switch_body`); and `ts-insecure-random`
2510
+ (the inline `(?i)` regex flag is invalid in JS `RegExp` — dropped the
2511
+ redundant name predicate and let the post-filter do the case-insensitive
2512
+ check, walking up from the `Math.random()` call so chained forms like
2513
+ `Math.random().toString(36)` are still attributed to their binding). Also
2514
+ implemented the four post-filters these rules referenced but that were never
2515
+ defined (`is_empty_block`, `no_break_or_return_in_body`, `same_param_name`,
2516
+ `no_terminating_statement`), which the batch runner had been failing closed on.
1032
2517
 
1033
2518
  - **A column-0 comment after a `query: |` block no longer breaks the rule**
1034
- (refs #884) — the query-block extractor kept every line more-indented than the
1035
- key (to preserve `#eq?`/`#match?` predicate lines) but did not stop at a
1036
- document-level `# …` comment sitting between the block and the next key, so the
1037
- comment was appended to the query and made it fail to compile. This is what
1038
- kept `mixed-async-styles` dead even after its query was otherwise correct.
2519
+ (refs #884) — the query-block extractor kept every line more-indented than the
2520
+ key (to preserve `#eq?`/`#match?` predicate lines) but did not stop at a
2521
+ document-level `# …` comment sitting between the block and the next key, so the
2522
+ comment was appended to the query and made it fail to compile. This is what
2523
+ kept `mixed-async-styles` dead even after its query was otherwise correct.
1039
2524
 
1040
2525
  - **Seven ruby security rules never compiled and never produced a finding** (refs
1041
- #884) — `ruby-command-injection`, `ruby-eval`, `ruby-insecure-deserialization`,
1042
- `ruby-insecure-random`, `ruby-open-struct`, `ruby-string-eval` and
1043
- `ruby-weak-hash` were authored against JavaScript-grammar node names
1044
- (`call_expression`, `method_call`, `command`, `interpolated_string`) that do not
1045
- exist in tree-sitter-ruby, so every one failed to compile with a "Bad node name"
1046
- error and was silently skipped. Each query is rewritten against the real ruby
1047
- grammar (`call` with `method:`/`receiver:` fields, `scope_resolution` receivers
1048
- for `Digest::MD5`, `string` for interpolated literals), with `#match?`/`#eq?`
1049
- predicates moved inside the pattern's outermost parens. Per-rule positive and
1050
- negative fixtures (`system` vs `File.read`, `Marshal.load` vs `YAML.safe_load`,
1051
- `rand` vs `SecureRandom`, `OpenStruct.new` vs `Struct.new`, `Digest::MD5` vs
1052
- `Digest::SHA256`, string vs block `class_eval`) pin the security intent.
2526
+ #884) — `ruby-command-injection`, `ruby-eval`, `ruby-insecure-deserialization`,
2527
+ `ruby-insecure-random`, `ruby-open-struct`, `ruby-string-eval` and
2528
+ `ruby-weak-hash` were authored against JavaScript-grammar node names
2529
+ (`call_expression`, `method_call`, `command`, `interpolated_string`) that do not
2530
+ exist in tree-sitter-ruby, so every one failed to compile with a "Bad node name"
2531
+ error and was silently skipped. Each query is rewritten against the real ruby
2532
+ grammar (`call` with `method:`/`receiver:` fields, `scope_resolution` receivers
2533
+ for `Digest::MD5`, `string` for interpolated literals), with `#match?`/`#eq?`
2534
+ predicates moved inside the pattern's outermost parens. Per-rule positive and
2535
+ negative fixtures (`system` vs `File.read`, `Marshal.load` vs `YAML.safe_load`,
2536
+ `rand` vs `SecureRandom`, `OpenStruct.new` vs `Struct.new`, `Digest::MD5` vs
2537
+ `Digest::SHA256`, string vs block `class_eval`) pin the security intent.
1053
2538
 
1054
2539
  - **Six python rules never compiled or never fired** (refs #884) —
1055
- `python-empty-except` used a `body:` field that doesn't exist on
1056
- `except_clause`; `in-operator-unsupported` used bare `"in"`/`"not"` `"in"`
1057
- tokens the grammar doesn't expose that way (`not in` is a single token) and
1058
- only matched `identifier` targets, so `x in None` never matched at all;
1059
- `no-super-torchscript` looked for a `(call function: (identifier))` inside the
1060
- decorator (`@torch.jit.script` has no call — it's a bare `attribute`) and
1061
- expected the decorator directly on the method rather than the class;
1062
- `notimplemented-boolean-context` used `("and" | "or")` (not valid tree-sitter
1063
- query syntax — alternation is `[...]`) inside a non-existent `binary_operator`
1064
- form and `unary_operator operator: ("not")`, when python's logical `not` is
1065
- its own `not_operator` node with an `argument:` field;
1066
- `yield-return-outside-function` referenced a `yield_expression` node type that
1067
- doesn't exist in tree-sitter-python. All five queries are rewritten against
1068
- the real grammar (verified via AST dumps against `tree-sitter-python.wasm`);
1069
- `no-super-torchscript` and `in-operator-unsupported` gained
1070
- `torchscript_super_call` / `check_in_operator_types` post-filters
1071
- (`clients/tree-sitter-client.ts`) since neither had a working implementation
1072
- behind their declared `post_filter` name. `exit-signature-check` compiled
1073
- fine but its `@PARAM1?`/`@PARAM2?`/`@PARAM3?` captures never matched anything
1074
- — the `?` quantifier was written after the capture name instead of the node,
1075
- so the captured names were literally `PARAM1?`/`PARAM2?`/`PARAM3?` while the
1076
- post_filter read `captures.PARAM1`; fixed to capture the whole `parameters`
1077
- node and count named children instead (per-slot optional quantifiers turned
1078
- out to match every valid sub-alignment, not just the maximal one, producing
1079
- spurious duplicate matches for a fully-correct signature).
2540
+ `python-empty-except` used a `body:` field that doesn't exist on
2541
+ `except_clause`; `in-operator-unsupported` used bare `"in"`/`"not"` `"in"`
2542
+ tokens the grammar doesn't expose that way (`not in` is a single token) and
2543
+ only matched `identifier` targets, so `x in None` never matched at all;
2544
+ `no-super-torchscript` looked for a `(call function: (identifier))` inside the
2545
+ decorator (`@torch.jit.script` has no call — it's a bare `attribute`) and
2546
+ expected the decorator directly on the method rather than the class;
2547
+ `notimplemented-boolean-context` used `("and" | "or")` (not valid tree-sitter
2548
+ query syntax — alternation is `[...]`) inside a non-existent `binary_operator`
2549
+ form and `unary_operator operator: ("not")`, when python's logical `not` is
2550
+ its own `not_operator` node with an `argument:` field;
2551
+ `yield-return-outside-function` referenced a `yield_expression` node type that
2552
+ doesn't exist in tree-sitter-python. All five queries are rewritten against
2553
+ the real grammar (verified via AST dumps against `tree-sitter-python.wasm`);
2554
+ `no-super-torchscript` and `in-operator-unsupported` gained
2555
+ `torchscript_super_call` / `check_in_operator_types` post-filters
2556
+ (`clients/tree-sitter-client.ts`) since neither had a working implementation
2557
+ behind their declared `post_filter` name. `exit-signature-check` compiled
2558
+ fine but its `@PARAM1?`/`@PARAM2?`/`@PARAM3?` captures never matched anything
2559
+ — the `?` quantifier was written after the capture name instead of the node,
2560
+ so the captured names were literally `PARAM1?`/`PARAM2?`/`PARAM3?` while the
2561
+ post_filter read `captures.PARAM1`; fixed to capture the whole `parameters`
2562
+ node and count named children instead (per-slot optional quantifiers turned
2563
+ out to match every valid sub-alignment, not just the maximal one, producing
2564
+ spurious duplicate matches for a fully-correct signature).
1080
2565
 
1081
2566
  - **Project scans run tree-sitter rules for every supported language, not just 10 extensions** (closes #882, refs #877, #880) — the scanner's `TREE_SITTER_EXT_TO_LANG` covered only ts/tsx/js/py/go/rs/rb, so files whose grammars and non-disabled rule dirs already exist (c, cpp, csharp, css, php, java, kotlin) were silently skipped by the tree-sitter phase of project scans. It now derives the shared per-edit resolver (`EXT_TO_LANG`) so c/cpp/csharp/php/css and the `.tsx`→tsx / `.jsx`→javascript nuances can't drift from the per-edit path, and layers java/kotlin on top (grammars + rule dirs exist but no per-edit `appliesTo`). A regression test asserts the map covers every non-disabled rule dir whose grammar is loadable.
1082
2567
 
1083
2568
  - **`.dart` files are now included in project-wide source enumeration** (closes #880, refs #876) — `ALL_SCANNABLE_EXTENSIONS` (`clients/source-filter.ts`) and `WARMUP_SOURCE_EXTS` (`clients/language-profile.ts`) were missing `.dart`, so Dart projects were fully supported per-edit (LSP, `dart-analyze`, `dart format`, autofix) but skipped by project-wide scans and cold-start language-profile warmup.
1084
2569
 
1085
2570
  - **Tree-sitter rules were compiled against the wrong grammar** — a compiled
1086
- query is bound to the language it compiled against, and running it on a tree
1087
- from another grammar returns zero matches silently. Rules now compile against
1088
- the language the file is parsed as. This is why the javascript→typescript rule
1089
- merge never fired a single diagnostic in its life. That merge stays off — the
1090
- rules are written against typescript node types and misfire on JS — but the
1091
- same fix is what makes `.tsx` work.
2571
+ query is bound to the language it compiled against, and running it on a tree
2572
+ from another grammar returns zero matches silently. Rules now compile against
2573
+ the language the file is parsed as. This is why the javascript→typescript rule
2574
+ merge never fired a single diagnostic in its life. That merge stays off — the
2575
+ rules are written against typescript node types and misfire on JS — but the
2576
+ same fix is what makes `.tsx` work.
1092
2577
 
1093
2578
  - **Project scans ran rules from `-disabled/` directories** — the per-edit runner
1094
- excluded them, the scanner read the raw loader map and ran them anyway.
1095
- **1,936 of a 500-file scan's 2,590 tree-sitter findings came from rules that
1096
- were explicitly switched off.** Rule-set selection now goes through one seam,
1097
- `queriesForLanguage`.
2579
+ excluded them, the scanner read the raw loader map and ran them anyway.
2580
+ **1,936 of a 500-file scan's 2,590 tree-sitter findings came from rules that
2581
+ were explicitly switched off.** Rule-set selection now goes through one seam,
2582
+ `queriesForLanguage`.
1098
2583
 
1099
2584
  - **`.tsx` parsed with the typescript grammar**, which produces ERROR nodes on
1100
- JSX, and under a different language id than the fact providers use — so every
1101
- `.tsx` file was parsed twice, once wrongly. It now parses as `tsx` and
1102
- inherits the typescript rule set, which is rule-for-rule identical on both
1103
- grammars. `.tsx` also gets those rules in the per-edit runner now, where it
1104
- previously saw only its own two JSX rules.
2585
+ JSX, and under a different language id than the fact providers use — so every
2586
+ `.tsx` file was parsed twice, once wrongly. It now parses as `tsx` and
2587
+ inherits the typescript rule set, which is rule-for-rule identical on both
2588
+ grammars. `.tsx` also gets those rules in the per-edit runner now, where it
2589
+ previously saw only its own two JSX rules.
1105
2590
 
1106
2591
  - **An unimplemented `post_filter` reported every raw match instead of none.**
1107
- 40 of the 84 filters rules reference have no implementation; the default now
1108
- drops the match and warns once. `duplicate-function-arg` (whose
1109
- `same_param_name` filter was never implemented) was reporting 59 phantom
1110
- duplicates across 60 files.
2592
+ 40 of the 84 filters rules reference have no implementation; the default now
2593
+ drops the match and warns once. `duplicate-function-arg` (whose
2594
+ `same_param_name` filter was never implemented) was reporting 59 phantom
2595
+ duplicates across 60 files.
1111
2596
 
1112
2597
  - **YAML scalars kept their trailing comments** in the rule loader's hand-rolled
1113
- parser — `post_filter: not_in_test_block # skip test blocks` carried the
1114
- comment as part of the filter name, so the filter silently never applied.
2598
+ parser — `post_filter: not_in_test_block # skip test blocks` carried the
2599
+ comment as part of the filter name, so the filter silently never applied.
1115
2600
 
1116
- - **tree-sitter runner no longer double-increments already 1-indexed
1117
- line/column** (refs #448) — dispositions recorded against the old off-by-one
1118
- are anchored (`clients/diagnostic-dispositions.ts`) to the wrong physical
1119
- line's content and will resurface once after upgrading; this is expected and
1120
- one-time, not a regression.
2601
+ - **tree-sitter runner no longer double-increments already 1-indexed line/column (refs #448)** — dispositions recorded against the old off-by-one
2602
+ are anchored (`clients/diagnostic-dispositions.ts`) to the wrong physical
2603
+ line's content and will resurface once after upgrading; this is expected and
2604
+ one-time, not a regression.
1121
2605
 
1122
2606
  ### Security
1123
2607
 
1124
2608
  - **Debug logs redact credential-shaped text before it reaches disk** (closes
1125
- #327). The shared NDJSON boundary scrubs private keys, provider tokens, AWS
1126
- access keys, and JWT/JWE compact tokens with deterministic linear scanners.
1127
- The synchronous crash-adjacent LSP launch diagnostic uses the same redactor.
2609
+ #327). The shared NDJSON boundary scrubs private keys, provider tokens, AWS
2610
+ access keys, and JWT/JWE compact tokens with deterministic linear scanners.
2611
+ The synchronous crash-adjacent LSP launch diagnostic uses the same redactor.
1128
2612
 
1129
2613
  ## [3.8.73] - 2026-07-28
1130
2614
 
@@ -1133,15 +2617,15 @@ All notable changes to pi-lens will be documented in this file.
1133
2617
  ### Changed
1134
2618
 
1135
2619
  - **`@types/js-yaml` removed; js-yaml 5 migration complete** (closes #391) — with
1136
- js-yaml 5.2.2 in (#843) the package ships its own declarations, so the stub
1137
- types dev-dependency is gone and the lockfile regenerated. This completes the
1138
- v5 migration started with the namespace-import compatibility pass below.
2620
+ js-yaml 5.2.2 in (#843) the package ships its own declarations, so the stub
2621
+ types dev-dependency is gone and the lockfile regenerated. This completes the
2622
+ v5 migration started with the namespace-import compatibility pass below.
1139
2623
 
1140
2624
  - **`js-yaml` imports are compatible with v5** (refs #391, #843) — use the
1141
- package's namespace exports so the central accessor and YAML rule tooling work
1142
- with both the current v4 dependency and the upcoming v5 release. `@types/js-yaml`
1143
- remains temporarily because js-yaml 4.3.0 needs its declarations; remove it
1144
- with the v5 Dependabot upgrade once the bundled declarations are active.
2625
+ package's namespace exports so the central accessor and YAML rule tooling work
2626
+ with both the current v4 dependency and the upcoming v5 release. `@types/js-yaml`
2627
+ remains temporarily because js-yaml 4.3.0 needs its declarations; remove it
2628
+ with the v5 Dependabot upgrade once the bundled declarations are active.
1145
2629
 
1146
2630
  - **`build:dist` now compiles with TypeScript 7.0.2** (refs #809) — the dist emit pin moves from `typescript@6` to the same major as the dev dependency (#600); emitted output was verified byte-identical across all 328 files before the bump.
1147
2631
 
@@ -1154,17 +2638,17 @@ All notable changes to pi-lens will be documented in this file.
1154
2638
  - **Missing tree-sitter defect-class metadata is now covered** (refs #863) — loop-termination and `finally` control-flow rules now declare their taxonomy classes, keeping the rule audit complete.
1155
2639
 
1156
2640
  - **MCP smoke deadlines scale under CI load (refs #860)** — the shared MCP
1157
- test harness keeps its 20s local default, accepts the
1158
- `PI_LENS_TEST_TIMEOUT_SCALE` multiplier, and the analyze-graph smoke pays
1159
- server startup through a throwaway health call before its assertions.
2641
+ test harness keeps its 20s local default, accepts the
2642
+ `PI_LENS_TEST_TIMEOUT_SCALE` multiplier, and the analyze-graph smoke pays
2643
+ server startup through a throwaway health call before its assertions.
1160
2644
 
1161
2645
  - **TypeScript 7 compatibility fixes (refs #809)** — narrowed ast-grep search test details and made the LSP `workspace/applyEdit` response overload-compatible, unblocking Dependabot PR #600.
1162
2646
 
1163
2647
  - **ast-grep NAPI fallback now runs TSX-tagged rules and reports unsupported-language skips** (refs #282) — `.tsx` files use the `tsx` grammar for rule-language scoping, preserving the exact TypeScript/JavaScript twin behavior from #657; rules for languages the fallback cannot evaluate are logged once per rule instead of disappearing as zero matches.
1164
2648
 
1165
2649
  - **CSS server root-policy coverage is hermetic on Windows** (refs #855) — the
1166
- server-policy fixture now pins its nearest `package.json` inside the temporary
1167
- workspace, so a marker in the host user's profile cannot change the result.
2650
+ server-policy fixture now pins its nearest `package.json` inside the temporary
2651
+ workspace, so a marker in the host user's profile cannot change the result.
1168
2652
 
1169
2653
  - **Multi-client LSP shutdown no longer serializes the grace-period tail** (#851) — retiring clients now tear down concurrently while generation handoff still waits for every client to settle; instance-registry child removals serialize their read-modify-write without serializing process kills.
1170
2654
 
@@ -1368,6 +2852,8 @@ All notable changes to pi-lens will be documented in this file.
1368
2852
  - **`fact-rules` diagnostics now stamp `tool: "fact-rules"` instead of their own rule id, fixing scattered turn-summary grouping** (#578) — `clients/dispatch/types.ts`'s `Diagnostic` contract documents `tool` as "which runner produced this" and `rule` as "the specific check within it"; `ast-grep`'s runner follows it correctly (one `tool: "ast-grep"` value shared across every pattern), but all 13 rules under `clients/dispatch/rules/*.ts` (the `fact-rules` runner) stamped their own rule id into `tool` instead (`{ tool: "high-fan-out", rule: "high-fan-out" }`), so `clients/turn-summary.ts`'s collapsed one-liner — which groups by `event.tool` — scattered N fact-rule findings into N separate per-rule-name buckets instead of one clean `fact-rules N` bucket like every other runner. Fixed at the emission source in all 13 files (`async-noise.ts`, `async-unnecessary-wrapper.ts`, `cors-wildcard.ts`, `error-obscuring.ts`, `error-swallowing.ts`, `high-complexity.ts`, `high-fan-out.ts`, `high-import-coupling.ts`, `missing-error-propagation.ts`, `no-commented-credentials.ts`, `pass-through-wrappers.ts`, `placeholder-comments.ts`, `unsafe-boundary.ts`) — `rule` is untouched, so `detectFactRuleId` (`clients/dispatch/integration.ts`, rule-first with an id-prefix fallback) and `code-quality-warnings.ts`'s `warning.rule ?? warning.tool` grouping both keep resolving to the specific rule id. Also closed a latent gap the fix surfaced: `error-obscuring.ts` and `error-swallowing.ts` had no `rule` field at all (only `tool` carried the rule id), which would have made `code-quality-warnings.ts`'s `rule ?? tool` fallback collapse them into `"fact-rules"` too post-fix — both now set `rule` explicitly, matching the other 11 rules' existing shape. New test in `tests/clients/turn-summary.test.ts` constructs three fact-rule diagnostics with distinct `rule` values sharing `tool: "fact-rules"` and asserts they collapse into a single `fact-rules 3` bucket via `formatTurnSummaryLine`.
1369
2853
  - **`clientScope: "all"` (the standalone `lsp_diagnostics` tool and `lens_diagnostics_full`) now gets per-server-aware diagnostics timeouts instead of one flat cap for every spawned server** (#573, found investigating the #570 incident: a 150-file `lens_diagnostics_full` scan took 114s and saturated the TypeScript LSP server) — `LSPService.touchFile` (`clients/lsp/index.ts`) already computed a `perServerTimeout` that reads each server's own `aggregateWaitMs` budget from `server-strategies.ts` (TS ~1s, rust-analyzer 3s, opengrep 3.5s, …), but only wired it up for the single-server `"primary"` hot path (#203) and, later, `clientScope: "with-auxiliary"` (#242). `clientScope: "all"` fell through to the pre-#203 flat `callerCap ?? modeFloor` branch — a leftover from #203 explicitly deferring the "full/cascade path," never revisited when #242 added per-server budgeting for auxiliaries. Every server (fast primary, slow auxiliary) was held to one shared number, either wasting time on a fast server capped to a slow auxiliary's ceiling (multiplied across a full-workspace scan) or starving a slow auxiliary of the time its own strategy says it needs. Fix: `clientScope === "all"` is now included alongside `"with-auxiliary"` in the `perServerTimeout` branch, so each spawned server's individual `waitForDiagnostics` call is bounded by `min(callerCap, ownStrategyBudget)` rather than the flat number. The touch's overall detection deadline (used only for the `lsp_diagnostics_timeout` latency log) is unchanged — it's still `Math.max(...)` over every spawned server's timeout, so "all" still waits for the slowest server before logging a timeout; nothing about auxiliary coverage regresses. `envWait` (`PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS`) and the existing `"primary"`/`"with-auxiliary"` behavior are untouched — this is purely additive coverage of the previously-flattened `"all"` case. Evaluated whether the richer live capability-matrix data (`getCapabilitySnapshots()`'s push/pull `mode`, `diagnosticProviderKind`) should feed `perServerTimeout` beyond what `server-strategies.ts` already encodes; found no case in the current budgeting logic it would improve (the existing `aggregateWaitMs`/`silentOnClean` table already differentiates push vs. pull servers where it matters) — left as a documented non-integration rather than added complexity; `cascade-tier.ts`'s existing capability-matrix consumption is a separate lane (cascade-lane skip-decision, not touch-time budgeting) and is unaffected. New tests in `tests/clients/lsp/service-touch-collect.test.ts` (`#573`): each server on the `"all"` scope gets its own caller-cap-bounded deadline rather than a shared flat number; a fast primary's own wait isn't held to a slow auxiliary's larger budget; a tight caller cap still binds every server as a ceiling; the env override still wins; and a regression guard confirming `"primary"`/`"with-auxiliary"` per-server behavior is unchanged.
1370
2854
  - **A timed-out LSP diagnostics check no longer presents as a confirmed-clean result and no longer erases known-good diagnostic state** (#570, found via live log analysis on a dogfooding project) — `LSPService.touchFile` (`clients/lsp/index.ts`) already tracked `notifyWriteTimedOut`/`diagnosticsTimedOut` per touch (logged to `lsp_touch_file` latency events) but never used them to gate anything: an inconclusive empty `collected` result was cached and returned identically to a genuinely server-confirmed empty result. Concretely, a timeout unconditionally deleted `lastKnownDiagnostics`/`lastKnownContentHash` for the file (so a hot-path consumer like `actionable-warnings` at `turn_end` would see "no known diagnostics" for a file that may still have real errors), and callers of `touchFile` (the per-edit dispatch runner, the `lsp_diagnostics` tool) had no way to distinguish "confirmed 0" from "timed out, defaulted to 0". Fix: `touchFile` now computes `inconclusive = notifyWriteTimedOut || diagnosticsTimedOut` (deliberately touch-wide/conservative — `collected` merges diagnostics across every spawned server, so even a partial per-server timeout means the merge may be incomplete) and (1) skips the `lastKnownDiagnostics` set-or-delete block entirely when inconclusive, leaving whatever was cached from the last confirmed check untouched; (2) flags the returned diagnostics array with a non-enumerable `inconclusive: true` bonus field (a plain array otherwise — existing callers that only read it as an array are unaffected) so callers that care can check `.inconclusive` without a breaking return-type change across `touchFile`'s ~10 production call sites. Two consumers wired: `clients/dispatch/runners/lsp.ts` (the per-edit path feeding the footer/widget via `recordDiagnostics`) now returns `status: "skipped"` (same treatment as "no LSP client was ready") instead of `"succeeded"` with an empty diagnostics list when the touch was inconclusive — this automatically feeds the existing dispatcher coverage-notice mechanism (`getCoverageNotice`), so an inconclusive edit is flagged the same way a fully-skipped one is. The standalone `lsp_diagnostics` tool (`tools/lsp-diagnostics.ts`, and its `pilens_lsp_diagnostics` MCP mirror, which reuses the same `createLspDiagnosticsTool()`) now threads the priming `touchFile`'s `inconclusive` flag through `collectDiagnosticsForFile`/`collectFileDiagnosticResult` and folds a timed-out check into the existing #533 "unconfirmed" bucket (never a bare "0 diagnostics"), while distinguishing WHY in the rendered text/compact-render/batch-and-directory tallies ("N timed out" vs. #533's "cannot confirm clean — push-only, silent-on-clean") via a new per-result `timedOut` field and `timedOutFiles`/`unconfirmedReasonClause` aggregation, rather than collapsing the two distinct reasons into one misleading message. The non-timeout path is unchanged: a genuinely fast, confirmed empty result still clears the cache and reports clean exactly as before. New tests: `tests/clients/lsp/service-touch-collect.test.ts` (`#570` describe block — a timed-out touch does NOT clear a prior confirmed non-empty `lastKnownDiagnostics` record; a confirmed non-timeout empty result still clears it as before), `tests/clients/dispatch/runners/runner-status-semantics.test.ts` (the lsp runner returns `"skipped"` on an inconclusive touch), `tests/tools/lsp-diagnostics.test.ts` (`#570` describe block — single-file/batch renders distinguish timed-out from confirmed-clean/silent-unconfirmed).
2855
+ - **Turn-end inline blockers now reconcile against disk — a blocker recorded for a file that has since been deleted can never be cleared (clearInlineBlockers only fires on a later dispatch of the same path, which a deleted file never gets), so it re-surfaced as an "Unresolved from this turn" blocker every turn and could keep the #1063 git guard conservative for the whole session (closes #1245, refs #279/#297/#298 reconcile-on-display discipline)** — RuntimeCoordinator._pendingInlineBlockers (a PathKeyedMap) is now reconciled against disk at every read: getInlineBlockersSnapshot() drops entries whose file no longer exists (the map is tiny and reads are bounded — once per turn_end / tool_result — so the probe cost is negligible), and the git guard's aggregate _gitGuardHasBlockers check reads the reconciled snapshot instead of the raw map size, so a deleted file's blocker no longer counts. The empty-vs-stale distinction is preserved: a blocker for a live file still gates exactly as before. The reconcile rebuilds the map from existence-checked survivors instead of deleting in place — delete-time key normalization lowercases a gone file's tail on Windows while set-time realpaths it, so a delete-based reconcile missed mixed-case filenames (a verified live gap, covered by a dedicated regression test). Regression tests record a blocker for a temp file (lowercase and mixed-case), delete the file, and assert the snapshot and git-guard status both clear (fail pre-fix).
2856
+
1371
2857
  - **`lens_diagnostics` mode=full and `lsp_diagnostics` now reconcile fresh scan results into the footer/widget-state cache** (#571) — `recordDiagnostics` (`clients/widget-state.ts`), the sole writer to the footer's `allDiagnostics` store, previously had exactly one caller: `pipeline.ts`'s per-edit dispatch. A `lens_diagnostics` mode=full workspace scan or a standalone `lsp_diagnostics` check fetches fresh, authoritative diagnostics for files it examines but, until now, only reported that data back to the caller — never reconciled it into the footer. Practical consequence: if file A's diagnostics only became stale/fresh because of a change in file B (e.g. a shared interface), and A itself was never directly re-edited, the footer for A could stay stale indefinitely even after a scan proved the fresher truth. Both tools now call a new shared choke point, `clients/widget-state.ts`'s `reconcileScanDiagnostics(filePath, diagnostics, confirmed, writeIndex?)`, for each file they get a result for; `index.ts` injects the same monotonic `RuntimeCoordinator.nextWriteIndex()` source `pipeline.ts`'s per-edit writes draw from, so the existing `WriteOrderingGuard` (#555/#560) can't be clobbered by (or clobber) a concurrent, genuinely newer per-edit write for the same file. Guardrail: a result the check can't vouch for is never reconciled. `#570` landed in the same window as this fix (see above) and its `inconclusive` signal (`touchFile`'s non-enumerable `.inconclusive` flag, set when the notify write or the diagnostics wait itself timed out) is what BOTH tools now key off: `lsp_diagnostics` reuses it directly (threaded from `collectFileDiagnosticResult`/`runFileDiagnostics`'s own `timedOut`/`confirmation` fields), and `lens_diagnostics` mode=full's per-file LSP sweep (`runWorkspaceDiagnostics`) reads the same flag off each `touchFile` call's result and ORs it with its own outer per-file deadline/throw (`LSPWorkspaceDiagnosticResult.timedOut`) — either reason skips reconciliation for that file. No new batching/throttling was added for full-scan bursts — `recordDiagnostics`'s render trigger is a standard TUI dirty-flag request (already exercised by today's per-edit multi-file cascades without dedicated batching) and each write is independent/synchronous, so a scan touching many files behaves the same as N sequential per-edit writes already do. New tests: `tests/clients/widget-state.test.ts` (`reconcileScanDiagnostics`'s confirmed/unconfirmed gating and write-ordering-guard interaction), `tests/tools/lens-diagnostics.test.ts` and `tests/tools/lsp-diagnostics.test.ts` (each tool's confirmed-vs-timed-out/unconfirmed reconciliation wiring, including batch mode).
1372
2858
  - **Native TS7 no longer inherits classic typescript-language-server's `silentOnClean` cascade fast path** (#558, reverts #541) — PR #541 (2026-07-11) classified TS7's native `tsc --lsp --stdio` launch variant (PR #526) as `silentOnClean`, letting the cascade lane (`clients/lsp/cascade-tier.ts`) skip its in-lane diagnostic wait for native-ts7 edits on the strength of a clean-signal probe run that appeared to show it silent, same as classic. A 2026-07-12 dual-environment re-measurement (nightly CI on Linux and a live local run on Windows dev, same `typescript@7.0.2` both times) found native-ts7 now publishes 2 version-less diagnostic sets on the clean transition (`cleanPubs=2(v:0)`) — it is NOT silent, a drift from the #541 measurement, and skipping the wait could miss or delay real diagnostics on native-ts7 edits. `cascade-tier.ts`'s classifier again routes a `launchVariant === "native-ts7"` snapshot through the fail-safe full-wait path; `server-strategies.ts`'s `silentOnClean: true` for `"typescript"` is effectively classic-only again. Classic typescript-language-server is unaffected — re-confirmed silent (`cleanPubs=0(v:0)`) in the same run. `scripts/probe-clean-signal.mjs`'s nightly drift check no longer routes native-ts7 rows through classic's shared marker; it now compares them against an explicit `false` expectation, so a future TS7 build that goes silent again surfaces as a `silent-not-marked` signal instead of being silently skipped.
1373
2859
  - **LSP diagnostics no longer briefly cache stale results after rapid edits** — `textDocument/publishDiagnostics` pushes were written into the diagnostics cache (`pushDiagnostics`) unconditionally, even when the server's own reported `version` field showed the push was computed against an EARLIER edit than the one currently in flight (e.g. the server was still finishing analysis of edit N when edit N+1 already landed). `isVersionStale()` (`clientWaitForDiagnostics`'s staleness check) only gated whether a diagnostics *wait* resolved early — it was never consulted by the plain read path (`client.getDiagnostics()`/`getAllDiagnostics()`/`pruneDiagnostics()`), so a late push could get cached and served as "current" until the next genuinely fresh push overwrote it: diagnostics would transiently show stale results right after a rapid edit, then self-correct a moment later once real analysis caught up. The `publishDiagnostics` handler in `clients/lsp/client.ts` now drops a push before it reaches the cache (no `pushDiagnostics.set`, no `diagnosticsVersion` bump, no `diagnostics` event emit) whenever the push reports a version that's behind the currently-tracked document version for that path — checked at write time (after the debounce timer fires, not at notification-receipt time) so a push that arrives fresh but whose debounce window straddles a later edit is still caught. Version-less servers (no `version` field reported) are unaffected — that remains an intentional, documented tradeoff, unchanged by this fix. A dropped push correctly emits nothing, so a pending `clientWaitForDiagnostics` call still falls through to its other resolution paths (a later genuinely-fresh push, or the existing timeout backstop) rather than resolving on stale data. Deliberately out of scope, left as known follow-ups: the pull-diagnostics path (`clientRequestPullDiagnostics`/`clientRequestWorkspaceDiagnostics`) has no version stamp to compare against in this codebase's current handling, so nothing analogous is applied there; and `diagnosticsVersion` remains a single global counter rather than per-path, so an unrelated path's fresh push can still satisfy a wait baselined on this path's version — both are separate, larger-blast-radius changes.