@herbertgao/pi-extensions 2026.8.4 → 2026.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (360) hide show
  1. package/README.md +5 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +11 -3
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +11 -3
  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/feature/context.ts +74 -5
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +53 -14
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
  14. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  15. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +18 -0
  16. package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
  17. package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
  18. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +20 -4
  21. package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
  22. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
  23. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
  25. package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
  26. package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
  27. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
  30. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
  31. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
  32. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  33. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
  34. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
  35. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
  36. package/node_modules/@narumitw/pi-btw/README.md +32 -24
  37. package/node_modules/@narumitw/pi-btw/package.json +6 -6
  38. package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
  39. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -13
  40. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  41. package/node_modules/pi-lens/CHANGELOG.md +2535 -954
  42. package/node_modules/pi-lens/README.md +13 -0
  43. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
  44. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
  45. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
  46. package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
  47. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
  48. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
  49. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
  50. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
  51. package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
  52. package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
  53. package/node_modules/pi-lens/dist/clients/biome-client.js +136 -24
  54. package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
  55. package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
  56. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
  57. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  58. package/node_modules/pi-lens/dist/clients/bus-publish.js +19 -4
  59. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
  60. package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
  61. package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
  62. package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
  63. package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
  64. package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
  65. package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
  66. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
  67. package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
  68. package/node_modules/pi-lens/dist/clients/console-guard-install.js +26 -0
  69. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
  70. package/node_modules/pi-lens/dist/clients/dead-code-client.js +137 -31
  71. package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
  72. package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
  73. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
  74. package/node_modules/pi-lens/dist/clients/dependency-checker.js +361 -77
  75. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +65 -13
  76. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +18 -4
  77. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +176 -29
  78. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
  79. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
  80. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
  81. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
  82. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
  83. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1172 -683
  84. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
  85. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
  86. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
  87. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
  88. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +47 -6
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +473 -42
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  114. package/node_modules/pi-lens/dist/clients/disposition-publish.js +18 -4
  115. package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
  116. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  117. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
  118. package/node_modules/pi-lens/dist/clients/extension-log.js +432 -0
  119. package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
  120. package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
  121. package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
  122. package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
  123. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  124. package/node_modules/pi-lens/dist/clients/format-events-publish.js +51 -7
  125. package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
  126. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
  127. package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
  128. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
  129. package/node_modules/pi-lens/dist/clients/git-guard.js +678 -11
  130. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
  131. package/node_modules/pi-lens/dist/clients/go-client.js +23 -40
  132. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +124 -7
  133. package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
  134. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
  135. package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
  136. package/node_modules/pi-lens/dist/clients/installer/index.js +395 -100
  137. package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
  138. package/node_modules/pi-lens/dist/clients/jscpd-client.js +27 -55
  139. package/node_modules/pi-lens/dist/clients/knip-client.js +58 -42
  140. package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
  141. package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
  142. package/node_modules/pi-lens/dist/clients/latency-logger.js +72 -1
  143. package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
  144. package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
  145. package/node_modules/pi-lens/dist/clients/lens-events.js +63 -9
  146. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
  147. package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
  148. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +73 -0
  149. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
  150. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  151. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +59 -14
  152. package/node_modules/pi-lens/dist/clients/lsp/client.js +662 -56
  153. package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
  154. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +115 -0
  155. package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
  156. package/node_modules/pi-lens/dist/clients/lsp/index.js +2025 -800
  157. package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
  158. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  159. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  160. package/node_modules/pi-lens/dist/clients/lsp/server.js +437 -86
  161. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
  162. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  163. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
  164. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  165. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
  166. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
  167. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +127 -32
  168. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
  169. package/node_modules/pi-lens/dist/clients/mcp/session.js +235 -14
  170. package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
  171. package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
  172. package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
  173. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  174. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
  175. package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
  176. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
  177. package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
  178. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
  179. package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
  180. package/node_modules/pi-lens/dist/clients/pipeline.js +76 -15
  181. package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
  182. package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
  183. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
  184. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
  185. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
  186. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  187. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
  188. package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
  189. package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
  190. package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
  191. package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
  192. package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
  193. package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
  194. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
  195. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
  196. package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
  197. package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
  198. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
  199. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
  200. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
  201. package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
  202. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
  203. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
  204. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
  205. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
  206. package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
  207. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  208. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +257 -77
  209. package/node_modules/pi-lens/dist/clients/runtime-context.js +129 -12
  210. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +242 -26
  211. package/node_modules/pi-lens/dist/clients/runtime-session.js +428 -117
  212. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
  213. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +342 -33
  214. package/node_modules/pi-lens/dist/clients/runtime-turn.js +619 -93
  215. package/node_modules/pi-lens/dist/clients/rust-client.js +23 -38
  216. package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
  217. package/node_modules/pi-lens/dist/clients/security-scan-client.js +90 -6
  218. package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
  219. package/node_modules/pi-lens/dist/clients/sg-runner.js +424 -196
  220. package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
  221. package/node_modules/pi-lens/dist/clients/smells-rollup.js +231 -0
  222. package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
  223. package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
  224. package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
  225. package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
  226. package/node_modules/pi-lens/dist/clients/test-runner-client.js +469 -32
  227. package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
  228. package/node_modules/pi-lens/dist/clients/tool-policy.js +390 -39
  229. package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
  230. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  231. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
  232. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
  233. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
  234. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
  235. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
  236. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
  237. package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
  238. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
  239. package/node_modules/pi-lens/dist/clients/warm-attach.js +50 -13
  240. package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
  241. package/node_modules/pi-lens/dist/clients/word-index.js +680 -81
  242. package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
  243. package/node_modules/pi-lens/dist/index.js +51785 -36118
  244. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
  245. package/node_modules/pi-lens/dist/mcp/server.js +219 -46
  246. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  247. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  248. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
  249. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  250. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +203 -38
  251. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +210 -42
  252. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
  253. package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
  254. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  255. package/node_modules/pi-lens/docs/agent-guide.md +40 -15
  256. package/node_modules/pi-lens/docs/agent-tools.md +21 -10
  257. package/node_modules/pi-lens/docs/analysisall.md +192 -0
  258. package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
  259. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +14 -3
  260. package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
  261. package/node_modules/pi-lens/docs/environment-variables.md +44 -0
  262. package/node_modules/pi-lens/docs/fable.md +27 -0
  263. package/node_modules/pi-lens/docs/features.md +45 -3
  264. package/node_modules/pi-lens/docs/globalconfig.md +56 -1
  265. package/node_modules/pi-lens/docs/language-coverage.md +4 -3
  266. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
  267. package/node_modules/pi-lens/docs/mcp.md +37 -4
  268. package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
  269. package/node_modules/pi-lens/docs/settings.md +18 -1
  270. package/node_modules/pi-lens/docs/tools.md +4 -1
  271. package/node_modules/pi-lens/docs/usage.md +41 -5
  272. package/node_modules/pi-lens/docs/word-index.md +35 -0
  273. package/node_modules/pi-lens/package.json +10 -10
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  300. package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  301. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
  302. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +134 -2
  303. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
  304. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  305. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
  306. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
  307. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  308. package/node_modules/pi-mcp-adapter/CHANGELOG.md +38 -0
  309. package/node_modules/pi-mcp-adapter/README.md +38 -4
  310. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  311. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
  312. package/node_modules/pi-mcp-adapter/cli.js +25 -4
  313. package/node_modules/pi-mcp-adapter/commands.ts +15 -7
  314. package/node_modules/pi-mcp-adapter/config.ts +4 -4
  315. package/node_modules/pi-mcp-adapter/direct-tools.ts +58 -8
  316. package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
  317. package/node_modules/pi-mcp-adapter/index.ts +8 -3
  318. package/node_modules/pi-mcp-adapter/init.ts +29 -5
  319. package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
  320. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  321. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  322. package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
  323. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
  324. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  325. package/node_modules/pi-mcp-adapter/metadata-cache.ts +37 -4
  326. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  327. package/node_modules/pi-mcp-adapter/package.json +4 -3
  328. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
  329. package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
  330. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  331. package/node_modules/pi-mcp-adapter/server-manager.ts +41 -7
  332. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  333. package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
  334. package/node_modules/pi-mcp-adapter/tool-metadata.ts +62 -4
  335. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
  336. package/node_modules/pi-mcp-adapter/types.ts +176 -27
  337. package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
  338. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  339. package/node_modules/pi-web-access/README.md +18 -12
  340. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  341. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  342. package/node_modules/pi-web-access/curator-page.ts +5 -3
  343. package/node_modules/pi-web-access/curator-server.ts +2 -1
  344. package/node_modules/pi-web-access/extract.ts +106 -34
  345. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  346. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  347. package/node_modules/pi-web-access/gemini-search.ts +18 -4
  348. package/node_modules/pi-web-access/index.ts +120 -48
  349. package/node_modules/pi-web-access/package.json +2 -2
  350. package/node_modules/pi-web-access/summary-review.ts +11 -5
  351. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  352. package/package.json +12 -13
  353. package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
  354. package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
  355. package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
  356. package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
  357. package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
  358. package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
  359. package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
  360. package/node_modules/pi-lens/dist/clients/types.js +0 -11
@@ -8,8 +8,1608 @@ 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
+
15
+ ### Fixed
16
+
17
+ ### Security
18
+
19
+ ## [4.0.1] - 2026-08-16
20
+
21
+ ### Added
22
+
23
+ - **Live native-TS7 diagnostic fixture (refs [#1412](https://github.com/apmantza/pi-lens/issues/1412))** — an opt-in (`PI_LENS_INTEGRATION=1`) integration suite launches the real `tsc --lsp` server against a Vitest-typed fixture, asserting no `Mock`/`mockResolvedValueOnce` false positives after settling while an intentional type-error control still surfaces, and recording the publication/settle sequence.
24
+
25
+ - **Cascade injection, test-targeting, and cache-effectiveness telemetry (Closes #1446)** — `cascade_injected` (what text and how many neighbors/diagnostics reached `blockerParts` — i.e. were queued for the turn-end advisory, not confirmation of agent delivery, which can still be suppressed later; answering item 1) and `cascade_test_targets` (which tests were suggested for cascade neighbors, including the zero-suggestion case, answering item 2) are new `cascade.log` records. `cascade_result` now also carries `cacheHits`/`recentlyCleanHits`/`coldTouches`/`deferredTouches` (item 5's cache-effectiveness measurement, unblocked now that #1444 has landed) and `neighborBudget`/`budgetTruncated` (item 4's telemetry half). Item 3 (`lsp_diagnostics_timeout` server attribution) was already delivered by #1457. Item 4's budget-vs-settle-time sizing change is re-homed as #1462.
26
+
27
+ - **Four ast-grep rules for the type-laundering smell family (Closes #1447)** — Add `no-unknown-laundering`, `no-conditional-empty-object-spread` (+ `-js`), `no-reflect-apply` / `no-reflect-get` (+ `-js`), and `no-chained-type-assertions` to the shipped rule catalog, alongside the existing `no-as-any` and `no-any-type`.
28
+
29
+ - **Attribute worklog and disposition entries to the model/provider that produced them (closes #1448)** — `WorklogEntry` and `DispositionLogEntry` gain optional `model`/`provider` fields, populated at append time from the runtime's telemetry identity (`RuntimeCoordinator.telemetryModelId`/`telemetryProviderId`) and blank when the runtime doesn't know its identity. Provider is the host's explicit value when reported, else a conservative parse of the model id (`clients/model-provider.ts`: a single `/` or `:` separator, or a small known-prefix table; blank on ambiguity — never guessed). `npm run logs:smells` now prints a per-model rollup (rule × model counts, auto-fixed vs. agent-required rates) from `worklog.jsonl`. Old worklog/disposition-log entries stay valid; readers treat both fields as optional.
30
+
31
+ - **Prefix filters for word-index queries: lang, file, ext (Closes #1450)** — `symbol_search`/`pilens_symbol_search` queries can now mix plain terms with composable `lang:`, `file:`, and `ext:` prefix filters, plus `-` negation, e.g. `lang:jsts file:clients/ -file:test rank`. A hand-rolled tokenizer (`parseWordIndexQuery` in `clients/word-index.ts`) splits filters from terms; `lang:` resolves through `KIND_EXTENSIONS` (`clients/file-kinds.ts`), the single source of truth, so there is no second, hand-maintained language list. Filters apply as a pre-ranking predicate, composing with the existing `paths`/`lang` structured options, before BM25/priors/centrality scoring runs. An unrecognized prefix or `lang:` kind throws a typed `WordIndexQueryError` naming the supported list instead of silently degrading to a literal search term.
32
+
33
+ - **Live classic-TypeScript repair fixture (refs [#1436](https://github.com/apmantza/pi-lens/issues/1436))** — an opt-in (`PI_LENS_INTEGRATION=1`) integration suite stages a real managed-tools tree in the reported broken shape (TypeScript 7 with no `lib/tsserver.js`, cached by a prior probe) inside an isolated `PI_LENS_HOME`, then drives the production spawn through the real installer: it asserts the version-gated repair fires exactly once, the tree self-heals to the pinned classic compiler, and the classic launch serves diagnostics again. A negative control proves a disallowed-install spawn stays discovery-only and mutates nothing.
34
+
35
+ ### Changed
36
+
37
+ - **docs: document the per-edit autofix routing (refs #1414)** — `docs/agent-guide.md` and `docs/features.md`/`docs/usage.md` now describe the write-immediate vs. edit-deferred autofix split, the authoritative post-fix content attached to `write` tool results (with its size cap and shared multi-file bash budget), and the coalesced `{autofix, format}` deferred-mutation queue that drains at `agent_end`.
38
+
39
+ - **Skill docs realigned with shipped behavior and split for token economy (closes #1424, refs #1423)** — `pi-lens-write-ast-grep-rule`'s stale `-js` twins gotcha now reflects the #657 NAPI runner grammar dedup (`ruleLanguageForFile` in `ast-grep-napi.ts`) instead of advising a fix that already shipped; its NAPI-runner-specifics and hard-to-express-pattern gotchas moved verbatim into a new `reference.md` behind a pointer, keeping `SKILL.md` to the minimal template, language list, rule-condition syntax, and the load-bearing `stopBy` warning. `pi-lens-ast-grep`'s Structural-intent parameters table now documents `hasKind`/`hasDescendantKind` per-tool (`ast_grep_search`'s `hasKind` is immediate-child only, with a separate recursive `hasDescendantKind`; `ast_grep_replace` has only the immediate-child `hasKind`) with a cross-tool divergence warning, and its Agent task recipes table is trimmed to entries not already covered by the Patterns table. A new `tests/skills/skill-doc-drift.test.ts` pins param names against real tool schemas, path references against the filesystem and `package.json`'s published files, both rule-writing skills' language lists against their `rule-schema.json` enums, and `<!-- verified: ... -->` claim-comment format.
40
+
41
+ - **Dead code reports the per-turn delta, not the whole project ([#1477](https://github.com/apmantza/pi-lens/issues/1477), refs [#127](https://github.com/apmantza/pi-lens/issues/127))** — turn_end used to re-inject the entire vulture cache every turn. A fixed list of pre-existing findings burned context, and the one actionable signal — a symbol the current edit just orphaned — never stood out. Dead code now follows knip's contract: a new `owns()` seam re-scans only when the turn touched a file of that language, and the result is diffed against the previous scan and filtered to the edited files. Only that delta is injected. It also feeds `projectDiagnosticsDelta`, and the block moved ahead of the delta-report write, so a dead-code-only turn persists a report `lens_diagnostics` can read. A timed-out or killed scan backs the runner off, and a failed scan never overwrites a good cached one. The project-wide list stays available on demand through `lens_diagnostics`. vulture also gets `--ignore-decorators`, so framework-invoked symbols such as fixtures and route handlers stop appearing as permanent noise.
42
+
43
+ - **The auto-format nudge names the action, not the process** — a cross-process batch used to read "by another pi-lens instance (e.g. a subagent's)", which sent agents investigating what other instance was running. It now reads "by an automatic run outside your turn". Which process formatted the file is an implementation detail the reading agent cannot act on; the origin split stays exact in the telemetry.
44
+
45
+ - **Word-index resume stats run through a bounded parallel walk (refs [#1409](https://github.com/apmantza/pi-lens/issues/1409))** — session-start incremental refresh now stats source files with a bounded indexed cursor pool (8 workers over libuv's 4-slot threadpool) instead of serial synchronous calls, while publishing metadata in original walk order so churn classification and rebuild preflight remain deterministic. Per-file stat failures retain the previous absent-file semantics, supersession stops new claims and settles in-flight work, and phase telemetry separates snapshot load, deserialize, source walk, stat walk, refresh reads, and the synchronous snapshot-save span.
46
+
47
+ ### Fixed
48
+
49
+ - **TypeScript LSP roots follow governing configs (refs [#1412](https://github.com/apmantza/pi-lens/issues/1412))** — TypeScript and JavaScript files now prefer their nearest `tsconfig.json` or `jsconfig.json` (filtered by extension family — a `.ts` file skips a jsconfig-only directory) while preserving package-boundary client isolation, and classic servers emit bounded, read-only project-association telemetry after the first open. Classic-server tool discovery (`typescript-language-server`, `tsserver.js`) now walks up from a nested config root instead of only checking the root itself. Known accepted tradeoff: honoring nested config roots enlarges the population of roots subject to #1373's pre-existing open-order sensitivity.
50
+
51
+ - **Post-fix decisions are observable (Closes #1432)** — Add bounded decision telemetry for advisory provenance, TypeScript project identity, deferred mutations, authoritative-content attachment, bus context resolution, and time-scoped smell rollups.
52
+
53
+ - **Native TS7 diagnostic waits stabilize versionless publication bursts (refs [#1412](https://github.com/apmantza/pi-lens/issues/1412))** — TypeScript wait strategy resolution now follows the launched server variant: classic typescript-language-server retains authoritative first-push seeding, while native `tsc --lsp --stdio` debounces provisional pushes until a quiet window or advertised pull provides the settled result. Bounded latency telemetry records publication shape and settle source without diagnostic text.
54
+
55
+ - **Stale advisories are historical, not current blockers (refs #1413)** — Turn-end and async test caches record immutable capture provenance and SHA-256-confirm every affected file at delivery. Changed, legacy, malformed, unreadable, truncated, and superseded findings remain non-blocking historical context; deleted per-file findings disappear, while unchanged findings retain live blocker framing across turn and project-sequence drift. Monotonic async-test generations prevent older batches from overwriting newer results, and MCP and in-process delivery share one classification without changing acknowledgement, one-shot consumption, or commit-gate state.
56
+
57
+ - **Per-edit autofix no longer mutates files mid-turn (closes #1414)** — `edit` queues pipeline autofix for the owning `agent_end`, where autofix runs before the stable formatting pass; `write` keeps immediate autofix and returns authoritative post-fix file content, while write-then-edit paths stay demoted for the rest of the turn. Deferred mutation records coalesce both phases, preserve session ownership, deduplicate project-wide Rust/Dart fixers, and merge independently requeued phases.
58
+
59
+ - **Bus publishers keep activation context ownership (closes #1415)** — Event producers pair each live emitter with its OWN activation's context (no fallback to a process-global "latest ctx", which could belong to an unrelated sibling activation) and guard lens events through the shared stale-session seam with the same occurrence-scoped failure gating every other producer uses. Lens emit failures now count toward the `bus-stale` degradation smell like every other producer's — intentional: a probe-undefined or resolve/emit race on the lens path is a genuine failure that should be visible, not silently excluded.
60
+
61
+ - **`ast_grep_replace` now matches `ast_grep_search`'s `hasKind`/`hasDescendantKind` surface (closes #1423)** — `ast_grep_replace`'s `hasKind` param description wrongly said "contain a descendant"; it actually restricts to an **immediate child** (ast-grep default `stopBy: neighbor`), same as `ast_grep_search`. The description now matches the real behavior. `ast_grep_replace` also gained the `hasDescendantKind` param `ast_grep_search` already had — the explicit recursive form (`stopBy: end`) for when the target kind is nested below an immediate child; it's mutually exclusive with `hasKind`, surfaced as a clear synthesis error from both tools. `skills/pi-lens-ast-grep/SKILL.md`'s structural-intent table drops the now-obsolete "replace has no `hasDescendantKind`" divergence warning and documents both params as available on both tools.
62
+
63
+ - **pi's own CLI output is no longer swallowed by the console guard (Closes #1434)** — The guard used to replace every `console.*` method globally and permanently once the extension loaded. pi's one-shot commands print through `console.log`, so `pi list` exited 0 with no output in any project whose directory loaded the extension first. The guard is now a dispatcher: it routes a write to the extension log only while pi-lens owns execution, and passes every other write to the original console method. Windows open around module evaluation, the extension activation, and every callback registered through the host API's `on` and `register*` members. `AsyncLocalStorage` carries the window across `await` boundaries. Terminal safety is unchanged for pi-lens code paths. One cost note for Node 22: an active `AsyncLocalStorage` adds per-`await` overhead process-wide on that runtime. `PI_LENS_CONSOLE_GUARD=0` disables the guard and avoids that cost entirely.
64
+
65
+ - **Managed TypeScript self-heals the classic LSP fallback (closes [#1436](https://github.com/apmantza/pi-lens/issues/1436))** — Pi Lens now pins its managed classic compiler to TypeScript 5.9 and repairs existing managed TypeScript 7 trees when `tsserver.js` is absent, while leaving project-local TypeScript 7 on the native `tsc --lsp --stdio` path.
66
+
67
+ - **Deliver late cascade results instead of dropping them (closes #1443)** — A cascade whose compute missed the turn-end settle cap, or whose neighbor diagnostics landed in the quiet window after the turn ended, was carried over and then discarded unread: the turn-end filter rejected every run stamped with an earlier turn, so the carry-over path was dead code (the two measured cases were the highest fan-out cascades of the day). Late runs now merge into the following turn's output, a run superseded by a newer write is dropped with a logged record instead of silently, and the carry is bounded to one turn.
68
+
69
+ - **Settle native TS7 cascade checks (closes #1444)** — Cascade neighbor checks on native TS7 no longer burn the in-lane wait budget on a publication that only arrives later; the result is collected in the quiet window instead, and a late clean result now also clears the neighbor's stale footer errors. On the classic (full-wait) lane, a neighbor whose diagnostics wait lapsed used to produce no output at all — which read as "clean" — and now renders an explicit inconclusive note; unconfirmed checks are still never cached as clean. `cascade_result` records how many neighbors were deferred, so a fully-deferred cascade is distinguishable from a clean leaf.
70
+
71
+ - **Cascade no longer blames the review graph for test-file edits (closes #1445)** — A `missing_node` cascade result had two causes that read the same but meant opposite things: a real gap in the graph, and a test file excluded from the graph by design (#260). Test-file edits now get a distinct `excluded_by_role` reason that stays in telemetry but never reaches the agent as a "review graph was unavailable" advisory. A genuinely missing source file still reports `missing_node` and still triggers the advisory.
72
+
73
+ - **Test-runner results report a real duration and a real skip count (closes #1452)** — Every vitest and jest run logged `(0ms)`: `parseJsonTestOutput` hardcoded `duration: 0` and read `numSkippedTests`, a field neither reporter emits, so a file with skipped tests also always reported 0 skipped. Duration now comes from the per-suite `startTime`/`endTime` the reporters do emit (wall-clock span across suites, falling back to summed per-assertion durations), and skips come from `numPendingTests` + `numTodoTests`. PHPUnit's own `Time:` summary line — both the `00:00.123` clock form and the legacy `1.23 seconds` / `123 ms` forms — is parsed instead of discarded. An unparseable summary reports no duration at all rather than a wrong figure (see #1479 — this sentence read "still reports 0" until that landed, and both entries are still unreleased, so one release's notes would otherwise have contradicted the other).
74
+
75
+ - **Keep the tool list stable across forks and reloads (closes #1453)** — When a session is forked, reloaded, or resumed, pi tells the model about every pi-lens tool again. Pi-lens now puts the list straight back to what it was, including the situational tools the model had activated, so the tool list the model sees does not change and your cached prompt still applies. Use `--no-lazy-tools` (or `tools.lazy: false`) to keep every tool active from the start instead.
76
+
77
+ - **Auxiliary LSP scanners reach per-edit diagnostics (closes #1458)** — Auxiliary waits now use each server's declared budget within a 2-second post-primary ceiling, in both the touch push wait and the `getDiagnostics` aggregation lane. Late findings carry into the next unchanged-content read only when their SHA-256 content binding matches. Each touch logs a per-scanner wait outcome (answered, silent, or cut off — decided from evidence of an actual publication, not just promise settlement) with elapsed time and effective budget. Independently diagnosed by @snowyukitty, who reported the same root cause and both affected call sites in #1471.
78
+
79
+ - **Secret blockers no longer name deleted files (refs #1461)** — Scanner result caches were valid on wall-clock age alone, so a gitleaks finding stayed a 🔴 STOP blocker for the rest of its 30-minute window even after the file it named was deleted. The advisory provenance guard did not catch it: it validates the files the agent edited, not the paths inside the findings. Provenance now also validates finding-cited paths, and gitleaks drops findings whose path is gone rather than demoting them — a deleted file offers no remediation. Findings on files that still exist are delivered unchanged; a finding with no path, an unreadable path, or a path past the stat budget is still delivered. Each drop writes one bounded `finding_dead_path_drop` record to `latency.log` naming the store, the count, and a sample of paths.
80
+
81
+ - **A slow tool check no longer disables an installed tool for the whole session (closes #1467)** — When the check for knip, madge, govulncheck, or vulture timed out, pi-lens remembered the tool as unavailable until you restarted, and told you to install a tool that was already installed. Knip produced no findings for weeks because of this. A timed-out check is now retried after a short wait, the message says the check timed out instead of naming the wrong cause, a failed run keeps the last good cached findings instead of overwriting them, and each decision is recorded in `latency.log` as an `availability_decision` entry with its cause and timing.
82
+
83
+ - **A cut-off auxiliary no longer reads as confirmed clean (closes #1470)** — When
84
+ the aux grace timer cut off a scanner such as opengrep, the touch still reported
85
+ an unqualified confirmation, so a hung security scanner produced a result that
86
+ read as a clean bill of health. The confirmation is now narrowed rather than
87
+ discarded: it names the servers it does not speak for, so `lsp_diagnostics` says
88
+ which coverage is missing, the cascade no longer wipes a live finding on that
89
+ evidence, the workspace sweep stops caching a partially covered result, and the
90
+ per-edit lane stops reporting an empty result as checked — on both the
91
+ incumbent-touch route and the warm-attach route a per-edit check takes in a
92
+ live session. A primary that
93
+ answered stays trustworthy — its findings still reach you. A related gap stays
94
+ open: a scanner that answers within its budget but publishes nothing still reads
95
+ as clean (#1493).
96
+
97
+ - **A slow first probe no longer disables an installed tool (closes #1476)** —
98
+ biome, ast-grep, Go, cargo and govulncheck now tell a probe timeout apart from
99
+ a missing install, the way knip and madge already did. Before this, one stalled
100
+ second at session warm-up latched "tool is not installed" for the life of the
101
+ process, and biome and ast-grep also paid for an auto-install nobody needed. A
102
+ transient verdict now expires and the tool comes back without a restart, its
103
+ cooldown escalates on a host that stays sick, and every verdict is recorded in
104
+ `latency.log` as `availability_decision` with its cause, timing and retry
105
+ schedule. A structural coverage test parses `clients/` and fails when a new
106
+ version probe parks its verdict outside the shared policy; the hand-rolled
107
+ latches that predate it are listed in the test as named, shrink-only gaps
108
+ rather than being quietly excluded.
109
+
110
+ - **The turn-end log no longer prints `(0ms)` for a run it never timed (closes #1479)** — `(0ms)` was the string for a genuinely sub-millisecond run *and* for one nobody measured: a JSON payload with no suite timestamps, a pytest or ExUnit or PHPUnit summary the parser could not read, a runner error, or nothing run at all. A reader could not tell "measured 0" from "unmeasured", which is the confusion #1452 was reported for in the first place. `TestResult.duration` is now absent when the run was not measured — a present `0` means a real reading, because pytest does print `in 0.00s` — and the turn-end line renders `(unmeasured)` for the absent case. The agent-facing `formatResult` string is unchanged.
111
+
112
+ - **Cargo, dotnet, maven, rspec and minitest runs report a real duration (closes [#1480](https://github.com/apmantza/pi-lens/issues/1480))** — `parseGenericRunnerOutput` read an elapsed time only for go, so the seven runner families behind it reported a hardcoded `0`. Each duration is now parsed from the summary block the runner already prints, and each pattern is applied only to the runner it belongs to, so no runner can be scored by another's output — gradle used to satisfy go's probe and report the whole-build wall clock as test time. Gradle stays unmeasured: its console summary carries no test elapsed time. Multi-module maven runs are scored by the sum of their surefire aggregates rather than by a single module, a runner that exits non-zero can no longer be reported as a pass, and the pytest and ExUnit summaries are rounded to whole milliseconds instead of logging `2009.9999999999998ms`. The "was this measured at all" rule now lives in one module (`clients/run-duration.ts`) that both the turn-end log and the agent-facing summary read, rather than being re-derived at each site.
113
+
114
+ ## [4.0.0] - 2026-08-14
115
+
116
+ ### Added
117
+
118
+ - **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.
119
+
120
+ - **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.
121
+
122
+ - **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.
123
+ - **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()`.
124
+
125
+ - **tflint respects the project's linter policy (refs #1117)** — every other
126
+ dispatch runner gates on `getLinterPolicyForCwd` before spawning; tflint was
127
+ the one that never consulted it, so a project that elected a different
128
+ terraform linter got tflint's findings on top of its own. It now skips when
129
+ policy does not prefer it, matching golangci-lint and terragrunt. A project
130
+ `.tflint.hcl` is also an explicit opt-in, so `hasTflintConfig` promotes the
131
+ `.tf`/`.tfvars` policy from `smart-default` to `config-first` the same way
132
+ `.golangci.yml` does for Go. tflint ships built-in rules and still runs
133
+ unconfigured, so the no-config default is unchanged. `hasTflintConfig` takes
134
+ the edited file's directory rather than the project cwd, so it agrees with
135
+ what the runner hands tflint: keyed off cwd it would miss every `.tflint.hcl`
136
+ living in a terraform subdirectory, which is the common monorepo layout.
137
+ - **tflint honors a repo-root `.tflint.hcl` (refs #1117)** — tflint resolves
138
+ `.tflint.hcl` from its own working directory and never walks parents (its only
139
+ fallback is `~/.tflint.hcl`), and the runner spawns it from the edited file's
140
+ directory. A config at the repo root therefore governed nothing beneath the
141
+ root: every module under `modules/` or `envs/` was linted with tflint's
142
+ built-in defaults, silently. The runner now walks up for the nearest
143
+ `.tflint.hcl` (via `findNearestDirWithAnyBasename`, so the same home-guarded,
144
+ depth-capped, mtime-invalidated walk every other marker lookup uses) and
145
+ passes it as `--config`. Skipped when `TFLINT_CONFIG_FILE` is set, since
146
+ `--config` outranks the env var in tflint's own precedence and would override
147
+ a deliberate choice. A `.tflint.hcl` that declares an uninitialized plugin
148
+ makes tflint exit non-zero with empty stdout, which the guard fix above turns
149
+ into a SKIPPED rather than a false clean.
150
+ - **Terragrunt linting and formatting** — `terragrunt.hcl` and `root.hcl` now
151
+ get a dedicated `terragrunt` file kind (filename-detected only; a plain
152
+ `.hcl` file stays unmapped). New `terragrunt` dispatch runner wraps
153
+ `terragrunt hcl validate --json`, and a `terragrunt-hcl` formatter wraps
154
+ `terragrunt hcl fmt`. Terragrunt has no LSP, so it's runner/formatter only.
155
+ The `terragrunt` binary installs the same way as tflint (GitHub release,
156
+ bare per-platform binaries). Also added `.tf` (Terraform) to the
157
+ `trivy-config` runner's `appliesTo`, gated behind the existing
158
+ `trivy.enabled` opt-in — terragrunt itself stays excluded from trivy, since
159
+ trivy evaluates the Terraform language, not terragrunt config. The two
160
+ entrypoint filenames live in one exported `TERRAGRUNT_FILENAMES` constant
161
+ (`clients/file-kinds.ts`, same single-source-of-truth pattern as the .NET
162
+ root markers), consumed by kind detection, both tool policies, the formatter,
163
+ and the project/root markers; `tests/clients/terragrunt-filenames.test.ts`
164
+ derives its cases from that list, so a call site that hand-copies it and
165
+ misses a name fails CI. Diagnostic ids carry line, column, and a normalized
166
+ message slug (the actionlint shape), because `hcl validate` reports no rule
167
+ code: a line-only id collapses two findings at one position in the
168
+ dispatcher's dedupe (keyed `filePath:line:column:defectClass:rule||id`) and
169
+ hides a changed finding from delta mode, which keys on `id` alone.
170
+ Diagnostics are attributed by resolving `range.filename` against the unit
171
+ directory and comparing through `pathsEqual`, not by basename: a unit that
172
+ pulls in its parent via `find_in_parent_folders()` gets diagnostics from a
173
+ parent `terragrunt.hcl` whose basename matches the edited file's but whose
174
+ line numbers belong to another file.
175
+ - **Smells self-surfacing (refs #1123 item 3)** — `scripts/analyze-pi-lens-logs.mjs`
176
+ (`npm run logs:smells`) already catalogues a wide set of operational smells,
177
+ but it's MANUAL: the #1123 investigation found 20 stale-ctx `emit_failed`
178
+ rows and 37 opengrep respawns sitting unread in logs for days until an audit
179
+ went looking. `clients/smells-rollup.ts` adds a small ALWAYS-ON rollup
180
+ covering the two smells the issue named, without touching every producer of
181
+ the underlying logs and without re-scanning the size-rotated (up to ~10MB,
182
+ `clients/log-cleanup.ts`) `~/.pi-lens/*.log` files on the `session_start`
183
+ hot path. **Design + cost bound:** a BOUNDED TAIL READ — at most 64KB from
184
+ the end of `bus-events.log` and `latency.log` each (~128KB total I/O per
185
+ check, enforced by construction via a single sized `fs.readSync` at a
186
+ computed offset in `tailReadText`, never a full-file scan) — counts
187
+ stale-ctx `emit_failed` rows (`outcome === "emit_failed"` with an `error`
188
+ containing the SDK's `"stale after session replacement"` fragment,
189
+ `session-lifecycle.ts`'s documented benign-but-worth-watching class) and
190
+ opengrep respawns (`phase === "lsp_server_respawn"` with
191
+ `metadata.serverId === "opengrep"`, `clients/lsp/index.ts`'s existing,
192
+ unmodified respawn log point). Because both source logs are append-only
193
+ NDJSON, the tail is always the most recently written activity, so the same
194
+ bounded read serves as both the cross-session glance and a live/this-session
195
+ proxy — no separate write-time counters were added at the producer call
196
+ sites (kept the change to one new module + three call sites). Both counts
197
+ are gated by trivial threshold constants (`SMELLS_THRESHOLDS`, currently 5
198
+ each) so a single stray event never surfaces. Three surfaces: one
199
+ `session_start` line (only emitted when a threshold trips, via
200
+ `runtime-session.ts`'s `emitSmellsSessionStartLine`), an always-on compact
201
+ `/lens-health` line (current counts regardless of threshold, matching
202
+ #1123 item 2's `formatMemoryHealthLine` style), and a `turn_end` note —
203
+ re-checked every 20 turns (`shouldCheckSmellsThisTurn`), `ctx.ui.notify`d
204
+ at most ONCE per smell per session (`checkSmellsAndNoteOnce`'s gate,
205
+ re-armed by `resetSmellsSessionState()` at the next `session_start`).
206
+
207
+ New tests: `tests/clients/smells-rollup.test.ts` (tail-scan cost bound
208
+ proven by construction — a file far larger than the byte budget only ever
209
+ yields tail content; threshold gating for both the session_start line and
210
+ the always-on health line; the once-per-session notify gate) and
211
+ `tests/index-smells-rollup-wiring.test.ts` (turn_end wiring: nothing before
212
+ the check interval, one notify at the trip turn, no repeat notify on the
213
+ next check turn for an already-notified smell) — fail-then-pass verified
214
+ against a deliberately broken threshold gate.
215
+
216
+ - **Oxfmt formatting support for Svelte (refs #1134)** — `.svelte` is now a
217
+ recognized oxfmt extension, gated by a stricter conditional than oxfmt's
218
+ other extensions. Empirically verified against the real `oxfmt` npm
219
+ package (0.62.0, scratch fixture outside vitest, per this repo's
220
+ verify-the-CLI-contract-empirically rule): oxfmt requires BOTH the
221
+ `svelte` package installed AND the config's `svelte: true` flag enabled —
222
+ either alone exits non-zero ("excluded by ignore rules" or "Cannot find
223
+ module 'svelte/compiler'"), only both together format the file. The new
224
+ `hasOxfmtSvelteConfig` (`clients/tool-policy.ts`) encodes this, consulted
225
+ only for `.svelte` in `formatters.ts`'s `hasExplicitFormatterConfig` — the
226
+ other oxfmt extensions are unaffected. **Class fix**:
227
+ `oxfmtFormatter.extensions` (`clients/formatters.ts`) and
228
+ `OXFMT_SUPPORTED_EXTENSIONS` (`clients/tool-policy.ts`) were two
229
+ hand-maintained parallel lists (the #883 single-source-of-truth class);
230
+ `OXFMT_SUPPORTED_EXTENSIONS` is now exported as the sole source of truth
231
+ and `oxfmtFormatter.extensions` derives from it directly, plus a drift-guard
232
+ test asserting they stay equal. `docs/language-coverage.md`'s Svelte row now
233
+ reports the formatter and its gating condition instead of "—". The real
234
+ format-smoke fixture (`tests/fixtures/format-smoke/`) was NOT extended: no
235
+ fixture in that harness has a dependency-install step (oxfmt itself is only
236
+ found via a global `which` lookup, not `ensureTool`), so a svelte fixture
237
+ would either always skip (oxfmt unavailable) or, worse, hard-fail with a
238
+ "Cannot find module 'svelte/compiler'" error if oxfmt happened to resolve
239
+ without svelte actually installed — a strictly worse risk than omitting it.
240
+ - **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:
241
+ 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.
242
+ 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).
243
+ 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.
244
+
245
+ 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`.
246
+
247
+ - **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.
248
+
249
+ - **Source-walk generated-artifact escape hatch (closes #1107, phase 2 of 2)** — three pieces, building on phase 1's
250
+ counters (#1111):
251
+ 1. **Directory-level skip counting.** `shouldRecurseIntoDir`'s
252
+ `isGeneratedArtifactDirectoryName` branch (`clients/source-walker.ts`)
253
+ pruned whole directories (`generated/`, `codegen/`, `__generated__/`, …)
254
+ with zero counting — an entire directory of real files reported zero.
255
+ `SourceCollectionResult` gains `generatedDirSkips`, threaded through the
256
+ same `SourceWalkSkipCounters` seam and the `source_walk_skip_summary`
257
+ log line: one count per PRUNED DIRECTORY, never per file inside it
258
+ (enumerating the contents would defeat the pruning). The
259
+ `SourceCollectionResult` docblock's former "KNOWN GAP" note is now the
260
+ real field doc.
261
+ 2. **Tool-facing surfacing.** `ProjectDiagnosticsSnapshot` gains
262
+ `generatedFileSkips` (the raw skip total, all evidence tiers),
263
+ `generatedNameOnlySkips` (the narrower at-risk subset — see below), and
264
+ `generatedDirSkips` (only present, like `scanTruncated`, on a walk that
265
+ actually ran); `lens-engine.ts`'s new `generatedSkipNotice` renders a
266
+ one-line notice mirroring `scanTruncationNotice`'s (#784) style, wired
267
+ into both `pilens_project_scan` (`mcp/server.ts`) and `lens_diagnostics`
268
+ (`tools/lens-diagnostics.ts`). **Review fix (P1, empirically proven):**
269
+ the notice originally keyed off the raw `generatedFileSkips` total,
270
+ which includes STRONG evidence (lockfiles, declaration files,
271
+ minified/bundle/chunk output — expected on almost every real repo) —
272
+ a repo with just `index.ts` + an ambient `.d.ts` + `package-lock.json`
273
+ showed "2 file(s) excluded" on every single scan, forever. It now keys
274
+ off `generatedNameOnlySkips`: the narrower bucket of WEAK name matches
275
+ trusted with NO corroborating evidence check at all (only reachable
276
+ when a caller opts out of the header probe; the default project-walk
277
+ path always enables it, so this is rare-to-zero in practice — meaning
278
+ the notice is meaningful again when it does fire) plus `generatedDirSkips`
279
+ (directory pruning has no escape hatch, so it stays a genuine
280
+ unverified-content signal). Both `ProjectDiagnosticsScanOptions` and
281
+ both tool surfaces also gain `includeGenerated` (default `false`) so
282
+ the notice's opt-out advice is now actually actionable, not just prose.
283
+ `module_report` has no walk-backed section of its own to thread this
284
+ through (it reads the cached review graph, built asynchronously
285
+ elsewhere) — surfaced in the project-diagnostics path only.
286
+ 3. **Content-probe escape hatch (the actual behavior change).** A file
287
+ matching a generated-artifact NAME pattern but with no other evidence is
288
+ now KEPT instead of silently dropped. `generated-artifacts.ts` splits
289
+ its path check into STRONG evidence (a generated directory segment, a
290
+ lockfile, minified/bundle/chunk output — always conclusive, never
291
+ rescued) and WEAK evidence (the remaining filename-regex patterns, e.g.
292
+ `gen.ts`, `foo_generated.go`); `classifyGeneratedOrArtifactDetailed`
293
+ requires a WEAK match to be corroborated by a generated-code header in
294
+ the first 4 KB (or the sibling-source probe already checked upstream by
295
+ every walk-driven caller) before treating it as an artifact —
296
+ cheapest-first. With NEITHER piece of evidence, the file is KEPT and
297
+ counted under the new `generatedNameOverrides` counter (also in the
298
+ rollup log line), so the heuristic's rescues stay observable.
299
+ **Review fix (P2, maintainer decision):** minified/bundle/chunk output
300
+ (`bar.min.js`, `vendor.bundle.js`, `runtime.chunk.js`) is STRONG tier,
301
+ not WEAK — the escape hatch's evidence checks are structurally dead for
302
+ it (minifiers strip banners, so the header leg never confirms; the
303
+ sibling probe looks for `app.min.ts`, never `app.js` at a different
304
+ stem, so that leg is unreachable too), which would have made it a
305
+ PERMANENT override rather than an occasional rescue. `app.min.js` next
306
+ to `app.js` is skipped unconditionally, like a lockfile.
307
+ Shipped **ungated**: the existing `LENS_FLAGS`/`.pi-lens.json` toggle
308
+ registry is sized for whole-subsystem behavior (lsp/tests/delta/…), not
309
+ a narrow heuristic refinement, and the new `includeGenerated` scan
310
+ option (item 2) already gives callers a full, actionable opt-out.
311
+ Documented tradeoff: a false-KEEP (one extra file an agent must judge)
312
+ is preferred over the prior silent false-DROP (a real file invisibly
313
+ never analyzed) — this changes walk output for review-graph/word-index/
314
+ project-diagnostics/call-graph: a repo with a real `gen.ts` gains
315
+ coverage (intended), and a repo with a headerless generated file with
316
+ no source twin also gains coverage (accepted per the issue). Invariant
317
+ preserved for lockfiles, declaration files, minified/bundle/chunk
318
+ output, and anything the ignore-matcher/extension filter already
319
+ excluded — none of those are ever rescued.
320
+
321
+ - **Machine-wide test-suite lock (closes #1101)** — `npm test` /
322
+ `npm run test:unit` / `npm run test:integration` now route through the new
323
+ `scripts/with-test-lock.mjs` wrapper, which acquires a single machine-wide
324
+ `~/.pi-lens/test-suite.lock` before running vitest and releases it after.
325
+ Concurrent full-suite runs (several agents on parallel worktrees, plus an
326
+ interactive run) previously each spawned a fork pool sized for a dedicated
327
+ machine and fought over CPU/RAM, producing vitest worker-crash cascades and
328
+ timing-budget flakes that looked like real bugs but weren't; they now
329
+ serialize (queue) instead. A waiting run prints a heartbeat line at least
330
+ every 15s so it never looks hung, and — mirroring the installer's
331
+ `.install.lock` — a lock is stale only after its recorded PID is confirmed
332
+ dead, never on a timer alone. `PI_LENS_TEST_NO_LOCK=1` opts out (CI sets it:
333
+ runners are isolated, nothing to serialize against). Lock core lives in
334
+ `scripts/lib/suite-lock.mjs`, unit-tested for acquire/release, dead-PID
335
+ takeover, and real contention (two concurrent acquisitions serialize).
336
+ AGENTS.md documents the lock plus the companion layer-1 policy for agents
337
+ running tests concurrently.
338
+ - **Source-walk skip observability (refs #1107, phase 1 of 2)** — the source
339
+ walk silently dropped real files whose NAMES match generated-artifact
340
+ heuristics (a real `src/gen.ts` was invisible to
341
+ `collectSourceFilesWithBudgetAsync`, with nothing anywhere saying so), an
342
+ invisible coverage hole for review-graph/word-index/project-diagnostics/
343
+ call-graph. This phase adds observability only — it does NOT change which
344
+ files get skipped (a content-probe escape hatch for name-only matches is
345
+ phase 2, tracked on #1107). `SourceCollectionResult` gains two additive
346
+ optional counters, `generatedOrArtifactSkips` (files dropped by the
347
+ generated/artifact NAME heuristic, `isGeneratedOrArtifact`) and
348
+ `buildArtifactSkips` (files dropped by the sibling-source artifact probe,
349
+ `isBuildArtifact`) — separate from extension-filter and ignore-matcher
350
+ skips, which are policy/config driven rather than name/content heuristics.
351
+ Both `collectSourceFilesWithBudget` and `collectSourceFilesWithBudgetAsync`
352
+ (and therefore every consumer that funnels through them — the review-graph
353
+ builder, project-diagnostics scanner, word index, etc.) populate the
354
+ counters and log a one-line rollup through the existing `logLatency`
355
+ channel (`phase: "source_walk_skip_summary"`) whenever either is nonzero; a
356
+ clean walk with no name/artifact-probe skips emits no new log line.
357
+ `tests/clients/graph-cache.test.ts`'s two remaining `gen.ts` fixtures
358
+ (unrelated to the drift-path test, which already used `alpha.ts`) were
359
+ unknowingly exercising empty-walk graph builds — renamed to
360
+ `buildstamp.ts`/`identitycheck.ts` since their assertions only cover
361
+ buildGeneration/cache-mode plumbing, never node/symbol content, so the
362
+ rename is behavior-preserving.
363
+
364
+ - **LSP diagnostics content binding (refs #1095, first PR)** — diagnostics are now
365
+ bound to the document content they were computed against, so a consumer can ask
366
+ "were these diagnostics computed against what's on disk now?" instead of inferring
367
+ staleness purely from mtime/TTL proxies. When a `publishDiagnostics` notification
368
+ echoes a document `version`, the owning client binds the stored diagnostics to a
369
+ content fingerprint captured at didOpen/didChange SEND time (never a disk read on
370
+ the notification path). `getAllDiagnostics`/`touchFile` results and the
371
+ workspace-diagnostics cache lookup expose a `binding`
372
+ `{version?, contentHash?, boundToCurrentDisk: boolean | "unknown"}`, with
373
+ `boundToCurrentDisk` verified lazily against disk (memoized per file+mtime, reading
374
+ with the identical raw-UTF-8 transform the payload was built with, so CRLF/BOM files
375
+ round-trip correctly). Across multiple contributing clients the merged binding is
376
+ `false` if ANY contributor mismatches disk, `"unknown"` if all are unknown, else
377
+ `true`. `tools/lsp-diagnostics.ts` adopts it: a result whose binding demonstrably
378
+ mismatches disk (`boundToCurrentDisk === false`) is demoted to inconclusive — even a
379
+ non-empty result no longer re-cements the footer with a stale view (the #1092
380
+ re-cementing path). Servers that never report a version yield binding `"unknown"` and
381
+ behave exactly as before (zero regression). Binding state (`bound`/`mismatch`/
382
+ `unknown`) is logged in the `lsp_touch_file` latency metadata so an unbinding server
383
+ is diagnosable. Non-goals (stated for follow-ups): cascade adoption in
384
+ `clients/dispatch/integration.ts` (#1094 in flight), pull-diagnostics resultIds,
385
+ widget per-entry timestamps (#1093), aux retag (#1094).
386
+ - **Cascade adopts LSP diagnostics content binding (refs #1095, second PR)** — the
387
+ cross-file cascade (`clients/dispatch/integration.ts`) now consults the `binding`
388
+ from the first PR instead of trusting a snapshot purely on TTL. A passive neighbor
389
+ snapshot whose diagnostics `boundToCurrentDisk === false` (the server's view diverged
390
+ from disk — e.g. the PRE-fix content) is no longer reconciled into the footer widget;
391
+ it falls through to an active touch on the existing cold-snapshot budget. This kills
392
+ the window where the first cascade after a fix-edit replayed a neighbor's stale
393
+ pre-fix snapshot. `"unknown"` bindings (version-less servers) keep EXACTLY the prior
394
+ TTL-only behavior; `true` reconciles (TTL stays the outer bound). On the active-touch
395
+ path, a bound-false result is treated like `inconclusive` — composed with it into one
396
+ `isConfirmedTouch()` predicate so a future flag can't be missed at just one gate — so
397
+ it neither reconciles nor seeds the recently-clean neighbor cache. `observedAt` stays
398
+ the snapshot's publish time (`entry.ts`) for passive reconciles. Binding-rejected
399
+ snapshots/touches are logged through the cascade channel (`bindingState`). The lazy
400
+ binding getter is re-read from a fresh `getAllDiagnostics()` every cascade run (the
401
+ Map is never retained across turns), with a caveat comment guarding future retention.
402
+ - **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.
403
+ Filtering is output-only, so the baseline, widget state, and dedup cache stay
404
+ authoritative and a policy edit never corrupts or resets delta tracking.
405
+ Matching is project-wide: the `<id>` key is a grouping label, not a filter
406
+ scope. Every `disable` list across every key unions into one drop list, and
407
+ every `select` list unions into one allowlist, so
408
+ `"security": { "disable": ["no-eval"] }` works without `security` being a real
409
+ rule id. Disable wins over select project-wide, even across different keys.
410
+ A non-empty `select` anywhere silences every rule not listed, project-wide, so
411
+ it is a big hammer (documented as such). Findings that carry no rule id at all
412
+ are exempt from select, since `Diagnostic.id` is a dedup key rather than a rule
413
+ id and measuring it against an allowlist would have silently dropped blocking
414
+ findings like eslint parse errors. A user lists `no-eval` once and the filter
415
+ covers `no-eval`, `ast-grep:no-eval`, and `no-eval-js`, reusing the rule-id
416
+ normalization that `inline-suppressions.ts` and `tools/lens-diagnostics.ts`'s
417
+ dedup already applied, now consolidated in
418
+ `clients/dispatch/rule-id-normalize.ts` so the three surfaces cannot drift.
419
+ That normalization conflates a rule whose real name ends in `-js` with its stem
420
+ (`prefer-js` with `prefer`), which is now pinned by tests and called out in the
421
+ docs. The policy applies on the per-edit dispatch path
422
+ (`clients/dispatch/dispatcher.ts`, resolved from the project root so a
423
+ package-local `.pi-lens.json` in a monorepo cannot shadow the root's policy,
424
+ matching the root `lens_diagnostics` already used) and across the
425
+ `lens_diagnostics` tool's `mode=delta`, `mode=all`, and `mode=full` paths. Each
426
+ loads the policy map once per call, filters after inline suppression and
427
+ disposition, and re-summarizes so the blocking/error/warning counts reflect the
428
+ drop, including delta mode's carried-over tally and mode=full's structured
429
+ `details` counts. Silencing a rule is not the same as fixing it: the stored
430
+ delta baseline is deliberately unfiltered, so dispatch compares against a
431
+ policy-filtered view of it. Otherwise every disabled finding would sit in the
432
+ "fixed" set on every single dispatch and inflate the agent's resolved tally
433
+ (`trackAgentFixed`) for as long as the rule stayed off. In the project-config
434
+ loader (`clients/project-lens-config.ts`) the lists are tolerantly parsed: a
435
+ non-array, an empty array, or an all-whitespace array is logged once and
436
+ dropped, matching the existing threshold-validation warn-once contract.
437
+ Threshold-only entries are unaffected, and policy-only entries (no `threshold`)
438
+ coexist cleanly. A `rules.<key>` whose value is not an object, or whose keys are
439
+ all unrecognized, now warns instead of failing silent. That covers the two
440
+ shapes a user is most likely to reach for first, both of which parse as valid
441
+ JSON and do nothing: the lists written directly under `rules`, and `only`
442
+ instead of `select`. New `tests/clients/dispatch/rule-policy.test.ts` (32 tests:
443
+ matcher, normalization, project-wide grouping, disable-wins-select, the `-js`
444
+ conflation, the rule-less select exemption, and the hot-path fast return),
445
+ `tests/clients/dispatch/rule-policy-dispatcher.test.ts` (13 tests covering
446
+ dispatch integration including monorepo root-versus-nested config resolution,
447
+ baseline integrity, and the resolved-tally guard),
448
+ `tests/clients/dispatch/rule-id-normalize.test.ts` (7 tests), and
449
+ `tests/tools/lens-diagnostics-rule-policy.test.ts` (14 tests covering the three
450
+ mode paths, `select` on the cache-only paths, the delta carried-over tally, the
451
+ project-diagnostics delta report, and mode=full's structured details).
452
+ Docs: `docs/settings.md` and `docs/globalconfig.md` gained the project-wide
453
+ schema, precedence, the select warning, and the `-js` caveat. #533 hygiene is
454
+ preserved, so typo and unknown-key warnings still surface and foreign LSP
455
+ namespaces stay silent.
456
+
457
+ - **Document the standing maintenance-routines menu (invoke-on-request)** — catalog named maintenance sweeps and their safety rails in `AGENTS.md`.
458
+
459
+ - **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.
460
+
461
+ - **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.
462
+
463
+ - **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`.
464
+
465
+ - **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.
466
+
467
+ - **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.
468
+
469
+ - **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.
470
+
471
+ - **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.
472
+
473
+ - **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.
474
+ - **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.
475
+ - **Per-rule configuration controls (closes [#444](https://github.com/apmantza/pi-lens/issues/444))** — enable or disable individual rules through `.pi-lens.json`.
476
+ - **Edit-batch read-guard observability (refs [#1053](https://github.com/apmantza/pi-lens/issues/1053))** — expose batch-level read-guard activity for clearer diagnostics.
477
+ - **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.
478
+ ### Changed
479
+
480
+ - **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.
481
+
482
+ - **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.
483
+
484
+ - **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.
485
+
486
+ - **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.
487
+
488
+ - **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.
489
+
490
+ - 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.
491
+
492
+ - **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.
493
+
494
+ - **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.
495
+ - **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.
496
+
497
+ - Document red-first regression-test and worktree-hygiene conventions
498
+
499
+ - **Adopt commit-message and doc/prose (Google + STE) writing conventions**
500
+
501
+ - **Refresh README/docs for v4 and update contributors** — reconciled the
502
+ README feature list and `docs/` reference against current code: documented
503
+ the four memory idle-eviction env vars (`PI_LENS_TS_IDLE_EVICT_MS`,
504
+ `PI_LENS_WORD_INDEX_IDLE_EVICT_MS`, `PI_LENS_PROJECT_SNAPSHOT_IDLE_EVICT_MS`,
505
+ `PI_LENS_REVIEW_GRAPH_IDLE_EVICT_MS`), the session degradation ledger
506
+ surfaced through `pilens_health`/`/lens-health`, deferred-format bounded
507
+ concurrency, and the `--lens-guard` commit/push blocker; linked
508
+ `docs/mcp.md` from the README and added an MCP-server bullet. Added six
509
+ external contributors (Nathan Cooke, Eli Stark, Marvin Aziz, Mark Faga,
510
+ aeturnal, floatGray) to the README contributors table, sourced from
511
+ `git shortlog` and merged-PR authorship.
512
+
513
+ - **Dogfood the shape-2 path-semantics rule (refs #1158)** — Flag host-default path operations inside branches committed to Windows path semantics.
514
+
515
+ - **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.
516
+
517
+ - **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.
518
+
519
+ - **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.
520
+
521
+ - **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.
522
+
523
+ - **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.
524
+
525
+ - **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.
526
+
527
+ - **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.
528
+
529
+ - **Warm dispatch, LSP async consumers, and formatter catalog lazily at session start (refs #1394 — Phase 2).**
530
+
531
+ ### Removed
532
+
533
+ - **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.
534
+
11
535
  ### Fixed
12
536
 
537
+ - **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.
538
+
539
+ - **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.
540
+
541
+ - **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.
542
+
543
+ - **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.
544
+
545
+ - **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.
546
+
547
+ - **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.
548
+
549
+ - **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.
550
+
551
+ - **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.
552
+
553
+ - **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.
554
+
555
+ - **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.
556
+
557
+ - **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.
558
+
559
+ - **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.
560
+
561
+ - **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.
562
+
563
+ - **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).
564
+
565
+ - **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.
566
+
567
+ - **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.
568
+
569
+ - **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.
570
+
571
+ - **`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.
572
+
573
+ - **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.
574
+ - `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.
575
+ - 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.
576
+ - 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.
577
+ - `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.
578
+ - 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.
579
+
580
+ - **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.
581
+
582
+ - **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.
583
+
584
+ - **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).
585
+ - **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).
586
+ - **`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.
587
+
588
+ - **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.
589
+ - **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.
590
+
591
+ - **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.
592
+
593
+ - **`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.
594
+
595
+ - **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.
596
+
597
+ - **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.
598
+
599
+ - **`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.
600
+ - **`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.
601
+
602
+ - **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)** —
603
+ the #1108 audit found all five side-channel flags safe as-is (every consumer
604
+ reads off the original), but the three LSP diagnostics flags stayed
605
+ fragile-by-construction: a non-enumerable property hung on the returned
606
+ diagnostics array, silently dropped by any `[...]`/`.map`/`.filter`/
607
+ `structuredClone`/`JSON` copy between producer and consumer (the class that bit
608
+ as #1094 `inconclusive` and #1096 `binding`). `touchFile` now resolves an
609
+ explicit `TouchFileResult` wrapper (`clients/lsp/diagnostic-binding.ts`) with
610
+ `inconclusive` and `binding` as ENUMERABLE fields alongside `.diags`, so a copy
611
+ operates on `.diags` and can no longer drop them — the copy-loss is impossible
612
+ by construction. Every flag-reading consumer was migrated in lockstep (the
613
+ cascade `readInconclusive`/`readBoundToCurrentDisk`/`isConfirmedTouch` and its
614
+ `.filter()` site in `clients/dispatch/integration.ts`, the dispatch LSP runner,
615
+ the `lsp_diagnostics` tool, the workspace-sweep `lens_diagnostics mode=full`
616
+ path, and the warm-attach IPC producer — which continues to re-surface
617
+ `inconclusive` as an enumerable DTO field over the socket). Behavior is
618
+ IDENTICAL — same flags, same values, same decisions; only the carriage
619
+ changed. `getAllDiagnostics`'s per-entry `binding` is DELIBERATELY left as a
620
+ lazy non-enumerable getter on the documented read-off-original contract (making
621
+ it enumerable would fire the per-file disk stat+hash on any incidental spread —
622
+ the stat storm its laziness exists to prevent). Separately closes a latent P3
623
+ from the #1108 review: `getGraphBuildInfoForGraph`'s global-slot fallback could
624
+ serve a SIBLING graph's build-info on a `_graphBuildInfoByGraph` identity miss,
625
+ feeding the `graph_degraded` marker gate a (possibly healthy) sibling verdict —
626
+ a new `graphBuildInfoIsTrustworthy` guard fails CLOSED so an unstamped/rehydrated
627
+ graph surfaces an honest degraded/unknown advisory instead of a #533 false
628
+ clean. Inert on the live path (every build stamps its returned graph before the
629
+ cascade reads it). An ast-grep rule remains not viable (AGENTS.md shape 5 is
630
+ semantic; cross-ref #1158).
631
+ - **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)** —
632
+ `reconcileCascadeNeighborLspErrors` → `commitDiagnostics` used to stamp the
633
+ ENTIRE merged record's single `touchedAt` with the incoming `observedAt`
634
+ (e.g. a passive snapshot's `entry.ts`, up to ~240s old), including PRESERVED
635
+ entries observed more recently. If the neighbor's mtime later fell between
636
+ that stale stamp and a preserved entry's true observation time,
637
+ `reconcileStaleWidgetFiles` dropped the WHOLE record — losing the newer
638
+ preserved findings (the residual documented at `clients/dispatch/integration.ts`).
639
+ `WidgetDiagnostic` now carries a per-ENTRY `observedAt`: `normalizeDiagnostics`
640
+ stamps each incoming entry at its observation time, the cascade merge keeps
641
+ each preserved entry's own prior stamp (never re-aging a fresh finding to the
642
+ incoming stamp), and `reconcileStaleWidgetFiles` gates per ENTRY — dropping
643
+ only entries observed before the file's current mtime, keeping the record when
644
+ any survive, and dropping the record only when empty (a clean, finding-less
645
+ record still gates on `touchedAt`). `PersistedWidgetState` bumps v1→v2:
646
+ `importWidgetState` accepts a v1 snapshot and migrates each stampless entry to
647
+ inherit the record's `touchedAt` (a safe, over-conservative default; a
648
+ missing/non-numeric version or a future version this build can't understand is
649
+ still rejected — the pre-existing strictness is preserved). The sibling gates
650
+ `dropStaleFiles`
651
+ (session-state-store) and `reconcileProjectDiagnosticsSnapshot` are unchanged
652
+ — both compare against a single save/scan time ≥ every entry's observation, so
653
+ a whole-record drop there already equals dropping every entry. The
654
+ `markDependentsUnverified`/seq-stamp half of #1093's sketch is a separate,
655
+ non-contained follow-up (left open under #1093).
656
+ - **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.
657
+ - **`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)** —
658
+ `package.json`'s `@ast-grep/cli` dependency range is `"^0.45.0"`, but the
659
+ `allowScripts` map still keyed its approval on `"@ast-grep/cli@0.44.1"` — a
660
+ version the range can no longer even resolve to. pi-lens has no
661
+ `allowScripts`-enforcing tool of its own (no `@lavamoat/allow-scripts`, no
662
+ `.npmrc` script policy), so the field does nothing for pi-lens's own
663
+ `npm ci`; its only audience is an external consumer or vetting tool reading
664
+ the *published* package. The published package ships **no lockfile**
665
+ (`package-lock.json` is not in the `files` array and there's no
666
+ `npm-shrinkwrap.json`), so that consumer fresh-resolves `"^0.45.0"` against
667
+ the registry — which lands on `0.45.1` (registry `latest`; only `0.45.0`
668
+ and `0.45.1` exist in the `0.45.x` line), exactly what the original report
669
+ observed. The correct target is therefore the **freshest in-range /
670
+ fresh-resolve version**, not the locally-committed lockfile's resolution —
671
+ so the entry is `"@ast-grep/cli@0.45.1": true`, and the committed
672
+ `package-lock.json` was also bumped (`npm install @ast-grep/cli@0.45.1`,
673
+ an in-range patch bump — the `^0.45.0` dependency range is unchanged) so
674
+ pi-lens's own dev tree and a fresh consumer resolve both land on the same
675
+ version. `0.45.1` ships the same `postinstall` script `0.44.1` needed
676
+ approval for. Swept every other `allowScripts` member for the same drift
677
+ class (a pinned exact version no longer matching the freshest version its
678
+ governing range resolves to): `@google/genai@1.52.0` (transitive via the
679
+ `@earendil-works/pi-coding-agent` devDependency) already matched, unchanged;
680
+ `protobufjs@7.6.4` (also transitive via `@earendil-works/pi-coding-agent`)
681
+ had drifted the same way and moved to `"protobufjs@7.6.5": true` — the
682
+ freshest in-range version, which here happens to match both the lockfile
683
+ and a fresh resolve. `@ast-grep/napi` (also range `^0.45.0`) was re-checked
684
+ for an install-time script at both `0.45.0` and `0.45.1` (`npm view
685
+ @ast-grep/napi scripts`) and has none in either, so it correctly has no
686
+ `allowScripts` entry. `fsevents@2.3.3` also has an install script
687
+ (`hasInstallScript: true`) but is intentionally NOT listed: it's
688
+ `dev:true, optional:true, os:["darwin"]`, so it's never part of a
689
+ consumer's production install and out of scope for this allowlist — noting
690
+ it here rather than leaving the omission silent. No other version-pinned
691
+ metadata (`overrides`/`resolutions`/`pnpm.overrides`/`packageManager`)
692
+ exists in `package.json` to drift, and a repo-wide grep for hardcoded
693
+ `0.44.`/`0.45.` ast-grep version literals outside `package.json`/the
694
+ lockfile turned up only historical comments in test files and one rule
695
+ doc-comment describing ast-grep's behavior as of a past version — not pins
696
+ that need to track the current range.
697
+ - **`switch-case-termination` false positives for returning try/catch and exhaustive conditionals (closes #1079)** — the `no_terminating_statement`
698
+ post-filter only checked whether a case's last statement was a literal
699
+ terminator, so a case ending in `try { return … } catch { return … }` (or an
700
+ exhaustive `if/else`) was flagged even though every path returns. It now
701
+ runs a bounded, fail-safe control-flow analysis: trailing blocks,
702
+ `try/catch/finally` (a returning try completes after finally; a try body
703
+ that completes normally only terminates via a finally), and exhaustive
704
+ `if/else` (no else still falls through). It also honors an intentional
705
+ `// fallthrough` marker comment (ESLint `no-fallthrough` convention),
706
+ checked against comment nodes only so a `case "fallthrough":` value can't
707
+ suppress a finding. Genuine fall-throughs are still reported. TS + JS
708
+ regression fixtures cover grouped labels, try/finally, and intentional
709
+ fallthrough.
710
+ - **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).
711
+ - **LSP runtime-exit breaker missed persistent low-frequency crash loops (closes #1142, refs #1127 #1139)** —
712
+ #1139's `runtimeExitCounts` counts a client death toward the circuit breaker
713
+ only when its lifetime (`exitedAt - spawnedAt`) is under
714
+ `RUNTIME_EXIT_UPTIME_THRESHOLD_MS` (60s) — the hot crash loop (opengrep dies in
715
+ seconds). A death OVER the threshold falls into the "survived → reset the
716
+ streak" branch, so a server that reliably dies at ~65-90s after every spawn
717
+ NEVER tripped: every death reset the streak and it churned forever, a slow loop
718
+ the hard-cutoff design structurally can't detect. Added a SECOND, independent
719
+ windowed-rate trip that COMPOSES with (does not replace) the fast path:
720
+ `RUNTIME_EXIT_WINDOW_TRIP_COUNT` (5, = `BROKEN_PERMANENT_AFTER`) non-intentional
721
+ deaths within a rolling `RUNTIME_EXIT_WINDOW_MS` (15 min) window trip the
722
+ breaker regardless of each death's individual lifetime. N/M err against
723
+ false-tripping: five clustered non-intentional deaths is definitionally a loop,
724
+ and sparse benign crashes age out of the rolling window before five coincide.
725
+ The fast path is byte-for-byte unchanged — a hot loop still trips at exactly 5
726
+ consecutive early exits, never delayed by the window. Benign over-threshold
727
+ "deaths" are excluded: intentional teardowns (user restart, config/workspace
728
+ reload, session change, #743 eviction, generation handoffs) are gated out by the
729
+ existing `!wasShutdownIntentional()` guard the windowed trip shares, and a
730
+ death whose lifetime exceeds a 10-min ceiling — a genuinely long healthy run, or
731
+ an `exitedAt - spawnedAt` inflated across a machine-sleep/Modern-Standby suspend
732
+ (the #1122/#1139 death-timestamp lesson) — is not recorded, backed structurally
733
+ by per-server-key windowing (one suspend kills at most one live client per key).
734
+ The death-timestamp window is bounded on BOTH axes (prune aged-out entries +
735
+ hard-cap the array at the trip count, drop-oldest) and needs no timer (ages by
736
+ prune-on-check). Fail-then-pass regression tests cover all three cases: the slow
737
+ ~65-90s loop now converges (verified failing on pre-fix — the whole bug), the
738
+ hot loop still trips fast (no regression), and a sleep-gap/long-run death,
739
+ sparse aged-out crashes, and deliberate restarts do NOT trip.
740
+ - **Closed out the three #1134 P3 tails left after #1138's Svelte oxfmt support (closes #1134)** —
741
+ 1. **TOML line-match heuristic limits.** `hasOxfmtSvelteConfig`'s
742
+ `OXFMT_SVELTE_TOML_TRUE` regex required `svelte = true` to be
743
+ immediately followed by end-of-line/end-of-file, so a trailing inline
744
+ comment (`svelte = true # enable`) false-negatived. Relaxed the
745
+ trailing match to tolerate an optional `#`-comment, and added a
746
+ docblock sentence documenting the heuristic's remaining known limits
747
+ (it is a line match, not a TOML parser — a `[table]`-sectioned or
748
+ multi-line-string `svelte = true` occurrence could still false-positive;
749
+ accepted since that only causes oxfmt to be offered, never a silent
750
+ formatter failure). Added a fail-then-pass regression test for the
751
+ `svelte = true # comment` case (`tests/clients/tool-policy.test.ts`).
752
+ 2. **Monorepo asymmetry.** `hasOxfmtSvelteConfig`'s svelte-dependency check
753
+ stops at the nearest `package.json` (`hasNearestPackageJsonDependency`)
754
+ while its config walk goes all the way to the repo root
755
+ (`walkUpDirs`) — a root-level `svelte` dependency with a sub-package
756
+ `cwd` under-offers oxfmt for `.svelte` there. Documented via a
757
+ docblock sentence (no behavior change: this cell fails safe —
758
+ under-offering, never mis-offering a formatter that then errors at
759
+ runtime — and stays untested by design, matching the async
760
+ `.tflint.hcl` nearest-vs-root-walk note already in this file).
761
+ 3. **docs/language-coverage.md's Vue row** showed formatter `—` though
762
+ `.vue` has been wired to `prettier` (default) + `oxfmt` (appended via
763
+ `OXFMT_SUPPORTED_EXTENSIONS`) since #1138 landed. Corrected the row to
764
+ `prettier, oxfmt`.
765
+ - **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)** —
766
+ `clients/cache/rule-cache.ts`'s `computeRuleHash` fingerprinted every
767
+ effective rule file over METADATA only (`mtimeMs:size`) — the review-graph
768
+ first-filter without its content-hash CONFIRM step. A rule-file edit
769
+ preserving both mtime and byte size (git-checkout timestamp restoration, a
770
+ same-length tweak, a formatter that preserves mtime) replayed a stale
771
+ compiled rule set from `<language>-rules-v6.json`, and because this is a
772
+ PERSISTED disk cache, the stale set was re-persisted under the
773
+ fresh-looking fingerprint, poisoning every future process (the #878
774
+ `force`-reload failure mode, one axis over). Fixed by splitting the
775
+ fingerprint on rule-file origin, mirroring the project/bundled split
776
+ `yaml-rule-parser.ts`/`ast-grep-napi.ts` already use for ast-grep rules
777
+ (#1105): the small, mutable PROJECT-LOCAL subset (under
778
+ `<project>/rules/tree-sitter-queries/`) now gets a content-hash CONFIRM on
779
+ top of the metadata fingerprint; the ~705 BUNDLED files (immutable within a
780
+ process) stay metadata-only, so `RuleCache.get`'s per-edit tree-sitter
781
+ runner hot-path cost is unchanged when a project has no rule overrides.
782
+ `CACHE_VERSION` bumped v6→v7 so a v6 entry persisted under the old
783
+ metadata-only formula (a potentially poisoned one) misses once on upgrade
784
+ instead of being trusted. Fail-then-pass regression tests pin a rule
785
+ file's mtime via `utimesSync`, edit its content while preserving byte size,
786
+ and assert the cache invalidates for project-local files but still hits
787
+ for bundled-only rule sets; a third test confirms a persisted
788
+ metadata-only-fingerprinted entry is rejected post-fix.
789
+ - **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)** —
790
+ `clients/formatters.ts` (formatter → `extensions[]`) and `clients/tool-policy.ts`'s
791
+ `FORMATTER_POLICY_BY_EXTENSION` (extension → `formatterNames[]`) are hand-maintained
792
+ INVERSE mappings of the same relation with no test binding the two directions — the
793
+ #883/#209 single-source-of-truth class, and the general shape under #1134's oxfmt/.svelte
794
+ gap. Added `tests/clients/formatter-policy-consistency.test.ts`, which imports the real
795
+ `ALL_FORMATTERS` definitions and the policy maps and asserts both directions: every
796
+ definition extension is policy-included / a documented deliberate exclusion / a documented
797
+ no-policy fallback (so a definition gaining a policy-gated extension can't be silently
798
+ never-offered — #1134's exact symptom); and every policy `formatterName`/`defaultFormatter`
799
+ is a real formatter whose definition claims that extension or filename (so a broken option
800
+ can't be offered — including the terragrunt-hcl filename-keyed variant). Fixed two real
801
+ latent drifts surfaced by the guard, both behavior-identical (the mismatched entries were
802
+ already inert): the `.sass` policy listed `oxfmt` though oxfmt does not support `.sass`
803
+ (absent from `OXFMT_SUPPORTED_EXTENSIONS`), and the `.fish` formatter policy named
804
+ `fish-indent`, which has no `FormatterInfo` (it is a lint runner) — a dead, unsatisfiable
805
+ entry, now removed. A structural derive (tool-policy importing the definitions) was rejected
806
+ because `formatters.ts` already imports `tool-policy.ts`, so the reverse edge would create a
807
+ module import cycle; the test-based guard binds both directions while keeping the dependency
808
+ one-way. Also bound `AUTO_INSTALLABLE_DEFAULT_FORMATTERS` keys to real formatter definitions
809
+ (same formatter-name-reference class). The guard reads four newly-exported read-only symbols
810
+ (`ALL_FORMATTERS` plus the three policy maps `FORMATTER_POLICY_BY_EXTENSION`,
811
+ `FORMATTER_POLICY_BY_FILENAME`, `AUTO_INSTALLABLE_DEFAULT_FORMATTERS`), and its own two
812
+ allowlists (deliberate exclusions, no-policy fallbacks) each carry a minimality check so
813
+ they cannot rot into blanket escape hatches.
814
+ - **Pull-diagnostics entries never bound to document content, so they never demoted (closes #1104, refs #1095 #1096 #1100)** —
815
+ #1096 bound LSP diagnostics to a content fingerprint on the PUSH path
816
+ (`publishDiagnostics` version echo + send-time hash); the PULL path
817
+ (`textDocument/diagnostic`, `workspace/diagnostic`) recorded no fingerprint
818
+ at all, so pull-served cache entries read binding `"unknown"` forever and
819
+ the #1096 P2-1 service-sweep binding gate could never protect them. Fixed by
820
+ threading the server's `resultId` and a request-time content hash through
821
+ both pull requests: a `"full"` report is fingerprinted (the single-file path
822
+ reuses the exact sent-content hash `recordSentContent` already captures on
823
+ every didOpen/didChange — no extra read; the project-wide
824
+ `workspace/diagnostic` pull hashes disk bytes at request time since files
825
+ may not yet be open when it fires), and an `"unchanged"` report (now
826
+ requested via `previousResultId`/`previousResultIds`) inherits the prior
827
+ pull's diagnostics AND binding instead of being misread as a confirmed-clean
828
+ `[]` (the #570/#571 false-clean shape). The workspace pull-sweep record site
829
+ (`clients/lsp/index.ts`) now threads this `contentHash` into
830
+ `workspaceDiagnosticsCacheCtx.record()`, closing the gap the site's own
831
+ `#1095` doc comment used to document as intentional. Also closed an
832
+ AGENTS.md shape-5 gap in the sibling per-file touch path: `processFile` read
833
+ the `#1095` binding off the RAW `touchFile` diagnostics array before
834
+ `applyAuxiliarySuppressions`' `.filter()` rebuilds it (a `.filter()` copy
835
+ does not carry a source array's non-enumerable `.binding`), and
836
+ `LSPService.mergeBinding` no longer gates a contributor's `contentHash` on
837
+ that SAME contributor also carrying a `version` — pull bindings
838
+ legitimately carry a hash with no version, and the old gate silently
839
+ dropped it. Folded in two #1100-review P3s on the same surface: the
840
+ cascade's degraded/fallback display paths and the `lsp_binding_rejected`
841
+ indeterminate-advisory preamble (`clients/runtime-turn.ts`), which
842
+ previously reused the graph-unavailable wording even when the review graph
843
+ was fine and only the LSP display was withheld, now use a
844
+ binding-rejection-specific frame. Fail-then-pass regression tests cover:
845
+ the pull-sweep record site actually receiving a `contentHash`; a
846
+ pull-recorded entry whose content changed under a matching mtime being
847
+ demoted (not replayed) on the next sweep; the per-file touch path's binding
848
+ surviving the suppression filter; and the `"unchanged"`-report inheritance
849
+ on both the single-file and workspace pull protocols.
850
+ - **Config caches gated freshness on mtime alone — the mtime-only cache-freshness class sweep (closes #1105)** —
851
+ Completed the shape-6 (freshness stamp that doesn't cover the data's real
852
+ dependency) sweep the #1092→#1119 diagnostics/word-index arc deferred. Audited
853
+ every persisted/derived cache with an mtime/TTL key: word-index (#1119),
854
+ `rule-cache.ts` (`mtime:size`), `sgconfig.ts` (content-hashed), the
855
+ `yaml-rule-parser` project/bundled split (project rules content-hashed via
856
+ `loadYamlRulesFresh`, bundled dir-mtime is safe by process-lifetime
857
+ immutability), `reverse-deps` (snapshot-seq/generation-coupled), installer
858
+ `probe-cache` (existence re-validated every read + ast-grep version-family
859
+ verify — mtime is a refresh hint, not the correctness gate), `TreeCache`
860
+ (content-hash authoritative, #890), and the project-snapshot per-file
861
+ `mtime:size` entries — all already-hardened or safe. The one gap: the
862
+ `.pi-lens.json` config caches gated reuse on the config file's **mtime alone**,
863
+ so an in-place edit that preserved mtime (git checkout timestamp restoration, a
864
+ same-second rewrite) but changed the file's byte length replayed a stale parsed
865
+ config / ignore matcher — a config that drives mutation, ignore, and rule
866
+ policy. This spanned every freshness gate in the two files: the root parsed-
867
+ config caches (`loadPiLensProjectConfig`/`loadPiLensConfigInDir` in
868
+ `clients/project-lens-config.ts`), the root ignore-matcher cache, and — the
869
+ member found in adversarial review — the **nested `.pi-lens.json`/`.gitignore`
870
+ layering cache** (`patternsForDir`, #783) in `clients/file-utils.ts`, which
871
+ short-circuits AHEAD of the root caches so a preserved-mtime, length-changing
872
+ edit to a NESTED config replayed stale patterns for that subtree. Fixed by
873
+ adding **`size` as the free second axis** of the review-graph `size:mtimeMs`
874
+ signature to every gate — the root `.pi-lens.json` (threaded through
875
+ `PiLensConfigMarker` in `clients/workspace-topology.ts`), the root `.gitignore`
876
+ and global `~/.pi-lens/config.json` (via a shared `fileFreshnessSignature`
877
+ helper), and both the nested `.pi-lens.json` and nested `.gitignore` axes. The
878
+ same stat that yields mtime already reads size, so the cheap hit path stays
879
+ cheap (no content hashing on the hot path). The residual (identical mtime AND
880
+ identical size, changed content) matches the review-graph/word-index accepted
881
+ residual by design. Fail-then-pass regression tests cover the root and nested
882
+ gates (each proven to replay stale on the pre-fix mtime-only code); FS-agnostic
883
+ (mtime pinned via `utimesSync`, size varied by content length) so they exercise
884
+ the gates identically on Linux CI.
885
+
886
+ - **Windows drive-root & autofix-snapshot `readdirSync` blocked the event loop on slow cloud-backed dirs (refs #1137)** —
887
+ the genuine synchronous event-loop-block tier from #1122 (the 1.6–10.6 s
888
+ single-process blocks in `latency.log`, distinct from the machine-level
889
+ Modern-Standby/commit-exhaustion artifacts dispositioned in #1122 and tagged
890
+ `suspectSystemStall` by #1125). On a OneDrive/network-backed path a stalled
891
+ synchronous directory read blocks the Node loop — and pi's TUI — for the whole
892
+ stall. Converted the two clearly-live-path, low-risk offenders: (a) the Windows
893
+ Ruby drive-root enumeration (`readdirSync(driveRoot)` scanning `C:\` for
894
+ `ruby<N>` installer dirs), previously re-run **synchronously on every LSP spawn**
895
+ in `buildAugmentedPath` (PATH build) and on every Ruby candidate build in
896
+ `rubyBinCandidates` — now a shared `clients/lsp/ruby-drive-dirs.ts` module that
897
+ **memoizes** the result once per process (O(1) amortized) and reads the drive
898
+ root **off the loop** via `fs.promises.readdir` on the hot spawn path; and
899
+ (b) the `tool_result` autofix side-effect snapshot walk (`snapshotDirInto`,
900
+ `clients/pipeline.ts`), whose per-directory `readdirSync`/`statSync` are now
901
+ `fs.promises.readdir`/`stat` (the walk was already async + chunk-yielding, but
902
+ each synchronous per-dir read still blocked on a cloud stall). Same outputs,
903
+ just non-blocking. `measureMaxSyncBlockMs` occupancy guards assert the converted
904
+ drive-root path no longer holds the loop and fail against the pre-conversion
905
+ sync shape. Remaining suspect sites (the shared `walkTreeStackAsync` per-dir
906
+ read in `clients/source-walker.ts`; `hasProjectMarker`/`expandWorkspacePattern`
907
+ deep in sync memoized/bounded chains; the `safe-spawn.ts` Windows `spawnSync`
908
+ teardown/cached paths) are deferred as scoped follow-ups on #1137 — each ripples
909
+ into a widely-called sync API or subprocess-teardown correctness.
910
+ - **`session_start_sequence_read` was an unbounded synchronous blocking read on the session_start hot path (closes #1162)** —
911
+ `readLatestProjectSequence` called `fs.readFileSync` on the project
912
+ change-log before `session_start_total` returned; normally ~2ms, but under
913
+ host I/O pressure it had no escape hatch and was observed to balloon to
914
+ 2125ms in production latency.log. A `setTimeout`/`Promise.race` timeout
915
+ cannot preempt a synchronous read (the thread only returns to the event
916
+ loop once the OS call returns), so the fix adds an async twin
917
+ (`readLatestProjectSequenceAsync`, `fs.promises.readFile`) and races it
918
+ against a 250ms budget (`PI_LENS_SEQUENCE_READ_BUDGET_MS`-overridable) in
919
+ both the quick-mode and full-mode session_start paths. On a healthy read
920
+ (the common case) this adds ~zero overhead; on a stalled read, session_start
921
+ proceeds immediately with the safe cold-start sequence (only gates
922
+ snapshot freshness, never correctness) while the real read finishes in the
923
+ background and re-seeds the runtime — skipped for a one-shot `pi --print`
924
+ process via `isPrintMode()`, screening the #1154/#1153 one-shot
925
+ referenced-handle retention class. The fallback is never silent: the
926
+ `session_start_sequence_read` latency line now carries a `timedOut` flag,
927
+ and a background reseed logs its own
928
+ `session_start_sequence_read_deferred_reseed` phase. Fail-then-pass
929
+ regression tests inject a controllable slow read and assert session_start
930
+ returns within budget, falls back to cold-start with the flag set, and
931
+ still seeds normally on the healthy path. Adversarial review (#1168) caught
932
+ two P3s in this exact stall regime, both fixed in the same PR: (1) the
933
+ background reseed's `isCurrentSession` guard caught a cross-session move-on
934
+ but not a SAME-session advancement — an edit landing in the stall window
935
+ could have its `bumpFileSeq` result clobbered by a late reseed of the
936
+ pre-edit state; fixed with a `runtime.projectSeq > 0` guard (the cold seed
937
+ always sets it to exactly 0, so `> 0` at reseed time can only mean an
938
+ in-window bump happened). (2) The cold sentinel's `projectSeq: 0` was
939
+ indistinguishable from a project's legitimate first-ever snapshot (also
940
+ persisted at `seq === 0`), so a timed-out read could hydrate a stale seq-0
941
+ snapshot as fresh; fixed with a dedicated `UNKNOWN_PROJECT_SEQ` (`-1`)
942
+ sentinel fed only to the freshness check (never to `runtime.projectSeq`
943
+ itself, keeping fix (1)'s guard valid). Both have their own fail-then-pass
944
+ regression tests.
945
+ - **Resource-sampler Windows CIM spawns were not `.unref()`'d, the same one-shot-retention shape as the orphan reaper (refs #1155)** —
946
+ `clients/resource-sampler.ts`'s two Windows-only `Get-CimInstance Win32_Process`
947
+ spawns (`findDescendantPidsWindows`'s descendant-tree lookup and
948
+ `sampleProcessesWindows`'s CPU/RSS query) used `stdio:["ignore","pipe","ignore"]`
949
+ with a piped, `data`-listener-attached stdout and neither the child nor its
950
+ stdout was ever `.unref()`'d — the same shape #1153/#1160 fixed for the orphan
951
+ reaper (shape 4 of AGENTS.md's recurring-defect catalog: a referenced handle
952
+ that outlives a one-shot settle). The sampler was empirically absent under a
953
+ trivial `pi --print` prompt (its own `setInterval` was already unref'd, and it
954
+ only runs bracketed to an awaited analyzer spawn), but was not safe by
955
+ construction for a file-editing repro that does exercise it. Fixed by
956
+ extracting the reaper's `unrefReaperChild` AND its `spawnCollectStdout`
957
+ spawn→pipe-stdout→resolve-on-close plumbing into a shared, dependency-free
958
+ `clients/child-unref.ts` (`unrefChildAndPipes`, `spawnCollectStdout`) and
959
+ calling `spawnCollectStdout` at both sampler spawn sites — a single source
960
+ of truth for both modules instead of a second hand-rolled copy (the
961
+ promotion also resolved a SonarCloud new-code-duplication gate failure:
962
+ adding an identical `unrefChildAndPipes(child)` line to both near-identical
963
+ spawn blocks had pushed duplicated-line density over the 3% threshold;
964
+ collapsing both blocks to parse-only call sites around the shared helper
965
+ removed the duplication instead of adding to it). Unref only detaches this
966
+ child ALONE from keeping a settled one-shot alive; in an interactive/
967
+ long-lived session (or one bracketed to real analyzer work) the loop stays
968
+ referenced for other reasons, so sampling is unaffected — the parse logic
969
+ at both call sites is otherwise unchanged, so a spawn/error failure still
970
+ resolves to the same empty/partial result as before. Fail-then-pass
971
+ regression tests assert both spawn sites unref the child and its stdout, in
972
+ both the sampler and (unchanged) the reaper.
973
+ - **`toProjectRelativePath` never relativized a Windows-shaped path off native Windows (closes #1163, refs #1150/#1152/#1161/#1024)** —
974
+ shape-2 bug-class sweep of the `path.*`-on-cross-shaped-input hot zone.
975
+ `clients/path-utils.ts:toProjectRelativePath` used the host-default
976
+ `path.isAbsolute`/`path.relative` even when the input was Windows-shaped
977
+ (drive letter or UNC). On Linux CI, `path.isAbsolute("C:\\repo\\src\\x.ts")`
978
+ is `false` (no POSIX leading slash), so the function short-circuited and
979
+ returned the whole absolute path instead of the project-relative `src/x.ts`
980
+ it produces on Windows — a persisted call-graph symbol-key path or graph
981
+ display path (via `module-report`/`lens-map`'s `toDisplayPath` and
982
+ `call-graph`'s `formatImpact`) rendered as a full absolute path on Linux
983
+ (green-locally / wrong-on-CI, the #1024 divergence class). Fixed
984
+ shape-conditionally (`isWindowsPath(p) ? win32 : path`, the #1152 idiom):
985
+ a Windows-shaped path is parsed with `win32.*` on ANY OS; native same-OS
986
+ paths are unchanged. Fail-then-pass regression tests feed `C:\...`/UNC
987
+ literals as INPUT and assert the relative result on any OS (meaningful on
988
+ Linux CI). The rest of the swept hot zone
989
+ (`widget-state`/`file-utils`/`call-graph`/`installer`/`elixir-check`, plus
990
+ `file-role` #1152 and `resolveNonExisting` #1150 already fixed) was audited
991
+ and cleared as native-by-design — inputs are real on-disk paths the running
992
+ OS produced (cwd/project-roots/scanned files, `path.resolve`'d first) or
993
+ already fold through `normalizeEphemeralMapKey`/`PathKeyedMap`/the
994
+ regex-based `parseSymbolKey`.
995
+ - **`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)** —
996
+ `hasStrongGeneratedArtifactPath` (lockfile match), `hasWeakGeneratedFileNamePattern`
997
+ (name-pattern match), and `isDeclarationFile` (`.d.ts`/`.d.mts`/`.d.cts` match)
998
+ all took the module-default `path.basename(filePath)` on shape-committed
999
+ input. On Linux CI, `path.basename("C:\\proj\\package-lock.json")` finds no
1000
+ POSIX separator and returns the whole string unchanged, so
1001
+ `LOCKFILE_NAMES.has(...)` misses — a Windows-shaped lockfile or declaration
1002
+ path was silently treated as ordinary source. `generated-artifacts.ts` is
1003
+ imported by `file-role.ts`'s `"generated"` branch, so this residual sat
1004
+ within `detectFileRole`'s own call tree even after #1152 fixed the
1005
+ dir-segment/basename split there. Fixed with a shared `basenameForShape`
1006
+ helper that routes through `path.win32.basename` when `isWindowsPath`
1007
+ (exported by #1152) is true, mirroring `file-role.ts`'s fix exactly —
1008
+ shape-conditional, not shape-committed, so native-OS classification is
1009
+ unchanged. The strong directory-segment match (`pathSegments`, which splits
1010
+ on `[\\/]+`) was already shape-safe and untouched. Fail-then-pass regression
1011
+ tests cover a `C:\...`-shaped lockfile and `.d.ts` literal.
1012
+ - **Quick-mode background warmup kept a one-shot `pi -p`/`--print` process alive (closes #1154)** —
1013
+ `handleSessionStart` forces **quick mode** for both a real `pi -p`/`--print`
1014
+ one-shot AND an interactive process's first session (to protect keystroke
1015
+ latency, then warm caches for the next `/new`). Quick mode scheduled a +2s
1016
+ background warmup that (1) armed a `setTimeout` that was **not** `.unref()`'d —
1017
+ violating the repo-wide convention that every background timer is unref'd
1018
+ (`clients/runtime-session.ts` previously had zero `.unref()` calls) — and
1019
+ (2) launched LSP-prewarm children + a language-profile source walk that
1020
+ outlive settle with **no `session_shutdown` abort**. In a one-shot (which
1021
+ exits right after the turn and has no future session to warm) both are pure
1022
+ waste AND referenced-handle keep-alives that hold the settled process open —
1023
+ the located **#1122 hypothesis-A** concern and a member of the
1024
+ one-shot-retained-by-a-referenced-handle class of #1097/#1110/#1148/#1149.
1025
+ Fixed by (a) skipping the warmup entirely in print mode via a new shared
1026
+ `isPrintMode()` helper (`clients/print-mode.ts`) — an interactive first
1027
+ session (quick but not print) still warms — and (b) `.unref()`'ing the warmup
1028
+ timer as defense-in-depth + convention conformity. Also unref'd the adjacent
1029
+ full-mode `scheduleStartupScans` deferred timers (same latent violation, not a
1030
+ one-shot member today). This resolves the #1122 hypothesis-A tail (cross-ref
1031
+ #1122). Fail-then-pass regression tests assert the warmup does not run in print
1032
+ mode and the scheduled warmup timer is unref'd.
1033
+ - **`detectFileRole` misclassified a Windows-shaped path off native Windows (closes #1152, latent sibling of #1150/#1151)** —
1034
+ `clients/file-role.ts:detectFileRole` used the module-default `basename`/
1035
+ `dirname` (POSIX on Linux) even when the input path was Windows-shaped
1036
+ (drive letter or UNC prefix). A backslash-only `C:\...` path has no `/`
1037
+ for POSIX `dirname` to find, so it collapsed to `"."` and the dir-based
1038
+ role branches (`/tests/`, `/spec/`, generated-marker dirs) silently
1039
+ misclassified — the byte-identical forward-slash form already worked,
1040
+ since POSIX `dirname` handles `/` regardless of the leading drive
1041
+ letter. `detectFileRole` is platform-native by design, so the fix is
1042
+ shape-conditional rather than shape-committed (unlike #1151's
1043
+ `resolveNonExisting`, which is already win32-committed): a Windows-shaped
1044
+ path (per the now-exported `isWindowsPath`, `clients/path-utils.ts`) is
1045
+ parsed with `path.win32.basename`/`dirname` regardless of the running
1046
+ OS; a same-OS-native path is unaffected (native win32 already equals the
1047
+ module default there; native POSIX was never win32-shaped). Added
1048
+ regression coverage in `tests/clients/file-role.test.ts` asserting
1049
+ coherent `"test"`/`"init"` classification for `C:\...`, `C:/...`, and
1050
+ UNC-shaped inputs regardless of the running OS.
1051
+ `clients/lsp/server.ts:639`'s `normalizeSlashKey(path.dirname(path.resolve(file)))`
1052
+ (flagged in #1151's review as the same shape) was audited and verified
1053
+ **safe, not fixed**: `path.resolve(file)` runs before `dirname`, so by
1054
+ the time `dirname` sees it the value is already coerced to the running
1055
+ OS's native absolute-path shape — `file` here is always a real on-disk
1056
+ path produced by this process's own directory walking (extension-root
1057
+ resolution backed by real `existsSync`/`stat` probes, confirmed via
1058
+ `tests/clients/lsp/typescript-extension-root.test.ts`), never a
1059
+ persisted or cross-OS-supplied literal. Same "platform-native by
1060
+ design" exemption #1151 already applied to `path-utils.ts`'s
1061
+ `walkUpDirs`/`findNearestContaining` family.
1062
+ - **Orphan-reaper fire-and-forget PowerShell/`ps` spawns kept a completed `pi --print` alive past settle (closes #1153)** —
1063
+ the orphan reaper (`clients/instance-reaper.ts`) is fired fire-and-forget from
1064
+ `session_start` (`index.ts` `sweepOrphans`/`sweepUntrackedOrphans`), not
1065
+ awaited and not gated out of one-shot/`--print` mode. Its OS-process-table
1066
+ enumeration spawns (`enumerateManagedProcesses`, `queryCommandLines`,
1067
+ `findPidsByMarkerWindows` — PowerShell on Windows, `ps` on POSIX) used
1068
+ `stdio:["ignore","pipe","ignore"]` with a `data` listener and **no `.unref()`
1069
+ anywhere in the file**. A piped, listener-attached stdout stream keeps the
1070
+ event loop REFERENCED until the child `close`s, and `sweepUntrackedOrphans`
1071
+ guarantees ≥1 such PowerShell on every Windows `session_start` — so a settled
1072
+ one-shot process could not exit until that PowerShell finished cold-starting
1073
+ (routinely 300 ms–2 s). This is the child-process member of the referenced-
1074
+ handle class (#1097/#1110 timers, #1148/#1149 worker ports). Fixed by
1075
+ `unref()`-ing every reaper child AND its stdio pipes (a `child.unref()` alone
1076
+ does not release a piped stdout that re-refs the loop) via a shared
1077
+ `unrefReaperChild` helper applied at all six spawn sites (the five enumeration
1078
+ spawns plus `killPidTree`'s `taskkill`). Unref, not a print-mode skip: the
1079
+ reaper is a machine-wide orphan backstop, not a next-session-only concern, so
1080
+ gating it out of `--print` would blind orphan cleanup on print-only machines
1081
+ (CI/automation/subagents — exactly where one-shots dominate and orphans
1082
+ accumulate); unref preserves the sweep in interactive sessions (the loop stays
1083
+ referenced for other reasons, so every child's `close` still fires and the
1084
+ sweep completes) while letting a genuinely-settled one-shot exit without
1085
+ waiting. Regression test spawns a fake child per spawn site and asserts the
1086
+ child + its stdout are unref'd (fails pre-fix, passes post-fix).
1087
+ - **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.
1088
+ - **`normalizeFilePath` mangled a Windows-shaped path on non-Windows OS (closes #1150)** —
1089
+ `normalizeFilePath` commits to its win32 branch by path *shape*
1090
+ (`isWindowsPath`), so a `C:/…`- or `C:\…`-shaped path enters that branch on
1091
+ ANY OS — but `resolveNonExisting`'s upward walk used the module-default
1092
+ `dirname` (POSIX on Linux) while the rest of the branch already used
1093
+ `win32.resolve`/`win32.normalize`. On Linux the POSIX `dirname` found no
1094
+ separator in the win32-resolved path, collapsed to `.`, stopped the walk at
1095
+ `process.cwd()`, and produced `<cwd>/file.ts` instead of the literal key — so
1096
+ a Windows-shaped path normalized to a DIFFERENT key on Linux than the
1097
+ byte-identical literal it is on Windows (the #1024 OS-divergence class,
1098
+ surfaced by #1139's green-on-Windows/red-on-Linux-CI). Fixed by using
1099
+ `win32.dirname` in the win32-committed branch, making the function internally
1100
+ coherent regardless of the running OS. Guarded by a both-OS-meaningful
1101
+ regression test (native win32 path on Windows; shape-committed win32 branch on
1102
+ Linux) and an AGENTS.md convention: tests must derive `normalizeMapKey`-keyed
1103
+ structure keys via `normalizeMapKey`, never hardcode a drive-letter literal.
1104
+ - **Workspace-edit preflight: five contained P3 deferrals from #1085 (refs #1085)** —
1105
+ `clients/lsp/edits.ts`:
1106
+ - **P3-2** `mergeWorkspaceTextEditsByPriority`'s exact-duplicate dedup
1107
+ collapsed identical zero-width inserts duplicated within ONE server's
1108
+ own edit on the `renameFile` merge path, contradicting the documented
1109
+ multiplicity invariant (several identical zero-width inserts at one
1110
+ point are meaningful, same as `validateTextEdits` on the normal apply
1111
+ path). The dedup now only applies to non-empty ranges; a genuine
1112
+ cross-server exact duplicate (two servers proposing the identical
1113
+ non-empty replace) still collapses to one.
1114
+ - **P3-3** an ordered edit `rename(b→c); create(b); textEdit(b)` was
1115
+ falsely rejected in preflight ("text edit target does not exist"): a
1116
+ `create` at a path vacated by an earlier rename in the same edit had
1117
+ `resolveVirtualPath` return `undefined` (correct — the path is
1118
+ virtually gone), so the created state was silently discarded instead
1119
+ of recorded. Added a `virtualOverrides` overlay, keyed on the raw
1120
+ query path, consulted only when `resolveVirtualPath` cannot resolve a
1121
+ physical address, so a later op at that same path (e.g. the trailing
1122
+ text edit) sees the re-established file.
1123
+ - **P3-4** `version: null` (LSP 3.17: "don't check") combined with a
1124
+ numeric version for the SAME uri across two edit containers incorrectly
1125
+ threw "conflicting text document versions". A numeric version is now
1126
+ authoritative and adopted over a `null`/`undefined` counterpart; two
1127
+ genuinely different numeric versions still conflict, and a numeric
1128
+ version adopted from a `null` pairing is still checked against the live
1129
+ document version (a stale adopted numeric is still rejected).
1130
+ - **P3-6** `fileDetails[].importsChanged` reported "the file contains any
1131
+ import statement" (`/^import\s/m.test(updated)`) rather than "this edit
1132
+ changed an import/re-export line", over-invalidating downstream
1133
+ dependency-graph re-checks (`cache-manager.ts`'s `importsChanged`
1134
+ filter, `lsp-mutation.ts`'s `addModifiedRange`) on every edit to a file
1135
+ that merely already had imports. Now compares an import/re-export-from
1136
+ line signature of the pre- and post-edit content; unchanged unless the
1137
+ edit actually touched those lines. `create`/`rename`/`delete` keep
1138
+ their existing conservative flags (a create writes an empty file;
1139
+ rename/delete are structural, not diffable against pre-edit text).
1140
+ - **P3-8** an ordered edit that CREATEs `foo.txt` then case-renames it to
1141
+ `Foo.txt` within the SAME edit fail-closed with "destination already
1142
+ exists" on a case-insensitive FS: the alias check (`isSameFsEntry`)
1143
+ lstats disk, but the just-created file exists only in the preflight's
1144
+ virtual overlay, never on disk yet. The rename branch now also treats
1145
+ a rename as an alias when the destination's cached `VirtualFile`
1146
+ object is referentially identical to the source's (both `stateFor`
1147
+ calls resolve through the same case-folded map key), before falling
1148
+ back to the physical `isSameFsEntry` probe — which is unchanged for
1149
+ genuinely-physical paths, so the #1024/#1120 ino-guard and
1150
+ case-sensitivity invariants are untouched.
1151
+
1152
+ **Deferred at the time (now fixed, see below):** P3-5 and P3-7 were
1153
+ rehomed to #1147; see the #1085 closing comment for that history.
1154
+ - **Workspace-edit CRLF boundary class + rename close-failure plaintext reopen (closes #1147, refs #1085)** —
1155
+ the two P3 deferrals left open after #1146:
1156
+ - **P3-5** (`clients/lsp/edits.ts`) — #1120 fixed only the past-EOL clamp
1157
+ member of the CRLF-boundary class. Two general members remained: (a) a
1158
+ `newText` containing a bare `\n` was spliced verbatim into a CRLF
1159
+ file's content, producing mixed line endings, because the LSP
1160
+ workspace-edit apply path (unlike the host-edit path) never
1161
+ EOL-normalized `newText`; (b) `utf16Position`'s past-EOL clamp only
1162
+ triggered on `character > wireLength`, so an in-bounds,
1163
+ caller-supplied `character === wireLength` on a line whose `\r` is
1164
+ folded into `lineTextAt`'s with-`\r` length landed the position
1165
+ squarely between `\r` and `\n`, splitting the pair on write. Fixed by
1166
+ (a) normalizing every `newText` through the exact
1167
+ `detectLineEnding`/`normalizeToLF`/`restoreLineEndings` contract
1168
+ `clients/host-edit-normalize.ts` already uses for the host-edit path
1169
+ (LF files are unaffected — `restoreLineEndings` is the identity for
1170
+ `"\n"`), applied once in `normalizeTextEditsForContent` so it covers
1171
+ both the preflight virtual-content chain and the final on-disk write;
1172
+ (b) clamping on `character > clampedWireLength` (the `\r`-stripped
1173
+ length) instead of the with-`\r` length, which subsumes and simplifies
1174
+ #1120's original past-EOL clamp into the same branch. All of #1120's
1175
+ existing invariants (single application-ordering sort, clamp, tie-break)
1176
+ are unchanged and covered by the full `edits.test.ts` suite.
1177
+ - **P3-7** (`clients/lsp/index.ts`) — the rename close-failure recovery
1178
+ path reopened the old document as a hardcoded `"plaintext"` languageId,
1179
+ degrading that server's diagnostics until the next genuine open. Now
1180
+ reopens with `getLanguageId(oldFilePath) ?? "plaintext"` — the same
1181
+ resolver every genuine `notify.open` call in this file already uses —
1182
+ so a recognized extension reopens with its real language ID and only a
1183
+ genuinely unrecognized extension still falls back to `"plaintext"`.
1184
+ - **Post-init runtime exits now count toward the LSP circuit breaker (closes #1127)**
1185
+ — `LSPService`'s (`clients/lsp/index.ts`) exponential-backoff breaker
1186
+ (`failureCounts` → cooldown → permanent-disable after
1187
+ `BROKEN_PERMANENT_AFTER`) only incremented on spawn/initialize failure.
1188
+ A server whose spawn SUCCEEDS but then exits shortly after (opengrep's
1189
+ post-init "Unhandled message" JSON-RPC crash, per #1122's Phase C
1190
+ corroborating-signal review — 37 respawns in one real session, never
1191
+ converging) hit the "dead client — needs respawn" path instead, which
1192
+ never touched the breaker: `failureCounts` was already cleared by the
1193
+ preceding successful spawn and the runtime exit itself was never counted.
1194
+ Fixed by adding a parallel `runtimeExitCounts` counter fed only by EARLY
1195
+ (lifetime < 60s) non-intentional exits, sharing the same cooldown formula
1196
+ and the same `state.broken`/`permanentlyBroken` maps as the existing
1197
+ breaker — tracked separately from `failureCounts` specifically because a
1198
+ successful respawn (which correctly resets the spawn/init failure streak)
1199
+ is not proof of health for a crash-loop server, so reusing that map would
1200
+ erase the streak on every respawn attempt (the #1127 bug). Deliberate
1201
+ teardowns (session reset, `#743` notify-backpressure eviction, generation
1202
+ handoffs) call `shutdown()` themselves before the process exits and set
1203
+ `shutdownRequested`; a new `wasShutdownIntentional()` accessor on
1204
+ `LSPClientInfo` (`clients/lsp/client.ts`) exposes that flag so the breaker
1205
+ distinguishes a genuine crash from a restart it initiated and never counts
1206
+ the latter. Adversarial review caught that lifetime was originally
1207
+ computed from the moment a dead client is lazily DETECTED (the next
1208
+ `getClientForFile` attach), not from when it actually died — #1127's
1209
+ documented pattern is attach-triggered respawns minutes to hours apart, so
1210
+ an early crash detected an hour later would misread as a long healthy run
1211
+ and never count. Fixed by stamping a real `exitedAt` on `LSPClientState`
1212
+ the moment the client's connection/process actually dies (first of
1213
+ `onError`/`onClose`/process `exit` to fire) and exposing it via a new
1214
+ `getExitedAt()` accessor; the respawn site computes lifetime as
1215
+ `exitedAt - spawnedAt`, falling back to the detection-time delta only when
1216
+ `exitedAt` is unexpectedly unset. New coverage in
1217
+ `tests/clients/lsp/service-runtime-exit-breaker.test.ts`: a crash-loop
1218
+ respawn sequence converges to permanent-disable instead of respawning
1219
+ forever (fails against pre-fix behavior), an early death with detection
1220
+ delayed by hours still counts (death time, not detection time, decides —
1221
+ fails against the pre-fix detection-time computation in both directions),
1222
+ a deliberate `shutdown()`-driven restart sequence never counts, a runtime
1223
+ exit past the lifetime threshold resets the streak instead of counting,
1224
+ and the REAL `#743` notify-write-backpressure eviction path (driven
1225
+ through `touchFile`, not a synthetic stand-in) never double-counts against
1226
+ this breaker. Full existing LSP suite (536 tests) stays green.
1227
+
1228
+ - **`terragrunt hcl fmt` reported success when it never ran (refs #1117)** —
1229
+ `formatFile` decided success from `result.error` alone, and a formatter that
1230
+ fails leaves the file byte-identical, which is indistinguishable from
1231
+ "already formatted". A terragrunt binary predating the `hcl` command group
1232
+ exits non-zero without touching the file, and pi-lens reported
1233
+ `success: true, changed: false`. `FormatterInfo` gained an opt-in
1234
+ `strictExitCode`, set on `terragrunt-hcl`, which folds the exit status into
1235
+ the check and surfaces the tool's own first stderr line as the error. It is
1236
+ opt-in rather than global because the lint-autofix formatters (`rubocop -a`,
1237
+ `ktlint -F`, `standardrb --fix`, `sqlfluff fix`) exit non-zero when offenses
1238
+ remain AFTER a successful rewrite; failing those would surface a formatter
1239
+ error on every file with an unfixable offense.
1240
+ - **Runners reported a clean file on a non-zero exit with no output (refs #1117)** —
1241
+ seven runners gated their "the tool never ran" skip on
1242
+ `result.error` alone. `safeSpawnAsync` sets `error` only for spawn, timeout,
1243
+ signal and abort failures, and resolves a NORMAL exit with no `error` at any
1244
+ status (see `SpawnResult.failure`: "nonzero exit statuses are not spawn
1245
+ failures"). So an unknown subcommand, a rejected flag, or a config that fails
1246
+ to load — non-zero exit, message on stderr, empty stdout — fell straight
1247
+ through the guard, parsed `""` into zero diagnostics, and reported the file as
1248
+ clean. Terragrunt was the visible case: its docblock claimed a binary
1249
+ predating the `hcl` command group was classified SKIPPED, and it was not. The
1250
+ test now lives in `spawnFailedWithNoOutput`
1251
+ (`clients/dispatch/runners/utils/spawn-outcome.ts`) and folds `status` into
1252
+ the check; terragrunt, tflint, hadolint, taplo, trivy-config, htmlhint and
1253
+ detekt all use it. Runners that exit non-zero BECAUSE they found something are
1254
+ unaffected, since their findings are on the stream the guard tests. ktlint,
1255
+ prisma-validate, gleam-check, zig-check, dart-analyze and elixir-check already
1256
+ covered the case through their own non-zero follow-up branches and are
1257
+ unchanged.
1258
+ - **Bare-binary GitHub release assets skip signature siblings** — the installer
1259
+ picked a release asset with `assets.find(a => a.name.includes(substring))`.
1260
+ Archive-based tools are unaffected (their substrings end in `.zip`/`.tar.gz`),
1261
+ but the three bare-binary entries (terragrunt, marksman, expert) resolve to
1262
+ the FULL asset name, which is a strict prefix of every `.asc`/`.sig`/`.sha256`
1263
+ sibling, so whichever the release listed first would be downloaded and marked
1264
+ executable as the binary. Selection now lives in an exported
1265
+ `pickReleaseAsset`: exact name first, then a substring match that excludes
1266
+ known sidecar suffixes.
1267
+ - **Cascade fallback-display paths re-displayed bound-false LSP snapshots (refs #1104)** —
1268
+ #1100 gated the cascade's RECONCILE path (the footer/widget) onto content
1269
+ binding (`boundToCurrentDisk`), but two DEGRADED-fallback DISPLAY paths in
1270
+ `clients/dispatch/integration.ts` still re-read TTL-fresh `getAllDiagnostics()`
1271
+ snapshots without consulting binding at all: the touch-error fallback (a
1272
+ failed active LSP touch falling back to the passive snapshot) and
1273
+ `appendFallbackNeighbors` (the CR-3/A2 degraded-fallback path when no
1274
+ neighbor produced trustworthy LSP data). A bound-false snapshot — diagnostics
1275
+ computed against a DIFFERENT disk state than what's currently on disk, e.g. a
1276
+ pre-fix-edit read — could still reach cascade OUTPUT even though the widget
1277
+ was protected. Both sites now apply the same false/`"unknown"`/true contract
1278
+ #1095/#1100 already established for reconcile: `false` → skip the stale
1279
+ display (logged via the cascade channel with `bindingState`), `"unknown"` →
1280
+ unchanged (the pre-existing fallback contract), `true` → display. HONESTY
1281
+ fix: filtering a display candidate could otherwise make a genuinely degraded
1282
+ cascade look clean, so when every fallback candidate a run considered was
1283
+ binding-rejected and nothing else produced output, the run now carries the
1284
+ same `indeterminate` marker #1023 built for a degraded graph compute (new
1285
+ `CascadeIndeterminateReason: "lsp_binding_rejected"`), so the turn-end
1286
+ advisory still surfaces an honest note instead of silence. The `resultId`
1287
+ pull-diagnostics plumbing that #1104 also tracks remains open — this covers
1288
+ only the cascade display-binding gap (#1100 review P3-1).
1289
+ - **`parseSymbolKey` mis-parsed LSP-fallback symbol kinds (refs #1088)** —
1290
+ the canonical-id parser whitelisted only the 7 kinds `buildSymbolId` mints
1291
+ directly, but `addLspFallbackSymbols` mints ids using the much larger
1292
+ `lspSymbolKindName` vocabulary (`enum`, `constant`, `struct`, `namespace`,
1293
+ `field`, `constructor`, `module`, the `lsp-symbol-<n>` catch-all, ...). Any
1294
+ of those ids failed the whitelist and fell through to the legacy
1295
+ last-colon split, shearing the id at the wrong boundary (e.g.
1296
+ `c:\p\a.kt:Color:enum:42` parsed as `filePath: "c:\p\a.kt:Color:enum"`,
1297
+ `symbolName: "42"`). `clients/review-graph/symbol-id.ts`'s
1298
+ `parseSymbolKey` now matches the trailing `:<kind-token>:<digits>` shape
1299
+ STRUCTURALLY (any lowercase, optionally hyphen-segmented token) instead of
1300
+ whitelisting specific kind strings, so it stays correct for the open-ended
1301
+ LSP-fallback vocabulary without needing a matching update per kind.
1302
+ - **Java try-with-resources suppression was dead (refs #1089)** —
1303
+ `clients/tree-sitter-client.ts`'s `not_closed_or_try_with_resources`
1304
+ post-filter built its resource-word regex as `` new RegExp(`\b${resource}\b`) ``
1305
+ inside a template literal, where `\b` is the BACKSPACE control character
1306
+ (U+0008), not a regex word-boundary escape. The regex could never match,
1307
+ so the Java 9 short-form `try (resource) { ... }` was never recognized as
1308
+ closing its resource — only an explicit `.close()` call suppressed the
1309
+ "resource should be closed" finding. Fixed to `\\b` plus a regex-escaped
1310
+ identifier. No shipped Java fixture used try-with-resources syntax, so no
1311
+ existing expected-findings fixture changes with this fix.
1312
+ - **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
1313
+ literal 0x00 byte embedded in source, which makes `ripgrep`/`grep` treat
1314
+ the whole 4,900+ line file as binary and stop scanning partway through —
1315
+ silently defeating the AGENTS.md-mandated repo-wide grep sweep for every
1316
+ commit that touched this file. Replaced the raw byte with the `\u0000`
1317
+ escape sequence, which is the byte-identical runtime string (same
1318
+ `hashIgnoredIds` function computes both the write-time and read-time
1319
+ hash, so no persisted checkpoint compatibility is affected).
1320
+ - **`buildCallGraph` same-file evidence accounting hardened + dead code removed (refs #1089)** — added regression coverage for the audited
1321
+ same-file evidence class (divergent path forms for the same file must
1322
+ both be recognized as same-file AND counted exactly once per reference,
1323
+ keeping the coverage sum invariant `validatePersistedCallGraph` enforces
1324
+ on every load); the normalized-compare fix for this had already landed
1325
+ upstream, so this closes out the finding with a fail-then-pass regression
1326
+ test rather than a behavior change. Also removed `clients/call-graph.ts`'s
1327
+ dead duplicate `saveCallGraph` overload signature (byte-identical to the
1328
+ implementation signature) and the unreachable `{}`-spread branch on the
1329
+ required `identity` parameter.
1330
+ - **JS destructured params dropped from `parameterCount` (refs #1089)** —
1331
+ `clients/dispatch/facts/function-facts.ts`'s `getParameters` recognized
1332
+ `required_parameter`/`optional_parameter` wrapper nodes (how TypeScript's
1333
+ grammar represents every parameter, destructured or not) but not the bare
1334
+ `object_pattern`/`array_pattern` nodes plain JavaScript's grammar uses
1335
+ for a top-level destructured parameter with no wrapper. `function
1336
+ f({a, b})` counted 0 parameters in a `.js` file while the TS-annotated
1337
+ equivalent counted correctly. Added JS-vs-TS parity tests.
1338
+ - **`call-graph.ts`'s exported `CACHE_VERSION` renamed to `CALL_GRAPH_CACHE_VERSION`** — collided in name (not value — separate
1339
+ modules) with `clients/cache/rule-cache.ts`'s own `CACHE_VERSION` export;
1340
+ renamed to match the `<SUBSYSTEM>_CACHE_VERSION` convention already used
1341
+ by `WORKSPACE_DIAGNOSTICS_CACHE_VERSION` and
1342
+ `PROJECT_DIAGNOSTICS_CACHE_VERSION`. Updated the two test-file imports.
1343
+ - **Dead SIGTERM→SIGKILL escalation guard on non-Windows kills (closes #1114)**
1344
+ — `clients/safe-spawn.ts`'s non-Windows `killTree` branch armed a 1s
1345
+ escalation timer gated on `if (!child.killed) child.kill("SIGKILL")`, but
1346
+ Node sets `ChildProcess#killed = true` the moment `kill()` successfully
1347
+ SENDS a signal — not when the child actually dies — so immediately after
1348
+ the `child.kill("SIGTERM")` one line above, the guard was always false
1349
+ and the SIGKILL escalation could never fire: a SIGTERM-ignoring child on
1350
+ Linux/macOS was never force-killed. Fixed by tracking OBSERVED death via a
1351
+ `closed` flag set synchronously (before any `await`) in the close/error
1352
+ handlers, gating the escalation on `!closed` instead — composes cleanly
1353
+ with the existing #1109/#1113 `escalationTimer` clear-on-close fix rather
1354
+ than switching to the LSP `killProcessTree` analog's unconditional-SIGKILL
1355
+ design, since `safeSpawnAsync` already has a real per-call close/error
1356
+ observation point to hang the flag off of. Proven with a new "child
1357
+ ignores SIGTERM → SIGKILL sent at the 1s mark" test
1358
+ (`tests/clients/safe-spawn-kill-escalation-timer.test.ts`) that fails
1359
+ against the pre-fix guard and passes post-fix; the existing #1109 timer-leak
1360
+ tests (escalation timer cleared when close/error DOES arrive) remain green.
1361
+ **Class sweep** of every `.killed` consumer under `clients/` and `scripts/`
1362
+ found two siblings of the same shape in `clients/lsp/client.ts`'s
1363
+ `killProcessTree`/`createLSPClient` and fixed both in this PR: (1) the
1364
+ `fast`-shutdown escalation timer checked `!proc.killed`, but the primary
1365
+ SIGTERM send there goes through the raw `process.kill(-pid, …)` process-group
1366
+ call (which never touches `proc.killed`), so the guard was either always-true
1367
+ (unconditional SIGKILL after the window on the common group-kill path) or
1368
+ dead (on the direct-child fallback path) — now tracked via a real `exit`
1369
+ listener set once up front, seeded from the same `exitCode`/`signalCode`
1370
+ pre-check the function's top-of-body early return already uses (a process
1371
+ that was already dead on entry — reachable when `options.processExiting`
1372
+ skips that early return — would otherwise miss its own "exit" event and
1373
+ still draw a redundant group SIGKILL at the escalation window); (2) the
1374
+ `initialize()`-timeout 2s SIGKILL backstop had the identical always-true
1375
+ `!lspProcess.process.killed` guard — switched to `lspProcess.process.exitCode
1376
+ === null && lspProcess.process.signalCode === null` (both, not `exitCode`
1377
+ alone: a process killed BY a signal — the common case here, since
1378
+ `killProcessTree` above it signals rather than lets the process exit on its
1379
+ own — has `exitCode === null` forever and only `signalCode` set, so
1380
+ `exitCode` alone still re-armed the backstop's kill against an
1381
+ already-dead corpse; harmless in practice since `ChildProcess#kill()` on an
1382
+ exited handle is a swallowed no-op, but not an accurate "still alive"
1383
+ read). Other `.killed` reads audited and left as-is because they're
1384
+ liveness/status checks, not escalation-action gates: `isClientAlive`'s
1385
+ `!state.lspProcess.process.killed` (redundant with `isDestroyed`, already
1386
+ set from real exit/close handlers), `checkProcessAlive`'s informational
1387
+ "was killed" health-check string, `launch.ts`'s post-spawn
1388
+ immediate-failure check (`proc.killed` read before any kill was ever sent),
1389
+ `scripts/with-test-lock.mjs`'s `.once`-registered first-forward guard, and
1390
+ `scripts/smoke-tools.mjs`'s read of Node's own `execFileSync` timeout-kill
1391
+ flag on the caught error object.
1392
+
1393
+ **Adversarial-review follow-up round:** the reviewer ran
1394
+ `kill-process-tree.test.ts` against PRE-fix `client.ts` and it passed 7/7 —
1395
+ the sibling fixes above had ZERO effective test coverage, because the
1396
+ "non-fast shutdown escalates" mock (and the other pre-existing mocks in
1397
+ that file) lacked `once`/never set `killed`, so BOTH the old dead guard and
1398
+ the new fix's guard were vacuously permissive against them (the #1106
1399
+ vacuous-mock class, recurring in mock form: a test's fixture is too weak to
1400
+ distinguish correct from broken behavior, so it passes either way). Fixed
1401
+ by: upgrading that test's mock to be `.once`-capable so it actually
1402
+ exercises the `exited`-flag logic; adding two new `fast`-shutdown tests with
1403
+ a real `.once`-capturing mock proving BOTH directions (exit observed before
1404
+ the 1.5s window → no group SIGKILL; no exit observed → group SIGKILL at the
1405
+ window) — the "no premature SIGKILL" direction fails against the pre-fix
1406
+ `!proc.killed` guard (proven by temporarily reverting the guard and
1407
+ re-running); and adding a real-subprocess POSIX-only test
1408
+ (`tests/clients/lsp/initialize-timeout-backstop.test.ts`, skipped on win32
1409
+ with an explicit reason — killProcessTree's Windows path is
1410
+ `taskkill`-based, not signal-based, and is already covered by the
1411
+ kill-process-tree suite) for the previously fully-untested `initialize()`
1412
+ 2s backstop.
1413
+ - **Micro-gap sweep: recorded coverage/observability/doc gaps (refs #1106, refs #1104)**
1414
+ - `session-state-store.ts`'s `loadSessionState` STATE_VERSION reject path
1415
+ (a wrong-version persisted snapshot is ignored, not rehydrated) had no
1416
+ test; `STATE_VERSION` is now exported so the new test can drive the
1417
+ mismatch off the real constant (`STATE_VERSION + 1`) rather than a
1418
+ hardcoded literal (#1116 pattern).
1419
+ - `tests/clients/cache/rule-cache.test.ts`'s deliberate `raw.version = "v2"`
1420
+ schema-mismatch override now pins `expect(CACHE_VERSION).not.toBe("v2")`
1421
+ alongside it (#1082/#1116 pattern), so the assertion can't vacuously pass
1422
+ if `CACHE_VERSION` ever became `"v2"`.
1423
+ - The cascade `neighbor_touch` log entry (`clients/dispatch/integration.ts`)
1424
+ now carries an `inconclusive` boolean in its metadata alongside
1425
+ `bindingState`, so the two independent unconfirmed-touch causes (notify/
1426
+ diagnostics wait lapsed vs. disk-diverged binding, #1093/#1095) are
1427
+ distinguishable from `cascade.log` alone, without cross-referencing
1428
+ `latency.log`.
1429
+ - `tools/lens-diagnostics.ts`'s `includeGenerated` param description now
1430
+ states it only takes effect with `mode=full refreshRunners=cheap/all`
1431
+ (it's silently a no-op under `cached`/`none`, since no project scan runs
1432
+ to apply it to) — a doc-only fix from PR #1115's review.
1433
+ - **mtime-only cache freshness sweep (refs #1105)** — the #1092→#1096 arc bound
1434
+ LSP-diagnostics freshness to real content; this sweep audited the OTHER
1435
+ persisted/derived caches for the same "mtime unchanged ≠ content unchanged"
1436
+ class (git checkout timestamp restoration, formatters preserving mtime,
1437
+ same-second writes) and hardened the two CONTAINED members whose fix is free:
1438
+ - **word index** (`clients/word-index.ts`): the incremental session-warmup
1439
+ refresh gated re-read on mtime ALONE (`fileMtimes.get(file) !== mtimeMs`), so
1440
+ a mtime-preserving content change left stale identifiers served to
1441
+ `symbol_search`/`pilens_symbol_search`. It now stores a per-file byte `size`
1442
+ (the SAME `stat` the walk already runs to enforce the byte cap — zero extra
1443
+ I/O) and re-reads when mtime OR size differs, matching the review graph's
1444
+ gold-standard `size:mtimeMs` first filter. `size` is a new optional parallel
1445
+ array in the serialized snapshot (`SerializedWordIndex.fileSizes`); a
1446
+ pre-#1105 snapshot lacking it forces one self-healing full re-read on the
1447
+ next refresh (the safe direction) rather than a version bump.
1448
+ - **dependency checker** (`clients/dependency-checker.ts`):
1449
+ `DependencyChecker.importsChanged`'s mtime fast path (`cached.timestamp >=
1450
+ stat.mtimeMs`) skipped the madge circular-dep re-check on a mtime-preserving
1451
+ import edit; it now also requires `cached.size === stat.size`.
1452
+
1453
+ The residual (same mtime AND same byte length, changed content) is left as the
1454
+ same accepted residual the review graph itself carries — closing it needs an
1455
+ unconditional per-read content hash, which the event-loop/hot-path discipline
1456
+ forbids. The sweep's full verdict table (word-index/dependency-checker fixed;
1457
+ project-snapshot seq, installer probe-cache, sgconfig, reverse-deps, TreeCache,
1458
+ yaml-rule-parser bundled cache all SAFE/already-hardened; rule-cache disk cache
1459
+ filed as a follow-up because a content-confirm there would read 700+ rule files
1460
+ on the per-edit hot path) is recorded in the PR.
1461
+ - **Test-role files no longer leak into collateral cascade/impact surfaces (closes #1080)**
1462
+ — the review graph is already tests-free, but several
1463
+ collateral surfaces re-derived neighbors from OTHER sources that never saw
1464
+ that filter, so an UNIGNORED `*.test.*` / `tests/` file could still surface
1465
+ as cascade collateral. The existing `getProjectIgnoreMatcher` filtering only
1466
+ closed the ignored-file half of the gap (#297). A new shared predicate
1467
+ `clients/collateral-test-role.ts:isTestRoleCollateral` composes the SINGLE
1468
+ existing structural classifier (`clients/file-role.ts:detectFileRole`) — no
1469
+ second matcher, no private test-filename list — and is applied at every
1470
+ collateral producer boundary: (1) `computeCascadeForFile` filters
1471
+ `impact.directImporters` / `directCallers` / `neighborFiles` AFTER graph
1472
+ neighbors, reverse-deps, LSP reference expansion, and transitive expansion
1473
+ are merged, so a test neighbor is excluded from the active-touch/passive-
1474
+ snapshot set, the returned `impact`, AND the formatted header
1475
+ (`formatImpactCascade` reads `impact` verbatim for `Direct importers` /
1476
+ `Check next` counts and names) — module-level downstream files (added inside
1477
+ `computeImpactCascade`) are caught here at the consumption boundary rather
1478
+ than inside the shared graph query; (2) `appendFallbackNeighbors` drops an
1479
+ unignored test file from the passive-snapshot fallback (ignore filtering
1480
+ stays separate and unchanged); (3) `runtime-turn.ts` filters call-graph
1481
+ `impact()` results so a test caller appears in neither the turn-end advisory
1482
+ text nor the persisted delta; (4) `callGraphImpactToProjectDiagnostics`
1483
+ re-applies the predicate at the persistence producer boundary. Honesty
1484
+ preserved: a KNOWN `"test"` role is filtered, but a role-classification
1485
+ failure RETAINS the candidate (never a false clean), the ignore matcher's
1486
+ fail-open behavior is untouched, and LSP-unavailable/inconclusive cascade
1487
+ semantics are unchanged. Per-runner and auxiliary-profile `skipTestFiles`
1488
+ semantics, primary LSP diagnostics, intentional test-runner findings, and
1489
+ the generic project-diagnostics snapshot/delta display are all left as-is.
1490
+
1491
+ - **`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.
1492
+ - **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.
1493
+
1494
+ - **`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.
1495
+ - **`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.
1496
+ - **`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.
1497
+ - **`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.
1498
+ - **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.
1499
+ - **`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.
1500
+ - **"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).
1501
+ - **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.
1502
+ - **`"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").
1503
+ - **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.
1504
+ - **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.
1505
+ - **`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.
1506
+ - **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.
1507
+ - **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.
1508
+ - **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.
1509
+ - **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.
1510
+ 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.
1511
+ 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.
1512
+ 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.
1513
+ 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).
1514
+ 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.
1515
+
1516
+ - **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.
1517
+ - **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.
1518
+ - **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.
1519
+ - **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.
1520
+ - **`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).
1521
+
1522
+ - **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.
1523
+
1524
+ - **Per-issue close-keyword CI validation** — Rejects comma-separated GitHub close lists and verifies merged PR close targets.
1525
+
1526
+ - **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.
1527
+
1528
+ - **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.
1529
+
1530
+ - **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.
1531
+
1532
+ - **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.
1533
+
1534
+ - **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.
1535
+
1536
+ - **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.
1537
+
1538
+ - **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.
1539
+
1540
+ - **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.
1541
+
1542
+ - **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.
1543
+
1544
+ - **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.
1545
+
1546
+ - **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.
1547
+
1548
+ - **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.
1549
+
1550
+ - **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.
1551
+
1552
+ - **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.
1553
+
1554
+ - **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.
1555
+
1556
+ - **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.
1557
+
1558
+ - **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.
1559
+
1560
+ - **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.
1561
+
1562
+ - **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.
1563
+
1564
+ - **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`.
1565
+
1566
+ - **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.
1567
+
1568
+ - **Bound and idle-evict review-graph workspaces and authoritative project snapshots (refs #1389).**
1569
+
1570
+ - 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).
1571
+
1572
+ - **Bound Tier-3 process caches, prune expired negative LSP entries, decay widget/agent state, and close the cache audit (closes #1389).**
1573
+
1574
+ - **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.
1575
+
1576
+ - **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.
1577
+ - **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.
1578
+ - **Nullish equality exemptions (closes [#966](https://github.com/apmantza/pi-lens/issues/966))** — allow intentional `== null` / `!= null` checks while retaining other strict-equality findings.
1579
+ - **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.
1580
+ - **Unsafe-boundary fallback detection (closes [#969](https://github.com/apmantza/pi-lens/issues/969))** — recognize catch blocks that recover through fallback assignments.
1581
+ - **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.
1582
+ - **Native zizmor suppressions (closes [#971](https://github.com/apmantza/pi-lens/issues/971))** — honor inline `zizmor: ignore[...]` directives.
1583
+ - **Named URL constants (closes [#964](https://github.com/apmantza/pi-lens/issues/964))** — avoid hardcoded-URL findings for deliberate `SCREAMING_SNAKE_CASE` endpoint constants.
1584
+ - **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.
1585
+ - **Workspace configuration responses (closes [#983](https://github.com/apmantza/pi-lens/issues/983))** — return one resolved value for each requested configuration section.
1586
+ - **Same-file diagnostic ordering (closes [#1198](https://github.com/apmantza/pi-lens/issues/1198))** — prevent late diagnostic work from restoring stale pending results.
1587
+ - **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.
1588
+ - **Stabilize LSP workspace-edit occupancy (closes [#1091](https://github.com/apmantza/pi-lens/issues/1091))** — keep workspace edits consistent under concurrent occupancy.
1589
+ ### Security
1590
+
1591
+ - **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.
1592
+
1593
+
1594
+ - **`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.
1595
+
1596
+ - **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).
1597
+
1598
+ - **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.
1599
+
1600
+ - **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.
1601
+
1602
+ ### Contributors
1603
+
1604
+ - Apostolos Mantzaris (@apmantza)
1605
+ - Nathan Cooke (@3choBoomer)
1606
+ - Eli Stark (@white-hat)
1607
+ - Mark Faga (@mjfaga)
1608
+ - Marvin Aziz (@marvtub)
1609
+ - Dominic (@aeturnal)
1610
+ - Kenneth McCormick
1611
+ - jiangmt
1612
+
13
1613
  ## [3.8.74] - 2026-08-01
14
1614
 
15
1615
  ### Added
@@ -17,1114 +1617,1093 @@ All notable changes to pi-lens will be documented in this file.
17
1617
  - **🙏 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
1618
 
19
1619
  - **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.
1620
+ signals now land in `~/.pi-lens/latency.log` as `type: "phase"` records.
1621
+ (1) Response-side: a defensively feature-detected `message_end` subscription
1622
+ (clients/agent-nudge.ts pattern — guarded, never throws on older hosts) logs one
1623
+ `cache_usage` record per assistant message that carries a `usage`, with
1624
+ `metadata: { provider, model, cacheRead, cacheWrite, input, output, cost }`
1625
+ (provider/model read straight off the assistant message; `cost` is the total).
1626
+ Messages with no usage (or non-assistant messages) are skipped rather than
1627
+ logged as zeros. (2) Request-side: the existing `context` handler now hashes
1628
+ `messages[0]` on every call and logs a `cache_prefix_break` record
1629
+ (`metadata: { turnIndex, previousHash, currentHash, sessionId, sessionRole }`,
1630
+ plus a baseline record on first observation) whenever that hash changes
1631
+ turn-over-turn — a regression guard that flags anything (pi-lens or otherwise)
1632
+ breaking the byte-stable prefix #1016 established. The baseline is keyed by the
1633
+ stable per-session id (`ctx.sessionManager.getSessionId()`) in a small bounded
1634
+ LRU, so a resume/reload keeps comparing against its own baseline while a
1635
+ new/fork or a concurrent in-process subagent (#473) gets an independent baseline
1636
+ instead of a spurious cross-session break; each record is tagged with the
1637
+ read-only #473 `sessionRole` classification, and a primary `session_shutdown`
1638
+ drops the ended session's entry. The hash is pure observation: it never changes
1639
+ the handler's injection behavior or return value, and runs even on non-injecting
1640
+ turns. New `clients/cache-observability.ts`; no new dependencies.
41
1641
 
42
1642
  - **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.
1643
+ (refs #958, #926, #533) — every word-index signal previously rode solely on
1644
+ the optional `dbg` callback, a documented no-op in the MCP host where
1645
+ `symbol_search` reads the index. The structured logger (`~/.pi-lens/
1646
+ word-index.log`, shared `createNdjsonLogger` infra) now records, independent
1647
+ of `dbg`: the full-rebuild-vs-incremental decision plus honest coverage
1648
+ (`indexedFileCount`/`truncated` and `refreshed`/`dropped`/`skipped`/`reused`
1649
+ counts) for both the session-start and cold-query (MCP) build paths; a
1650
+ swallowed snapshot **persist failure** as `persist_failed` (a silent stale
1651
+ index otherwise leaves no trace); and the safety refusal for a root at/above
1652
+ `$HOME`. The full-build collector (`collectWordIndexDocs`) now also returns a
1653
+ `skipped` count for files it enumerated but could not index (over the byte
1654
+ cap / unreadable), so a partial index is never reported as complete. The
1655
+ fragile string-parsing `dbg` adapter this replaces is removed.
1656
+
1657
+ - **Closed four observability gaps in recently-changed typos-config and project-snapshot code (refs #533)** — none of these change behavior, only
1658
+ what's now logged: (1) the typos LSP's inject-vs-step-aside decision
1659
+ (#967) now logs a `typos_config_resolved` phase in `latency.log` /
1660
+ `sessionstart.log` with `mode: "project_config" | "injected_default"` and
1661
+ the resolved `configPath`, so which typos config is actually active is no
1662
+ longer a guess; (2) a project-snapshot body persist that falls back to the
1663
+ synchronous main-thread gzip because the persist worker died/was
1664
+ unavailable now logs an explicit `project_snapshot_worker_fallback` phase
1665
+ with the `reason` (previously only visible via a test-only variable, or
1666
+ buried in an `offloaded:false` success line) — this is the +656MB-risk
1667
+ degraded path from #950; (3) a corrupt/truncated gzipped snapshot body
1668
+ (gunzip/JSON.parse failure) now logs `project_snapshot_body_corrupt`
1669
+ before failing open to a rebuild, instead of being indistinguishable from
1670
+ "no snapshot yet"; (4) dropping the in-process authoritative snapshot
1671
+ entry for an oversized (>24MB) body now logs
1672
+ `project_snapshot_authoritative_dropped_oversized` — low-priority, but no
1673
+ longer silent.
75
1674
 
76
1675
  - **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`.
1676
+ (refs #936, #533) — a present resume checkpoint that's rejected now logs
1677
+ `checkpoint_discarded` with a `reason` (`corrupt`, `version_mismatch`,
1678
+ `not_in_progress`, `git_stamp_mismatch`, `ignored_ids_mismatch`,
1679
+ `removed_file`, `all_stale`) instead of silently falling back to a full cold
1680
+ rebuild, and a failed checkpoint write (worker error/death, promote failure,
1681
+ sync-write failure) logs `checkpoint_write_failed` — so "why isn't my
1682
+ checkpoint resuming / persisting?" is diagnosable from `review-graph.log`.
84
1683
 
85
1684
  - **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`.
1685
+ limit 2) — a cold full build (walk + tree-sitter parse of every source file)
1686
+ previously restarted from scratch every session, so on a large repo with
1687
+ short-lived sessions it could never finish. The extraction loop now
1688
+ periodically checkpoints the PRE-resolution graph plus the exact set of files
1689
+ already folded into it (with content hashes) to a dedicated
1690
+ `review-graph.checkpoint.json.gz`, and a later session resumes from it,
1691
+ re-walking only files that changed/appeared since. The checkpoint lives in
1692
+ its own file (never the authoritative `review-graph.json.gz`) and its
1693
+ hydrated graph carries `persistCoverage.inProgress` on top of `partial`, so
1694
+ no reader (`getCachedReviewGraph`, `loadPersistedGraph`) can ever serve or
1695
+ launder a mid-build checkpoint as a complete graph (honesty doctrine, #533).
1696
+ Resume equivalence to a cold build is guaranteed by `addFileToGraph`'s
1697
+ per-file contribution being order-independent (all cross-file linking is
1698
+ deferred to `resolveDeferredSymbolEdges`): content-changed processed files
1699
+ are evicted and re-walked, orphaned placeholder nodes are pruned, and any
1700
+ removed file, ignored-id-set change, version bump, or git-identity mismatch
1701
+ fails open to a cold build rather than risking a wrong graph. Checkpoint
1702
+ stride/interval are tunable via `PI_LENS_GRAPH_CHECKPOINT_EVERY_FILES` /
1703
+ `PI_LENS_GRAPH_CHECKPOINT_MIN_INTERVAL_MS`.
105
1704
 
106
1705
  - **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).
1706
+ (fixes #967) — the `typos` auxiliary LSP now injects a small pi-lens-shipped
1707
+ `_typos.toml` (`rules/typos/_typos.toml`, `[default.extend-identifiers]`
1708
+ only: `dito`, `unparseable`) via `initializationOptions.config` whenever a
1709
+ project has no `typos.toml`/`_typos.toml`/`.typos.toml` of its own. A
1710
+ project's own config still always wins outright: when one is found,
1711
+ pi-lens injects nothing at all (rather than letting typos-lsp merge ours
1712
+ on top and risk our defaults outranking the team's own allowlist on key
1713
+ collisions).
115
1714
 
116
1715
  - **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.
1716
+ telemetry now separates host boot from pi-lens evaluation and records quick
1717
+ and full session-start totals, pre-handler/bootstrap work, runtime reset,
1718
+ log cleanup, LSP reset, sequence/snapshot reads (including snapshot bytes),
1719
+ and delayed warmup scan/profile/index phases.
121
1720
 
122
1721
  - **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`.
1722
+ zero declarations, the builder may use `documentSymbol` from an already-live,
1723
+ already-open capable server. Nodes carry `provenance: "lsp"`, hierarchical
1724
+ containment survives persistence (including flat native-TypeScript-7 results
1725
+ reconstructed through `containerName`), productive tree-sitter files never
1726
+ pay the request, and unavailable/failed fallback attempts degrade without
1727
+ opening or spawning while remaining visible in `review-graph.log`.
129
1728
 
130
1729
  - **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.
1730
+ build-graph [--cwd <dir>]` reuses the session builder and queued atomic
1731
+ persist path for CI/cron, forces the debounced snapshot write before exit,
1732
+ and prints file/node/edge/element counts, JSON bytes, and duration. Unsafe
1733
+ roots, build errors/skips, persist failures, and persist-cap trips exit
1734
+ non-zero with their reason instead of silently leaving no snapshot.
136
1735
 
137
1736
  - **`/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.
1737
+ shows independent top-five p50 and p99 rankings with sample counts for both
1738
+ the current process session and the machine-wide active `latency.log` window.
1739
+ It flushes pending writes, streams a tail bounded by the log rotation
1740
+ threshold (`PI_LENS_MAX_LOG_SIZE_MB`, 10MB by default), caps retained samples,
1741
+ and reports malformed/truncated input instead of silently reading it as clean.
1742
+ Session startup total and scan-context computation are now logged as phases so
1743
+ the startup regressions that motivated the command are visible there too.
145
1744
 
146
1745
  ### Changed
147
1746
 
148
1747
  - **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).
1748
+ the interactive session-start path recomputed the project sequence by reading
1749
+ the ENTIRE append-only change log and folding every line — and each fold does a
1750
+ `normalizeMapKey`/`realpathSync.native()` syscall per historical entry, so the
1751
+ `session_start_sequence_read` phase (measured ~94ms, ~47% of a 200ms warm start)
1752
+ grew unbounded with total log length, not the working set. The project snapshot
1753
+ now embeds the DERIVED sequence index (`projectSeq` + per-file `fileSeqByPath`)
1754
+ as of its `seq` (`clients/project-snapshot.ts`), mirrored into the tiny
1755
+ `project-snapshot.meta.json` sidecar so session-start can hydrate it WITHOUT
1756
+ parsing the 40-112MB body (preserving the #947 skip-stale optimization).
1757
+ `readLatestProjectSequence` (`clients/project-changes.ts`) gained an optional
1758
+ base param: it hydrates that index (O(files-in-snapshot), keys already
1759
+ normalized → no per-key `realpath`) and folds ONLY entries with
1760
+ `seq > snapshot.seq` — O(changes-since-snapshot). The fold uses the SAME
1761
+ `Math.max`/`normalizeMapKey` the full replay does, so the result is
1762
+ byte-identical to a full replay (proven by an equivalence test suite covering
1763
+ no-new-entries, new/existing files, deletes, gaps/out-of-order, and the empty
1764
+ log) and order-independent. Correctness-first fallbacks to a full replay: a
1765
+ legacy/missing meta with no embedded index, a version-mismatched meta, and a
1766
+ snapshot whose `seq` is AHEAD of the log (truncation/rotation) — it never serves
1767
+ a wrong seq. Wired into BOTH the quick/interactive and the full session-start
1768
+ paths. Secondary: `log_cleanup` (~7ms) was moved off the synchronous critical
1769
+ path into a deferred `setImmediate` (it still runs every session and notifies
1770
+ async — nothing on the hot path consumed its result).
172
1771
 
173
1772
  - **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.
1773
+ #883 registry core (scope split, precedence, negation) was audited consistent
1774
+ and left untouched; the gaps were all in #533 malformed/unknown-input
1775
+ signalling, asymmetric between the global and project loaders. (1) The
1776
+ project loader (`.pi-lens.json`) now warns once on an unrecognized top-level
1777
+ key — a typo like `maxProjectFile` or `lps` previously did nothing silently —
1778
+ while tolerating the foreign namespaces the shared file legitimately carries
1779
+ for the LSP loader (`servers`, `serverOverrides`, `disabledServers`,
1780
+ `warmFiles`, plus `$schema`); a user-level-only lens key placed at project
1781
+ scope (e.g. `lsp`, `tests`, `delta`) gets a distinct "not honored at project
1782
+ scope" signal instead of being lumped in with typos. (2) The recognized-key
1783
+ catalogs are single-sourced (#883): `GLOBAL_NON_FLAG_CONFIG_SECTIONS` and
1784
+ `PROJECT_FOREIGN_CONFIG_NAMESPACES` are declared once beside `LENS_FLAGS`, and
1785
+ both loaders derive flag sections from the registry, so adding a namespace is
1786
+ a one-line edit and adding a flag needs none — replacing a drift-prone inline
1787
+ literal set in the global loader. (3) Three global scalars
1788
+ (`dispatch.runnerTimeoutFloorMs`, `widget.visible`, `format.mode`) that
1789
+ silently coerced a present-but-malformed value to `undefined` now warn on
1790
+ invalid input through the same path as `actionableWarnings.autoFix.maxFixes`,
1791
+ while staying silent when the key is absent (no false warnings). (4) Removed
1792
+ five dead global-only accessors that bypassed the precedence chain
1793
+ (`getGlobalAutoformatEnabled`, `getGlobalAutofixEnabled`,
1794
+ `getGlobalImmediateFormatDefault`, `getGlobalContextInjectionEnabled`,
1795
+ `getGlobalTurnSummaryEnabled`) — no non-test callers existed. (5) Updated
1796
+ `docs/globalconfig.md`, whose "unknown keys are ignored" claim was stale.
198
1797
 
199
1798
  - **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.
1799
+ (refs #179) — the last remaining adapter gap in #179's normalization sweep.
1800
+ `runtime-turn.ts`'s turn-end call-graph impact check (WillBreak/MayBreak
1801
+ callers of symbols edited this turn, via `impact()`/`call-graph.ts`) was
1802
+ advisory-text only, unlike the other 9 runners already normalized through
1803
+ `runner-adapters/*.ts`. Added `callGraphImpactToProjectDiagnostics`
1804
+ (`project-diagnostics/runner-adapters/call-graph-impact.ts`), a pure mapper
1805
+ attributing each diagnostic to the CALLER's file (no line — `impact()`'s
1806
+ `ImpactResult` never carries one, and the underlying call-graph symbol
1807
+ table only has a placeholder `line: 1`, so inventing one would be a fake
1808
+ location). Severity is deliberately capped below this codebase's
1809
+ "blocking" hard-stop tier for BOTH WillBreak and MayBreak (#533 honesty):
1810
+ `impact()` is a name-resolution heuristic with no type information, so a
1811
+ resolved caller is never a confirmed break. Wired at turn-end (not
1812
+ `fresh-fetch.ts`/`ANALYZER_IDS`) since impact analysis is inherently
1813
+ per-turn-edit, not a whole-project scan — same reasoning that keeps
1814
+ test-runner's own turn-end fire out of that registry. The existing
1815
+ human-readable advisory string is kept as-is (separate surface, same
1816
+ dual-surface pattern already used by knip's turn-end delta). The call-graph
1817
+ block now runs BEFORE the single `writeProjectDiagnosticsDeltaReport`
1818
+ serialization point (alongside knip's delta push) rather than after it —
1819
+ previously it pushed into `projectDiagnosticsDelta`/`projectDiagnosticsSources`
1820
+ past the one-shot write, so a call-graph-only turn persisted nothing and a
1821
+ mixed turn dropped the call-graph entries, leaving `lens_diagnostics` (a
1822
+ pure reader of the persisted report) unable to ever surface the findings
1823
+ (#533). Covered by a new `handleTurnEnd`-level regression test asserting the
1824
+ persisted report for both call-graph-only and mixed turns.
226
1825
 
227
1826
  - **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`).
1827
+ turn-end circular-dependency pass previously ran one `await checkFile()`
1828
+ per import-changed file in a sequential `for…await` loop, serializing N
1829
+ madge subprocess spawns. `DependencyChecker.checkFilesBatch()` now runs
1830
+ those spawns concurrently (bounded to 6 in flight) via a new turn-end
1831
+ entry point. `DependencyChecker` keeps its circular-dep findings
1832
+ (`lastCircular`/`circularFiles`) as shared instance state that a single-file
1833
+ check overwrites wholesale, so naively parallelizing risked one file's
1834
+ spawn clobbering a sibling's write depending on subprocess completion
1835
+ order. The fix keeps every spawn's result local until all have settled,
1836
+ then folds them into the shared state in original file order — matching
1837
+ the sequential loop's file-by-file overwrite exactly, just without waiting
1838
+ for each subprocess before starting the next. Behavior-preserving: same
1839
+ per-file results, same final circular-dep state, same `dbg` logs — only
1840
+ wall-clock time changes.
1841
+
1842
+ - **Every runtime toggle is now settable from BOTH the CLI and `~/.pi-lens/config.json`, driven by one declarative registry (closes #166)** —
1843
+ the flag/config mapping used to live in two disconnected places (twelve
1844
+ `pi.registerFlag` calls in `index.ts` and an if/else chain in
1845
+ `resolvePiLensFlagWithSource`), which let coverage gaps open and persist.
1846
+ Seven flags were registered on the CLI but fell straight through the
1847
+ resolver, so config could never set them: `--no-lens`, `--no-lsp`,
1848
+ `--no-tests`, `--no-delta`, `--lens-guard`, `--no-opengrep`, and
1849
+ `--no-read-guard`. They now read `lens.enabled`, `lsp.enabled`,
1850
+ `tests.enabled`, `delta.enabled`, `guard.enabled`, `opengrep.enabled`, and
1851
+ `readGuard.enabled` (positive keys — a `--no-*` flag is the key set `false`).
1852
+ The four actionable-warning toggles went the other way: they were
1853
+ config-only and are now registered CLI flags too
1854
+ (`--lens-actionable-warnings`, `--lens-actionable-warning-actions`,
1855
+ `--lens-actionable-warning-autofix`, `--lens-actionable-warning-all`).
1856
+ `clients/lens-flag-registry.ts` is the single source both surfaces read, so
1857
+ they cannot diverge again; the wiring test derives its expected flag set from
1858
+ it rather than restating it (the old hand-written list had already drifted,
1859
+ missing `lens-turn-summary`).
262
1860
 
263
1861
  - **`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.
1862
+ documented in `globalconfig.md` since #792 but no loader ever parsed it, so
1863
+ `agent_end` always used the hardcoded default of 5 no matter what the config
1864
+ said. Same defect class as the seven flags above: a documented config key
1865
+ with nothing wiring it to the code that consumes it. Accepts any
1866
+ non-negative whole number; `0` keeps the warning report while applying
1867
+ nothing.
1868
+
1869
+ - **Flag provenance gained an `env` tier and reports the global tier more accurately** (#166) — `PI_LENS_NO_CONTEXT_INJECTION` moved out of a
1870
+ hardcoded check in `index.ts` into the registry's `env` binding, so it
1871
+ resolves through the same chain as everything else (env → cli →
1872
+ nested-project → project → global → default) and reports `source: "env"`.
1873
+ The global tier now reports `source: "global"` whenever the key is present,
1874
+ not only when it happens to differ from the built-in default. Affects debug
1875
+ and mutation-skip log lines only, no resolved values change.
1876
+
1877
+ - **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`
1878
+ previously did nothing with no signal. The recognized-key set is derived from
1879
+ the flag registry (`LENS_FLAGS`) plus the non-flag sections (`ignore`,
1880
+ `dispatch`, `actionableWarnings`, `widget`) and `$schema`, so it stays in sync
1881
+ with the single source of truth; anything else logs one `[pi-lens] ignoring
1882
+ invalid global config …: unknown key "…"` line and is ignored.
1883
+
1884
+ - **The review-graph resume checkpoint (#936) now offloads its gzip to the shared persist worker (refs #958, #883)** — mid-build checkpoint writes
1885
+ previously ran a synchronous `gzipSync` of the growing graph on the event
1886
+ loop; they now stream the stringify+gzip through the same worker the
1887
+ authoritative snapshot uses (via the newly-shared `writeGzipStageFile` core),
1888
+ generation-gated so a slow write can't clobber a newer checkpoint or
1889
+ resurrect one after the build completes and retires it, and falling back to a
1890
+ synchronous write when the worker is unavailable. Best-effort as before — a
1891
+ lost checkpoint only costs a cold rebuild.
297
1892
 
298
1893
  - **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.
1894
+ #958 item 2) — the snapshot body (40-112MB observed) is persisted as
1895
+ `project-snapshot.json.gz`, with the `JSON.stringify` + gzip run on a worker
1896
+ thread off the save path, mirroring the review graph's
1897
+ `persist-worker.ts`/generation-gated-promotion pattern (gzip measured 5-10x
1898
+ on top of the #957 compaction win; the review-graph's own measurement was
1899
+ 60MB → 1.4MB). A slow worker write for generation N is discarded rather than
1900
+ promoted over a newer generation N+1 already on disk, and the loader still
1901
+ reads the previous uncompressed `project-snapshot.json` for one compatibility
1902
+ release so an upgrade never loses a snapshot. The save path deliberately does
1903
+ NOT sync-gzip (the #950 review measured a naïve sync gzip regressing host
1904
+ memory by +656MB); when the worker is unavailable/dies the pending body falls
1905
+ back to a synchronous main-thread gzip write, surfaced via the logger rather
1906
+ than silently presented as saved (#533). Read-your-writes across the async
1907
+ promotion is preserved by an in-process authoritative "latest write" that
1908
+ `loadProjectSnapshot` consults before disk, so the merge-write callers
1909
+ (`saveRuntimeProjectSnapshot`, word index, reverse deps) never observe a
1910
+ stale body in the promotion window.
316
1911
 
317
1912
  - **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.
1913
+ the persisted serializer now carries per-file mtimes. Startup still performs
1914
+ the bounded source walk, but reuses unchanged postings, re-tokenizes only
1915
+ stale/new files, and drops deleted files; legacy indexes, refresh failures,
1916
+ and file-set churn above 30% fall back to a full rebuild. The current derived
1917
+ file cap and `truncated` state are re-evaluated on every refresh, and
1918
+ `warmup_word_index` telemetry records mode/refreshed/dropped/reused counts.
324
1919
 
325
1920
  - **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.
1921
+ #939) — each fully completed scanner file publishes content-hash-bound
1922
+ imports, reexports, function summaries, symbols, and references. A following
1923
+ or overlapping graph build reuses only exact-hash, successful entries;
1924
+ stale, failed, absent, and cold one-shot paths parse normally. The handoff
1925
+ retains neither source content nor WASM trees, and cancelled scans expose
1926
+ only files completed before cancellation.
332
1927
 
333
1928
  - **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.
1929
+ measured startup wins: (1) the dominant-language LSP pre-warm was gated on
1930
+ full startup mode that the first-session heuristic never allows (82 quick
1931
+ vs 0 full starts in 31k dogfood log lines), so it now runs inside the
1932
+ quick-mode +2s background warmup pass — once per process,
1933
+ generation-guarded, honoring subagent light mode, warm-attach, the
1934
+ `no-lsp` flag, and the `canWarmCaches` guard, with a `warmup_lsp_prewarm`
1935
+ phase record; (2) session start no longer sync-parses the whole
1936
+ `project-snapshot.json` body (110-130ms at 40MB, ~0.5s at 112MB) before
1937
+ checking freshness — the tiny `project-snapshot.meta.json` sidecar is read
1938
+ first and a stale seq/version skips the body parse entirely
1939
+ (`session_start_snapshot_load` records `skippedStale: true`), with
1940
+ missing-meta installs falling back to the legacy parse; (3) the snapshot
1941
+ is serialized compactly (~30% smaller) and its parsed body is cached
1942
+ in-process per (path, mtime), so `saveRuntimeProjectSnapshot` and the
1943
+ session-start/word-index/scan-context consumers no longer re-parse a file
1944
+ pi-lens itself wrote seconds earlier.
350
1945
 
351
1946
  - **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.
1947
+ read expansion keeps tree-sitter's line boundaries authoritative, but an
1948
+ already-open document with an already-active LSP can now replace the display
1949
+ name/kind from `documentSymbol` (including `Class.method` ancestry) within a
1950
+ 150 ms best-effort ceiling. Cold, closed, unsupported, timed-out, or failed
1951
+ servers retain the tree-sitter identity, and `ts_range_expanded` records
1952
+ whether enrichment succeeded.
358
1953
 
359
1954
  - **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.
1955
+ and archive extraction now use the shared safe-spawn path, await full Windows
1956
+ process-tree termination on timeout, and synchronously clean registered
1957
+ installer children during parent exit/signals.
363
1958
 
364
1959
  - **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.
1960
+ dependency-free atomic lock protects the shared tools tree, verifies owners
1961
+ before stale recovery, bounds lock waits with an honest error, and rechecks
1962
+ discovery after acquisition to avoid duplicate package-manager runs.
368
1963
 
369
1964
  - **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.
1965
+ `PI_LENS_DISABLE_TOOL_INSTALL=1`, its prewarm step creates a local synthetic
1966
+ oxlint probe-cache entry without networking, and one-shot analysis explicitly
1967
+ awaits probe-cache persistence before exit.
373
1968
 
374
1969
  - **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.
1970
+ fake package-manager coverage verifies Windows timeout tree-kill, exactly one
1971
+ install across concurrent processes, explicit install-disable refusal, and
1972
+ Vitest's default no-install environment.
378
1973
 
379
- - **Downgrade TypeScript `unsafe-regex` to advisory and suppress escaped-before-
380
- assignment false positives** (refs #932) — the coarse dynamic `RegExp`
1974
+ - **Downgrade TypeScript `unsafe-regex` to advisory and suppress escaped-before- assignment false positives (refs #932)** — the coarse dynamic `RegExp`
381
1975
  heuristic no longer blocks edits and recognizes escape/replace calls in a
382
1976
  same-file identifier initializer; structural ReDoS detection remains with
383
1977
  the `redos-nested-quantifier` ast-grep rule.
384
1978
 
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.
1979
+ - **Review-graph persistence no longer serializes or compresses on the event loop (refs #939)** — debounced snapshots are materialized in one lazy,
1980
+ unref'd worker and streamed through gzip into the new canonical
1981
+ `review-graph.json.gz` cache. The main thread promotes only the current
1982
+ generation's atomic staged file, so the synchronous CLI/exit flush can
1983
+ supersede an in-flight worker without a stale overwrite. Loads retain one
1984
+ release of fallback support for legacy uncompressed `review-graph.json`
1985
+ snapshots; worker failures are logged and degrade to a synchronous persist.
1986
+ Persist telemetry now records element count, raw/gzip bytes, serialization
1987
+ and write time, and whether the work was offloaded.
395
1988
 
396
1989
  - **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.
1990
+ default `GRAPH_PERSIST_MAX_ELEMENTS` cap is now 500,000 (still overrideable
1991
+ through `PI_LENS_GRAPH_PERSIST_MAX_ELEMENTS`), matching measured startup
1992
+ load/reindex costs and allowing the ~208,000-element #919 repository to
1993
+ persist completely. Above the cap, persistence retains whole-file node groups
1994
+ in existing reverse-dependency-centrality order plus induced edges that fit,
1995
+ instead of dropping the snapshot. Exact total-versus-persisted node/edge
1996
+ counts are stored and surfaced; read-only queries may use the partial graph,
1997
+ while incremental builds reject it as a complete base.
405
1998
 
406
1999
  - **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
2000
 
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.
2001
+ - **Incremental review-graph updates avoid redundant whole-graph copies and index rebuilds (refs #939)** — file re-extraction now rebuilds derived indexes
2002
+ once, immutable edges are array-copied without cloning every edge object, the
2003
+ updated graph itself becomes the workspace snapshot, and debounced persistence
2004
+ defers its O(graph) array materialization until the quiet-window flush.
413
2005
 
414
2006
  - **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.
2007
+ instead of rebuilding from every graph edge after a one-file edit. Body-only
2008
+ edits reuse the cached index without rewriting the project snapshot; import
2009
+ changes patch only the touched `imports` and `importedBy` buckets.
418
2010
 
419
2011
  - **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.
2012
+ #921) — `project_report` says a capped project has “more than N files” instead
2013
+ of presenting the cap+1 early-exit sentinel as an exact count. `module_report`
2014
+ now marks graph-backed `usedBy`, blast-radius provenance, and
2015
+ `semantic.source` as `unavailable:file-cap` and emits an actionable warning
2016
+ with the cap plus both `.pi-lens.json#maxProjectFiles` and
2017
+ `PI_LENS_REVIEW_GRAPH_MAX_FILES` controls, keeping disabled data distinct from
2018
+ a genuinely empty/cold graph.
427
2019
 
428
2020
  - **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.
2021
+ #939) instead of retaining source content, imports, summaries, and other
2022
+ derived per-file facts until the scan ends. The live dispatch store remains
2023
+ untouched; only the scanner-owned store is cleared.
432
2024
 
433
2025
  - Repair eight non-compiling Java, C++, CSS and PHP tree-sitter rules (refs #884).
434
2026
 
435
2027
  - Repair four non-compiling Go, Rust, and Kotlin tree-sitter rules (refs #884).
436
2028
 
437
2029
  - **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.
2030
+ tree-sitter rules, fact rules, and bundled ast-grep share each eligible
2031
+ file's content read while retaining their individual extension/size gates,
2032
+ diagnostic ordering, cancellation behavior, and latency telemetry. Full
2033
+ review-graph builds likewise hash the bytes already read for extraction
2034
+ instead of rereading every file after the graph is built.
443
2035
 
444
2036
  - **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**.
2037
+ (refs #675) — `runQueriesOnFile` compiles a language's rules into a single
2038
+ multi-pattern query and maps matches back to the owning rule, keeping per-rule
2039
+ metavars, predicates, post-filters, caps and ordering. Measured at 3.3× on
2040
+ identical matches; the scan's tree-sitter phase went **14.4s → 3.6s** and a
2041
+ full `mode=full` scan of pi-lens **24.4s → 12.2s**.
450
2042
 
451
2043
  - **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.
2044
+ the fact rules both tree-sitter-parse the same files, and running them as two
2045
+ whole-project sweeps meant the second one re-parsed every file the first had
2046
+ already parsed and evicted (357 of 357 first touches missed on capacity in a
2047
+ 500-file `mode=full` scan). They now share one file-major pass: **722 parses →
2048
+ 367, 892ms → 437ms of parse time, zero capacity misses**, same diagnostics in
2049
+ the same order. The 50-entry cache is unchanged and was never the problem —
2050
+ within a phase it already ran at a 96.8% hit rate, any capacity short of the
2051
+ full working set saves nothing, and one big enough to hold it would cost
2052
+ 150-300MB of resident WASM trees.
461
2053
 
462
2054
  - **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.
2055
+ consumer, including partial-read expansion, uses the shared process-wide
2056
+ client, and scope-isolated cache counters plus parser timing are emitted for
2057
+ project scans and full review-graph builds.
466
2058
 
467
2059
  - **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.
2060
+ `calculateEdit`, and `invalidate` had no callers; the header's "10-100×
2061
+ speedup on edits" was advertising a path nothing ever took.
470
2062
 
471
2063
  - **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.
2064
+ resolves on the child's exit event instead of sleeping the full 1.5s
2065
+ SIGTERM→SIGKILL escalation window (saves ~1.5s per graceful client
2066
+ shutdown); tool availability checks skip `--version` spawns for binaries
2067
+ that aren't on disk and no longer re-verify a probe-cache-validated managed
2068
+ install on every analyze.
477
2069
 
478
2070
  ### Fixed
479
2071
 
480
2072
  - **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".
2073
+ guard's read-set was in-memory only, so a `pi --session <id>` resume (which
2074
+ resets the runtime to a fresh empty guard) falsely `zero_read`-blocked the
2075
+ first edit of any file read in the prior session. The read-set now rides the
2076
+ same #190 `PersistedSessionState` save/load path that widget diagnostics use:
2077
+ `ReadGuard.exportState()`/`importState()` persist and rehydrate `reads`,
2078
+ reconciling each read against current disk (line-hash verified) so a resume
2079
+ drops any read whose file changed or vanished — a rehydrated read never masks
2080
+ a real staleness. Forked sessions adopt the parent's read-set via the same
2081
+ in-memory hand-off as widget state. Backward-compatible: the field is optional
2082
+ and pre-#1041 persisted sessions load cleanly as "no prior reads".
491
2083
 
492
2084
  - **`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.
2085
+ the `agent_settled` quiet-window task `turn_summary_emit` reads the
2086
+ `lens-turn-summary` flag through `pi.getFlag()` (via `getLensFlag`). The task
2087
+ holder's `pi` is refreshed on every activation, but the quiet window is fired
2088
+ fire-and-forget from `agent_settled`, so an interim session
2089
+ replacement/reload (`ctx.newSession/fork/switchSession/reload` — common in the
2090
+ in-process subagent flow #473) invalidates that captured `pi`; the very next
2091
+ `pi.getFlag()` then throws the SDK's stale-ctx guard. That throw sat OUTSIDE
2092
+ the existing `sendMessage` try/catch, so it escaped the task and the scheduler
2093
+ logged `quiet_window: task "turn_summary_emit" failed: … stale after session
2094
+ replacement or reload` — 55 times across two weeks of dogfooding, the single
2095
+ most frequent error in `~/.pi-lens/sessionstart.log`, and it fired even with
2096
+ the feature OFF (the flag READ throws before the flag value is known). Fix:
2097
+ the task now recognises the stale-ctx throw (new `isStaleExtensionCtxError`
2098
+ message matcher) at both pi touch points (flag read + emit) and degrades to a
2099
+ logged no-op — the session that run's summary belonged to is gone, so there is
2100
+ nothing to emit into — while a genuine error still propagates to the scheduler
2101
+ (recorded `ok:false`). User impact was minimal (a lost summary for the exact
2102
+ settle that raced a session swap, plus log noise), not silent feature
2103
+ degradation: in steady state the task runs `ok:true`. Separately, the
2104
+ quiet-window runner now logs the failing task's **stack** (not just `${err}`'s
2105
+ message) so the next failure is diagnosable from the log alone; it remains
2106
+ strictly non-fatal (per-task try/catch, `runQuietWindow` never rethrows, and
2107
+ `agent_settled` invokes it fire-and-forget — a failing task can never break the
2108
+ turn). Regression test drives the registered task against a `pi` whose
2109
+ `getFlag` throws the stale-ctx error and asserts a no-op (no throw, no emit);
2110
+ it fails pre-fix.
519
2111
 
520
2112
  - **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).
2113
+ the word index's `docLengths`/`forward`/`fileMtimes` maps are keyed on file
2114
+ paths, but the full/incremental BUILD keyed on the on-disk casing the
2115
+ `readdirSync` walk reports while the per-edit UPDATE seams
2116
+ (`clients/dispatch/integration.ts`, `clients/mcp/analyze.ts`) key via
2117
+ `path.resolve()` on the raw tool-input path. When those forms differed only by
2118
+ separator (`\` vs `/`, always) or case (on a case-insensitive FS), the plain-`Map`
2119
+ keying made `forward.has(doc.path)` MISS, so the stale build-form entry was never
2120
+ removed — a duplicate doc entry with stale postings served until a full rebuild,
2121
+ silently degrading `symbol_search` relevance (the path-key divergence class,
2122
+ #1020/#210). Fix: a new typed **`PathKeyedMap<V>`** primitive
2123
+ (`clients/path-keyed-map.ts`) folds every key through a caller-supplied
2124
+ normalizer INTERNALLY, so keying a raw path is structurally impossible; the word
2125
+ index now routes all three path maps through it using the shared, exported
2126
+ `wordIndexKey` (= `normalizeEphemeralMapKey` — cheap slash-fold + win32-lowercase,
2127
+ NO `realpathSync`, so the hot BM25 path adds no filesystem I/O), and the
2128
+ token-keyed `postings` cleanup compares `WordHit.file` through the same
2129
+ normalizer, and the incremental-refresh set-difference
2130
+ (`refreshWordIndexIncrementally`) now computes its `current`/`oldSet` keys in
2131
+ that same normalized space (previously raw-keyed — a file whose stored display
2132
+ path became the edit form would have been double-counted as churn and
2133
+ dropped-then-re-added, defeating the incremental path and opening a
2134
+ drop-before-readd regression window). `PathKeyedMap` stores each value's
2135
+ ORIGINAL display path, so `symbol_search`/rendering surfaces still see the
2136
+ on-disk form, not the folded key. Build-form, edit-form, and refresh keys now
2137
+ converge on ONE entry regardless of casing/separator. The runtime-coordinator suspect maps (#1025 item #4) are left
2138
+ for a follow-up (unproven trigger, and their correct normalizer is the
2139
+ realpath-based `normalizeMapKey` matching the file's own `_fileSeq` convention —
2140
+ a larger, higher-risk change across the concurrent-session drain logic).
549
2141
 
550
2142
  - **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.
2143
+ the cascade impact subsystem previously emitted an all-clear that was
2144
+ indistinguishable from "genuinely nothing impacted" whenever it could NOT
2145
+ compute impact: the review graph was size-skipped (`too_many_files` over
2146
+ `PI_LENS_REVIEW_GRAPH_MAX_FILES`) or root-skipped (`unsafe_root`), the changed
2147
+ file had no graph node, or the deferred compute threw — a silent under-report
2148
+ exactly where downstream breakage is most likely (large/over-cap monorepos)
2149
+ (#533). Impact results now carry a first-class `indeterminate` marker
2150
+ (`clients/review-graph/types.ts`) set ONLY on a degraded/cold/errored/
2151
+ missing-node compute; the already-known degraded state is threaded from
2152
+ `getLastGraphBuildInfo().mode`/`skipReason` at the compute call site (no
2153
+ re-derivation), a `missing_node` marker comes straight from
2154
+ `computeImpactCascade`, and a thrown compute is tagged in the pipeline catch. A
2155
+ new `indeterminate` `CascadeSkipReason` flows to the EXISTING turn-end seam
2156
+ (`clients/runtime-turn.ts`), which renders a short honest note in the ADVISORY
2157
+ tier ("Cascade could not compute downstream impact … a clean cascade result
2158
+ does not cover them") instead of silence. The advisory tier (not the blocker
2159
+ tier) is deliberate: an over-cap monorepo skips the graph on every edit, so a
2160
+ blocker would fire hard and never clear turn state every turn (over-escalation,
2161
+ the mirror of the silent-all-clear bug). A HEALTHY graph with a genuinely empty
2162
+ dependent set stays silent (keyed strictly off the degraded mode/marker, never
2163
+ off `neighbors.length === 0` alone — no crying wolf). Companion: the Tier-3 quiet-window reconcile
2164
+ (`clients/lsp/cascade-tier.ts`) now RE-INJECTS a `resolved-found` cold-neighbor
2165
+ error through the same neighbor→turn-end formatting via an `onResolvedFound`
2166
+ callback wired in `index.ts`, instead of leaving it logs-only. The
2167
+ neighbor-touch/freshness pipeline (neighborTouchCache, recentlyCleanNeighborCache,
2168
+ the 40-neighbor budget, tier-aware touch model, deferred cascade flow) is
2169
+ untouched.
578
2170
 
579
2171
  - **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.
2172
+ a `false-positive`/`flagged`/`defer` mark recorded via `lens_diagnostic_mark`
2173
+ was invisible to the per-edit dispatch filter for every file living under a
2174
+ nested language-root marker (`packages/app/tsconfig.json`, a nested
2175
+ `package.json`/`pyproject.toml`/`go.mod`/etc.) in a monorepo. The mark tool
2176
+ writes dispositions keyed on the **project root** (`runtime.projectRoot`), but
2177
+ `dispatcher.ts`'s `applyDispositions` read keyed on `ctx.cwd`, which
2178
+ `createDispatchContext` resolves to the nearest **language root** via
2179
+ `resolveLanguageRootForFile` (a dispatch-internal tool/config-resolution
2180
+ detail). Because both the anchor AND the persisted store location
2181
+ (`getProjectDataDir`, keyed on `cwd`) derive from that base, write and read
2182
+ computed different anchors AND opened entirely different
2183
+ `diagnostic-dispositions.json` files — a total, deterministic failure (not
2184
+ probabilistic) for every file under a nested marker. The tool returned success
2185
+ while dispatch never saw the mark (a #533 honesty failure); `suppress` survived
2186
+ only via its independent inline `pi-lens-ignore` comment. Fixed by reading the
2187
+ disposition layer from the project root (`ctx.projectRoot`, already computed in
2188
+ `createDispatchContext`), matching the write side — anchor base and store base
2189
+ now agree on both sides. `resolveLanguageRootForFile` is untouched and still
2190
+ drives actual dispatch/tool-root resolution. Orthogonal to #1024 (which fixes
2191
+ the path *form* of the anchor); the two compose for a monorepo-stable anchor.
2192
+ No new dependencies.
601
2193
 
602
2194
  - **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.
2195
+ `dd:`/`ddw:` disposition anchors (clients/diagnostic-dispositions.ts) are now
2196
+ derived from a single canonical path form, closing a #533-adjacent
2197
+ dropped-signal bug (sibling of #210/#1020). `relativeFile` — the chokepoint
2198
+ both `computeStrictAnchor` and `computeWeakAnchor` route through — computed
2199
+ `path.relative` on whichever raw path form the caller happened to hold: the
2200
+ mark tool (tools/lens-diagnostic-mark.ts) passes a RAW `cwd` /
2201
+ `path.resolve(cwd, arg)`, while the dispatch read side
2202
+ (clients/dispatch/dispatcher.ts `createDispatchContext`) passes
2203
+ `normalizeMapKey`-canonicalized (realpath'd) cwd/filePath. When the two forms
2204
+ diverged (Windows drive/segment case, symlink/realpath), the agent's own
2205
+ `false-positive`/`flagged` mark anchored under one id and the later
2206
+ `applyDispositions` lookup under another, so the mark was invisible and the
2207
+ "resolved" diagnostic kept re-firing / re-blocking every turn. Fix routes
2208
+ BOTH `cwd` and `filePath` through `normalizeMapKey` inside `relativeFile`
2209
+ (the same normalizer the read side already relies on — the #210 "every guard
2210
+ map keys through `normalizeFilePath`" invariant), so write and read derive
2211
+ identical anchors regardless of the form the caller passed. Anchor semantics
2212
+ are unchanged: strict (`dd:`, line-hash) vs weak (`ddw:`) split, the prefixes,
2213
+ and the `..`-escape fallback all behave exactly as before — only the path
2214
+ component is now form-stable. Existing on-disk marks written under the old raw
2215
+ form re-anchor to the canonical id; a stale orphan is re-derivable and simply
2216
+ reappears once for the agent to re-mark (identical to today's pre-fix
2217
+ behavior), so no read-time legacy fallback is needed.
626
2218
 
627
2219
  - **`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.
2220
+ the widget-state `files` map was keyed by the raw, non-normalized path string,
2221
+ so the SAME file could land under two different key forms in one session:
2222
+ the forward-slash form (`C:/…/x.ts`) the LSP client + cascade fold produce via
2223
+ `normalizeFilePath`, and the backslash form (`C:\…\x.ts`) that `mode=full`'s
2224
+ clean reconcile (`result.filePath`) and `path.resolve`/event inputs produce on
2225
+ Windows. A stale blocking entry and the fresh clean entry then coexisted as two
2226
+ separate map entries. `mode=full` rendered clean because its merge re-keys every
2227
+ summary through `path.resolve`, but `mode=all`'s `formatAllMode` reads
2228
+ `getFileDiagnosticSummaries()` verbatim with no dedup, so it saw both and
2229
+ rendered the stale entry's `blocking:1` as a 🔴 — a resolved state that replayed
2230
+ as still-broken on every `mode=all` (worst on Windows + resumed sessions), and a
2231
+ #533 honesty failure since the tool prompt tells agents to use `mode=all` to
2232
+ verify no blockers remain. Fixed at source: every write/read seam on the `files`
2233
+ map (and the `diagnosticsWriteGuard`) now folds its key through one normalizer,
2234
+ `normalizeEphemeralMapKey` (slash-fold + win32-lowercase, no filesystem I/O —
2235
+ chosen over the `realpathSync`-backed `normalizeMapKey` because this is a hot
2236
+ write path), and `importWidgetState` folds persisted keys on rehydrate so a
2237
+ `/`-key snapshot and a fresh `\`-key write collapse across a resumed session.
2238
+ The human-readable display path on each record is preserved verbatim (rendering
2239
+ and path-relative math unchanged). No new dependencies.
648
2240
 
649
2241
  - **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.
2242
+ `context` event handler in `index.ts` now splices pi-lens's ephemeral turn-end
2243
+ findings in **immediately before the final message** instead of prepending them
2244
+ at message index 0. Prepending flipped `messages[0]` every turn, which
2245
+ invalidated the entire prompt-cache prefix on every prefix-caching provider
2246
+ (Anthropic, Bedrock, and OpenAI — all key the cache on the exact token prefix).
2247
+ Inserting before the last message keeps `messages[0]` (the real first user turn)
2248
+ byte-stable so the prior conversation stays cached, while still keeping the real
2249
+ user prompt as the trailing message — preserving the trailing-`user` cache
2250
+ breakpoint and the historical `fe0ed5da` guarantee that input is never empty (the
2251
+ existing transcript is always preserved, never dropped; an empty transcript falls
2252
+ back to the prior prepend-only behavior). Because the `context` event fires
2253
+ before **every** provider call (not just at turn boundaries), a trailing-role
2254
+ guard (`isPlainUserPrompt`) only splices before the last message when it is a
2255
+ plain user prompt; when the tail is a mid-loop `tool_result` (or assistant/tool
2256
+ message) the findings are **appended** after the whole transcript instead — a
2257
+ pure append that both preserves the `tool_use`↔`tool_result` adjacency all three
2258
+ providers require (splicing between them is an HTTP 400) and keeps the entire
2259
+ prior transcript as an untouched cache prefix. Changes shipped `dist` behavior
2260
+ (the handler lives in the bundled dist path).
2261
+
2262
+ - **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
2263
+ OAuth/profile endpoints as SSRF: `fetch(authUrl.toString())` where
2264
+ `authUrl = new URL("auth/authorize", \`${BASE_URL_CLINE}/\`)` with a fixed
2265
+ (module-const or imported) base. The `ts_ssrf_sink` post-filter already
2266
+ exempted a `fetch()` of a file-local `const` literal string (#963) but had no
2267
+ awareness of the `new URL(literalPath, fixedBase)`shape, so
2268
+ `url.toString()`/`url.href` fell through to the broad taint heuristic. Extended
2269
+ the post-filter to also exempt `fetch(u.toString())`/`fetch(u.href)` when `u`
2270
+ resolves (same file) to a clean `const u = new URL(<literalPath>, <fixedBase>)`
2271
+ — literal path plus a base that is a literal, a file-local literal `const`, or
2272
+ an import binding (the`URL` ctor may be aliased, e.g. `NodeURL`). Query params
2273
+ added later via`searchParams.set(...)` don't control origin/path and never
2274
+ taint the destination. Still fires (regression fixtures added) when the base
2275
+ or path comes from a function parameter, `process.env`/config (including a
2276
+ const initialized from`process.env`), request data, or a parsed
2277
+ redirect/`location`value — a missed SSRF stays far worse than one FP.
2278
+ Adversarial review then closed two bypasses in that exemption: (1) a
2279
+ post-construction origin mutation (`u.host = req.x` / `u.href = …`/
2280
+ `u["host"] = …`, incl. augmented assignments) now re-taints the receiver and
2281
+ fails closed — only query-only writes (`u.search`/`u.searchParams`) stay
2282
+ exempt; (2) a request-tainted function *parameter* base is no longer exempted
2283
+ just because an unrelated same-named module-level`const` literal exists
2284
+ (scope-aware shadow check; imported bases stay trusted).
2285
+
2286
+ - **Fixed: `no-javascript-url`/`no-javascript-url-js` no longer flag defensive `javascript:`-URL filters (refs #533)** — a dogfood run flagged code that was
2287
+ *rejecting* `javascript:` links (e.g. `url.startsWith("javascript:")` inside a
2288
+ guard) as if it were introducing one. The rule matched the `javascript:`
2289
+ string literal alone with no way to tell "used as a sink" from "used to
2290
+ detect/block it". Narrowed both rules to also exclude the literal when it's
2291
+ the needle in `.startsWith`/`.endsWith`/`.includes`/`.indexOf`/`.search`/
2292
+ `.match`, or one side of an `===`/`!==`/`==`/`!=` comparison — deliberately
2293
+ NOT `.replace`/`.replaceAll`, since the literal there could be the malicious
2294
+ replacement argument rather than the defensive search argument, and
2295
+ excluding the whole call would hide that true positive. Both new exclusions
2296
+ deliberately avoid `stopBy: end` (unlike the pre-existing test-literal
2297
+ exclusion, which needs it) so the literal must be a DIRECT argument/operand
2298
+ of the defensive call/comparison, not merely somewhere in its subtree — an
2299
+ adversarial review caught that `stopBy: end` let a real sink smuggled
2300
+ inside the call/comparison (e.g. `candidates.includes(link.href =
2301
+ "javascript:...")`, a comma-operator sink, or `(a.href = "javascript:...")
2302
+ === expected`) go unflagged. Added valid fixtures reproducing the filter FP
2303
+ and invalid fixtures for the replace-based sink and all three smuggled-sink
2304
+ bypasses, confirmed against the real ast-grep 0.45.0 binary.
2305
+
2306
+ - **Contained spawn/callback failures that could crash the host (pidusage bug class) (refs #533)** — a best-effort telemetry sampler recently killed a live
2307
+ pi host with `Error: spawn UNKNOWN` (uncaughtException) because a bundled
2308
+ dep's `child_process.spawn(...)` threw SYNCHRONOUSLY with no try/catch, from
2309
+ a detached context the caller's `try/await/catch` could not contain. Audited
2310
+ every spawn/detached-callback site in `clients/` and hardened the at-risk
2311
+ best-effort/background ones to the repo's existing guard shape (try/catch
2312
+ around the synchronous `spawn` + resolve gracefully, matching the async
2313
+ `'error'`-listener path): `safeSpawnAsync`'s core spawn (the shared wrapper
2314
+ dozens of best-effort callers rely on to NEVER reject), its Windows
2315
+ `killPidTreeSync` (runs from `process` exit/signal handlers, where a throw
2316
+ becomes an uncaughtException during shutdown; the POSIX branch was already
2317
+ guarded), `SgRunner.exec`'s three ast-grep spawn branches, all six installer
2318
+ tool-discovery/verify probes, the PSScriptAnalyzer runner's `spawnPs`, and
2319
+ the MCP `analyzeFileFresh` worker fork. A synchronous spawn throw now
2320
+ resolves the operation's normal failure value instead of escaping as an
2321
+ unhandledRejection/uncaughtException. Behavior-preserving otherwise.
2322
+
2323
+ - **Fixed: opengrep security findings now surface project-wide in `lens_diagnostics mode=full` (refs #585, #584, #533)** — opengrep's
2324
+ whole-tree CLI scan ran at session-start and cached its findings, and the
2325
+ cache-only extractor registry (`project-diagnostics/extractors.ts`) even
2326
+ registered an `"opengrep"` row — but #585 replaced that registry in
2327
+ production with `fetchFreshProjectDiagnostics`
2328
+ (`project-diagnostics/fresh-fetch.ts`), whose `ANALYZER_IDS` list omitted
2329
+ opengrep. Net: opengrep scanned + cached, yet nothing production read it
2330
+ back, so semgrep-grade security findings (ERROR→blocking, CWE-tagged) never
2331
+ reached the agent for unedited/project-wide files — a scan-and-orphan
2332
+ honesty gap (#533). `fetchFreshProjectDiagnostics` now runs opengrep the
2333
+ same way it runs gitleaks/trivy: an availability probe (opengrep is
2334
+ structurally always-on, so no static project-type gate), then a fresh scan
2335
+ that JOINS the session-start scan of the same root via
2336
+ `SecurityScanClient.dedupeScan` rather than double-spawning a heavy scan,
2337
+ with the result written back to cache and adapted to project diagnostics.
2338
+ The per-edit aux-LSP push path (`clientScope: with-auxiliary`,
2339
+ `AUXILIARY_LSP_PROFILES`) was verified already-working and is untouched;
2340
+ opengrep stays excluded from the push-only LSP workspace sweep (#584). The
2341
+ now-dead cache-only `extractCachedProjectDiagnostics` reader — the parallel
2342
+ registry that shadowed `ANALYZER_IDS` and let opengrep silently diverge — was
2343
+ removed so a single source of truth (#883) remains.
2344
+
2345
+ - **Fixed: test-runner findings now surface project-wide in `lens_diagnostics mode=full` (closes #1004, refs #585, #533)** — the same `ANALYZER_IDS`
2346
+ omission #585/#1003 fixed for opengrep also orphaned test-runner: the
2347
+ per-edit turn_end test fire (`runtime-turn.ts`) caches failures under
2348
+ `"test-runner-findings"`, but `fetchFreshProjectDiagnostics`
2349
+ (`project-diagnostics/fresh-fetch.ts`) never read that cache back, so test
2350
+ failures never reached the agent for unedited/project-wide `mode=full`
2351
+ calls. Unlike opengrep/gitleaks/trivy, this is wired as a CACHE-READ (not a
2352
+ fresh scan): test-runner has no "whole project" run to trigger — turn_end
2353
+ only ever runs the targeted, cascade-aware test files touched by that
2354
+ turn's edits — so `fetchFreshProjectDiagnostics` now peeks at the same
2355
+ `"test-runner-findings"` cache key (never re-running the suite, never
2356
+ writing back) and adapts it via the existing
2357
+ `testRunnerFindingsToProjectDiagnostics`. Added a coverage guardrail
2358
+ (`tests/clients/project-diagnostics/analyzer-coverage.test.ts`) that greps
2359
+ the real session-start (`runHeavyweightTask`) and EVERY turn_end
2360
+ `cacheManager.writeCache` call site in `runtime-turn.ts` (excluding a
2361
+ short, explicit bookkeeping exclude-list — `errorDebt`/`turn-end-findings`/
2362
+ `turn-end-findings-last` — that isn't analyzer-shaped) and asserts every
2363
+ remaining id is a member of `ANALYZER_IDS`, so this whole #585 bug class
2364
+ can't silently regress again for a future analyzer added to EITHER writer.
2365
+ Adversarial review also caught two honesty/fidelity gaps in the initial
2366
+ fix: (1) test-runner is cache-read, edit-scoped (the targeted test files
2367
+ touched by the most recent turn's edits), unlike every other analyzer's
2368
+ fresh whole-project scan — `mode=full` now emits an explicit "coverage is
2369
+ edit-scoped, NOT a full-project run" caveat whenever test-runner
2370
+ contributed findings (not only when cold), and a `stale` cached result
2371
+ (the turn advanced before the test run finished) is prefixed
2372
+ `[stale — from a prior turn]` in its diagnostic message, matching the
2373
+ one-shot turn-context message's own stale wording; (2) `TestFailure.location`
2374
+ (`"file.ts:42"`, set by the vitest/jest JSON parser in
2375
+ `test-runner-client.ts`) never reached `ProjectDiagnostic.line`, so every
2376
+ test-runner finding rendered as `L?:` — `runner-adapters/runner-findings.ts`
2377
+ now parses a numeric trailing `:line[:col]` out of `location` (left alone
2378
+ for pytest/mix's non-numeric `location` strings, which carry a test name or
2379
+ module, not a line).
2380
+
2381
+ - **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`
2382
+ sampled CPU%/RSS via `pidusage`, whose Windows path shells out to `gwmi`
2383
+ through an internal `spawn(..., { shell: "powershell.exe" })` that has no
2384
+ try/catch and runs from inside a ChildProcess `close` callback. Under real
2385
+ Windows handle/commit pressure that spawn can throw `spawn UNKNOWN`
2386
+ (errno -4094) **synchronously in that detached callback**, which the call
2387
+ site's `try { await pidusage() } catch {}` cannot catch → `uncaughtException`
2388
+ → the whole host process dies (observed live). pidusage 4.0.1 offers no
2389
+ option to avoid the gwmi path. The sampler now runs its OWN fully guarded
2390
+ `Get-CimInstance Win32_Process` query on Windows (never calling `pidusage`
2391
+ there), mirroring the existing `findDescendantPidsWindows` guard: a
2392
+ synchronous spawn throw, a child `error` event, and a non-zero/garbage exit
2393
+ all resolve to a partial/empty map — the sampler can now only ever lose a
2394
+ data point, never throw into the heartbeat/spawn path. RSS comes from
2395
+ `WorkingSetSize`; CPU% is preserved via the same KernelModeTime/UserModeTime
2396
+ delta-over-elapsed-wall-time computation gwmi uses (a small per-pid history
2397
+ tracks the prior cumulative CPU time). Linux/macOS keep using `pidusage`
2398
+ (procfs/`ps` — not the crash vector) unchanged. A pid absent from the
2399
+ returned map still means "unsampled this tick", never zero.
2400
+
2401
+ - **Stabilized the two remaining flaky/environment-sensitive tests tracked in #902.** LSP workspace-diagnostics sweep flush-count assertions
2402
+ (`workspace-diagnostics-sweep-batch-open.test.ts`,
2403
+ `workspace-diagnostics-sweep-preopen-chunk.test.ts`) pinned an exact flush
2404
+ count, but the pre-open pass's real `fs.promises.readFile` genuinely races
2405
+ the real 100ms `WatchedFilesQueue` debounce timer — under full-suite
2406
+ scheduler contention that can legitimately fragment or merge a chunk's
2407
+ flush by one. Replaced the exact-count assertions with invariant checks
2408
+ (coalescing happened; nowhere near one flush per file; no lost/duplicated
2409
+ URIs) that tolerate that jitter without losing the #608/#621 regression
2410
+ coverage — a test-robustness fix, not a product bug. Separately, two
2411
+ ast-grep dispatch test harnesses
2412
+ (`ast-grep-rule-tests.test.ts`, `ast-grep-catalog-rules.test.ts`) shelled
2413
+ out to the real `ast-grep` CLI via a raw `execFileSync(..., { shell: true
2414
+ })` per call — an uncached cmd.exe wrapper on Windows whose own exit code
2415
+ can mask the real child's and which can intermittently fail to spawn at
2416
+ all under the process-creation pressure of a full parallel test run.
2417
+ Switched both to the already-hardened `safeSpawn` (`clients/safe-spawn.ts`,
2418
+ #817) — cached PATH+PATHEXT resolution, direct `.exe`/`.com` spawn with no
2419
+ shell involved — the same deterministic resolution production dispatch
2420
+ already relies on. The third tracked item
2421
+ (`startup-overhead.test.ts`'s "quick mode self-reports within 100ms")
2422
+ was already reworked to a generous fixed budget + `retry: 2` in an earlier
2423
+ pass; confirmed still adequate, no further change needed.
840
2424
 
841
2425
  - **`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.
2426
+ (closes #924, refs #936 limit 3) — the CLI's build-attempt check no longer
2427
+ mistakes the benign "succeeded, but persisted a partial subgraph" reason
2428
+ (#960's over-cap circuit-breaker) for a hard failure: it now exits 0 and
2429
+ prints a `PARTIAL persist` line with `persistedNodes=X/Y`,
2430
+ `persistedEdges=X/Y`, and the cap that was hit, instead of either silently
2431
+ reporting "done" or failing loudly on a build/persist that actually
2432
+ succeeded. `flushReviewGraphPersist()` now returns the persisted
2433
+ `coverage` so any standalone caller can make the same distinction.
850
2434
 
851
2435
  - **`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.
2436
+ (fixes #963) — the post-filter now resolves a bare `fetch(IDENT)` argument
2437
+ against the file's AST and exempts it only when `IDENT` provably resolves
2438
+ to a single `const` declarator initialized with a string literal (or a
2439
+ template literal with no `${...}` substitutions). SCREAMING_SNAKE_CASE
2440
+ naming is no longer sufficient by itself: `const TARGET_URL = req.query.url`
2441
+ still flags, since the identifier's *initializer* — not its name — is
2442
+ what's checked. Ambiguous/shadowed declarations, `let`/`var` bindings,
2443
+ reassigned identifiers, and unresolved identifiers all fall through to the
2444
+ existing broad heuristic and keep being flagged.
861
2445
 
862
2446
  - **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).
2447
+ `tests/clients/ast-grep-rule-precedence-followups.test.ts` (CLI probe + `runCli`),
2448
+ `tests/clients/coderabbit-ast-grep-rules.test.ts` (vendored-catalog smoke),
2449
+ `tests/clients/dispatch/runners/ast-grep-playground-verify.test.ts` (async `runVerify`),
2450
+ `tests/clients/dead-code-client.test.ts` (vulture probe), and
2451
+ `tests/clients/lsp/clangd-lazy-indexing.test.ts` (clangd `where`/`which` probe) now spawn
2452
+ through the hardened `safeSpawn`/`safeSpawnAsync` (`clients/safe-spawn.ts`, #817) instead of
2453
+ a raw `execFileSync`/`spawnSync`/`spawn`/`execSync` with a fresh, uncached `cmd.exe` wrapper
2454
+ per call — the documented intermittent ENOENT/EAGAIN source under windows-latest CI's
2455
+ parallel process-creation pressure. Completes the sweep PR #993 started for the two
2456
+ ast-grep runner test files; `git` spawns are left as-is (no `.cmd` shim, so out of the flake
2457
+ class) and the `mcp/*` and MCP harness spawns that need bidirectional stdin streaming are
2458
+ left as-is (`safeSpawnAsync` only exposes a close-based result, not a live stdin/stdout pipe).
875
2459
 
876
2460
  - **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.
2461
+ the 25 unknown filter references were resolved by implementing eight bounded,
2462
+ fail-open-on-filter-error AST checks, expressing two conditions directly in
2463
+ their queries, and removing fifteen rules whose promised semantic/framework
2464
+ analysis could not be supported honestly. Coarse resource, assertion, and
2465
+ sensitive-`memset` heuristics are advisory rather than blocking.
882
2466
 
883
2467
  - **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.
2468
+ persist indexed-file count and truncation state, and both `symbol_search`
2469
+ surfaces report coverage instead of presenting capped zero-hit results as
2470
+ authoritative.
887
2471
 
888
2472
  - **`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.
2473
+ permanently broken server/root pairs now render with their failure count, and
2474
+ temporary circuit-breaker cooldowns expose their retry deadline.
891
2475
 
892
2476
  - **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.
2477
+ unavailable results distinguish an active build, a safety refusal, and the
2478
+ last build's failure, while background build/persist errors reach a persistent
2479
+ NDJSON log.
896
2480
 
897
2481
  - **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.
2482
+ unsuccessful results are reported distinctly, omitted from cache so the next
2483
+ session retries, and valid fix-worklog records survive neighboring corrupt lines.
2484
+
2485
+ - **The footer refreshes as LSP servers come online during a cold `lens_diagnostics mode=full` sweep (refs #798)**, instead of showing
2486
+ `LSP Inactive` until turn end. The repaint captures UI methods during the
2487
+ active tool event, so async warm-up never touches a stale session context.
2488
+
2489
+ - **Tree-sitter WASM aborts are now visible instead of silently disabling structural analysis for the rest of the process (refs #915)**. The shared
2490
+ runtime records a process-wide, timestamped `restart_required` health state,
2491
+ logs a one-time actionable error, exposes it through `pilens_health`, and
2492
+ marks project-scan responses with `treeSitterStatus`. A poisoned scan remains
2493
+ truncated and never replaces the last complete snapshot. In-process retry is
2494
+ deliberately unsafe: every new client imports the same cached `web-tree-sitter`
2495
+ ES module and therefore reuses its corrupted Emscripten heap; restarting the
2496
+ host is the isolation boundary.
915
2497
 
916
2498
  - **`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.
2499
+ #920) — rebuilds are refused before spawning a package script unless the
2500
+ package is a source checkout with `tsconfig.dist.json` outside
2501
+ `node_modules`; installed servers also omit the tool from `tools/list`, so
2502
+ subagent allowlists cannot discover it.
921
2503
 
922
2504
  - **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.
2505
+ `project_report`/`pilens_project_report` now surface the most recent build
2506
+ attempt and its terminal skip/failure reason, including the persistence
2507
+ element circuit-breaker. A dedicated `~/.pi-lens/review-graph.log` records
2508
+ build and persistence starts, successes, skips, and failures, so a rejected
2509
+ fire-and-forget build or an over-cap graph cannot look perpetually in progress.
928
2510
 
929
2511
  - Resolve nested C# and F# project roots for dotnet builds (refs #895).
930
2512
 
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.
2513
+ - **A mid-scan tree-sitter WASM abort no longer replaces the authoritative project-diagnostics snapshot with a silently truncated result (refs #891)**.
2514
+ The partial scan is returned with `scanTruncated`, logs its completed/total
2515
+ file counts and abort point, and leaves the previous complete cache intact.
935
2516
 
936
2517
  - **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.
2518
+ (refs #910) — `switch-case-termination` now follows trailing statement
2519
+ blocks to recognize a nested `break`, `return`, `throw`, or `continue`, while
2520
+ still flagging empty and non-terminating blocks.
940
2521
 
941
2522
  - **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.
2523
+ #894) — `ALL_SCANNABLE_EXTENSIONS`, `WARMUP_SOURCE_EXTS`, and
2524
+ `SUPPORTED_FILE_KINDS` now derive from the single `KIND_EXTENSIONS`
2525
+ authority instead of three drifting language lists. TODO scans, symbol
2526
+ search indexing, dominant-language LSP warmup, and language-profile
2527
+ detection can now see Java, Swift, C/C++, PHP, and every other supported
2528
+ kind. Code kinds keep priority over data/doc kinds (json/yaml/markdown/…)
2529
+ inside the existing caps: the dominant-language LSP warm ranks code kinds
2530
+ first, and the capped warmup/word-index walks fill code files before
2531
+ non-code files, so a locale/fixture pile can't starve real languages.
2532
+ Package-manager lockfiles (package-lock.json, pnpm-lock.yaml, …) are
2533
+ filtered as generated artifacts, and the TODO scanner caps per-file reads
2534
+ at 512 KiB. A coverage guard makes a newly registered kind automatically
2535
+ enumerable — and classified as code or non-code — on both project-wide
2536
+ paths.
956
2537
 
957
2538
  - **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.
2539
+ previous `lsp` fallback had no registered server and silently produced no
2540
+ diagnostics. `cmake-language-server` now covers both `.cmake` files and the
2541
+ canonical `CMakeLists.txt` basename, with managed pip installation.
961
2542
 
962
2543
  - **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.
2544
+ registered for `.fish` files and auto-installed through npm; `fish_indent`
2545
+ continues to run alongside it.
965
2546
 
966
2547
  - **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
2548
 
968
2549
  - **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.
2550
+ <5-line skip threshold only guarded the zero-diagnostics early return; a
2551
+ second `extractEntitySnapshot` block ran unconditionally, so trivial edits
2552
+ still spent ~500-800ms per dispatch and, on unsaved buffers, parsed stale
2553
+ disk content (thrashing the parse-cache entry). Extraction is now one
2554
+ threshold-guarded block that receives the same `file.content` override the
2555
+ diagnostics phase used.
975
2556
 
976
2557
  - **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.
2558
+ (refs #888) — the dispatch hot path ran ~30-40 `runQueryOnFile` walks per
2559
+ edit behind a concurrency limiter that could not parallelize synchronous
2560
+ WASM. It now calls `runQueriesOnFile` once (#675 batching) and distributes
2561
+ the per-rule results; the per-rule `maxResults(10)` cap and modified-ranges
2562
+ gating are unchanged.
982
2563
 
983
2564
  - **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
2565
 
985
2566
  - **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)`.
2567
+ #887) — `tsLangForFile` hand-rolled a local extension map that sent
2568
+ `.js`/`.mjs`/`.cjs` to the typescript grammar and `.jsx` to tsx, while every
2569
+ other tree-sitter consumer resolves those extensions to `javascript` via the
2570
+ shared `EXT_TO_LANG` (`clients/tree-sitter-shared.ts`), so each plain-JS file
2571
+ was parsed and cached twice under two grammars (TreeCache keys are
2572
+ `languageId:path`) and ran TS-grammar symbol queries on JS trees. It now
2573
+ routes the extension-split kinds (jsts, and the c-vs-cpp split for cxx)
2574
+ through the shared `resolveTreeSitterLanguage`, keeping the historical kind
2575
+ default only for extensions the shared map does not cover (`.svelte`/`.vue`,
2576
+ the C++ module-interface/Objective-C tail). The symbol extractor gains a
2577
+ dedicated `javascript` defs/refs/import query set: the TypeScript symbol
2578
+ queries do NOT compile against the javascript grammar
2579
+ (`interface_declaration`/`type_alias_declaration`/`type_identifier` do not
2580
+ exist there — a query naming them fails with "Bad node name"), so the
2581
+ javascript set is the same queries minus the type-only patterns, with class
2582
+ names matched as `(identifier)` instead of `(type_identifier)`.
1002
2583
 
1003
2584
  - **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
2585
 
1005
2586
  - **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.
2587
+ hash match is now authoritative: a save-without-change (same bytes, newer
2588
+ mtime) is a cache hit that refreshes the entry's stat metadata instead of
2589
+ invalidating and re-parsing, and `get()` re-inserts hit entries so eviction
2590
+ is true LRU — hot per-edit files are no longer evicted by scan traffic.
2591
+
2592
+ - **Eight enabled typescript/javascript tree-sitter rules whose queries never compiled (refs #884)** — each had been silently dead since authoring because
2593
+ its query failed to compile against the real grammar, so it matched nothing
2594
+ and reported no diagnostics. Repaired against the actual node/field names and
2595
+ verified end-to-end (matches the bug, leaves correct code alone):
2596
+ `empty-switch-case`, `switch-case-termination`, `switch-case-termination-js`
2597
+ (switch cases carry their statements as direct `body:` children, not a
2598
+ `consequence: (statement_block)`); `infinite-loop` (`while (true)` wraps the
2599
+ condition in a `parenthesized_expression`; `for (;;)` has an `empty_statement`
2600
+ condition, not `(null)`); `duplicate-function-arg` (typescript parameters are
2601
+ `required_parameter`, not bare `(identifier)`; now also catches non-adjacent
2602
+ duplicates); `mixed-async-styles` (no `async_modifier` node — match the `async`
2603
+ token); `switch-non-case-labels` (JS) (a `labeled_statement` lives inside a
2604
+ `switch_case`, not directly under `switch_body`); and `ts-insecure-random`
2605
+ (the inline `(?i)` regex flag is invalid in JS `RegExp` — dropped the
2606
+ redundant name predicate and let the post-filter do the case-insensitive
2607
+ check, walking up from the `Math.random()` call so chained forms like
2608
+ `Math.random().toString(36)` are still attributed to their binding). Also
2609
+ implemented the four post-filters these rules referenced but that were never
2610
+ defined (`is_empty_block`, `no_break_or_return_in_body`, `same_param_name`,
2611
+ `no_terminating_statement`), which the batch runner had been failing closed on.
1032
2612
 
1033
2613
  - **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.
2614
+ (refs #884) — the query-block extractor kept every line more-indented than the
2615
+ key (to preserve `#eq?`/`#match?` predicate lines) but did not stop at a
2616
+ document-level `# …` comment sitting between the block and the next key, so the
2617
+ comment was appended to the query and made it fail to compile. This is what
2618
+ kept `mixed-async-styles` dead even after its query was otherwise correct.
1039
2619
 
1040
2620
  - **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.
2621
+ #884) — `ruby-command-injection`, `ruby-eval`, `ruby-insecure-deserialization`,
2622
+ `ruby-insecure-random`, `ruby-open-struct`, `ruby-string-eval` and
2623
+ `ruby-weak-hash` were authored against JavaScript-grammar node names
2624
+ (`call_expression`, `method_call`, `command`, `interpolated_string`) that do not
2625
+ exist in tree-sitter-ruby, so every one failed to compile with a "Bad node name"
2626
+ error and was silently skipped. Each query is rewritten against the real ruby
2627
+ grammar (`call` with `method:`/`receiver:` fields, `scope_resolution` receivers
2628
+ for `Digest::MD5`, `string` for interpolated literals), with `#match?`/`#eq?`
2629
+ predicates moved inside the pattern's outermost parens. Per-rule positive and
2630
+ negative fixtures (`system` vs `File.read`, `Marshal.load` vs `YAML.safe_load`,
2631
+ `rand` vs `SecureRandom`, `OpenStruct.new` vs `Struct.new`, `Digest::MD5` vs
2632
+ `Digest::SHA256`, string vs block `class_eval`) pin the security intent.
1053
2633
 
1054
2634
  - **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).
2635
+ `python-empty-except` used a `body:` field that doesn't exist on
2636
+ `except_clause`; `in-operator-unsupported` used bare `"in"`/`"not"` `"in"`
2637
+ tokens the grammar doesn't expose that way (`not in` is a single token) and
2638
+ only matched `identifier` targets, so `x in None` never matched at all;
2639
+ `no-super-torchscript` looked for a `(call function: (identifier))` inside the
2640
+ decorator (`@torch.jit.script` has no call — it's a bare `attribute`) and
2641
+ expected the decorator directly on the method rather than the class;
2642
+ `notimplemented-boolean-context` used `("and" | "or")` (not valid tree-sitter
2643
+ query syntax — alternation is `[...]`) inside a non-existent `binary_operator`
2644
+ form and `unary_operator operator: ("not")`, when python's logical `not` is
2645
+ its own `not_operator` node with an `argument:` field;
2646
+ `yield-return-outside-function` referenced a `yield_expression` node type that
2647
+ doesn't exist in tree-sitter-python. All five queries are rewritten against
2648
+ the real grammar (verified via AST dumps against `tree-sitter-python.wasm`);
2649
+ `no-super-torchscript` and `in-operator-unsupported` gained
2650
+ `torchscript_super_call` / `check_in_operator_types` post-filters
2651
+ (`clients/tree-sitter-client.ts`) since neither had a working implementation
2652
+ behind their declared `post_filter` name. `exit-signature-check` compiled
2653
+ fine but its `@PARAM1?`/`@PARAM2?`/`@PARAM3?` captures never matched anything
2654
+ — the `?` quantifier was written after the capture name instead of the node,
2655
+ so the captured names were literally `PARAM1?`/`PARAM2?`/`PARAM3?` while the
2656
+ post_filter read `captures.PARAM1`; fixed to capture the whole `parameters`
2657
+ node and count named children instead (per-slot optional quantifiers turned
2658
+ out to match every valid sub-alignment, not just the maximal one, producing
2659
+ spurious duplicate matches for a fully-correct signature).
1080
2660
 
1081
2661
  - **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
2662
 
1083
2663
  - **`.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
2664
 
1085
2665
  - **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.
2666
+ query is bound to the language it compiled against, and running it on a tree
2667
+ from another grammar returns zero matches silently. Rules now compile against
2668
+ the language the file is parsed as. This is why the javascript→typescript rule
2669
+ merge never fired a single diagnostic in its life. That merge stays off — the
2670
+ rules are written against typescript node types and misfire on JS — but the
2671
+ same fix is what makes `.tsx` work.
1092
2672
 
1093
2673
  - **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`.
2674
+ excluded them, the scanner read the raw loader map and ran them anyway.
2675
+ **1,936 of a 500-file scan's 2,590 tree-sitter findings came from rules that
2676
+ were explicitly switched off.** Rule-set selection now goes through one seam,
2677
+ `queriesForLanguage`.
1098
2678
 
1099
2679
  - **`.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.
2680
+ JSX, and under a different language id than the fact providers use — so every
2681
+ `.tsx` file was parsed twice, once wrongly. It now parses as `tsx` and
2682
+ inherits the typescript rule set, which is rule-for-rule identical on both
2683
+ grammars. `.tsx` also gets those rules in the per-edit runner now, where it
2684
+ previously saw only its own two JSX rules.
1105
2685
 
1106
2686
  - **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.
2687
+ 40 of the 84 filters rules reference have no implementation; the default now
2688
+ drops the match and warns once. `duplicate-function-arg` (whose
2689
+ `same_param_name` filter was never implemented) was reporting 59 phantom
2690
+ duplicates across 60 files.
1111
2691
 
1112
2692
  - **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.
2693
+ parser — `post_filter: not_in_test_block # skip test blocks` carried the
2694
+ comment as part of the filter name, so the filter silently never applied.
1115
2695
 
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.
2696
+ - **tree-sitter runner no longer double-increments already 1-indexed line/column (refs #448)** — dispositions recorded against the old off-by-one
2697
+ are anchored (`clients/diagnostic-dispositions.ts`) to the wrong physical
2698
+ line's content and will resurface once after upgrading; this is expected and
2699
+ one-time, not a regression.
1121
2700
 
1122
2701
  ### Security
1123
2702
 
1124
2703
  - **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.
2704
+ #327). The shared NDJSON boundary scrubs private keys, provider tokens, AWS
2705
+ access keys, and JWT/JWE compact tokens with deterministic linear scanners.
2706
+ The synchronous crash-adjacent LSP launch diagnostic uses the same redactor.
1128
2707
 
1129
2708
  ## [3.8.73] - 2026-07-28
1130
2709
 
@@ -1133,15 +2712,15 @@ All notable changes to pi-lens will be documented in this file.
1133
2712
  ### Changed
1134
2713
 
1135
2714
  - **`@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.
2715
+ js-yaml 5.2.2 in (#843) the package ships its own declarations, so the stub
2716
+ types dev-dependency is gone and the lockfile regenerated. This completes the
2717
+ v5 migration started with the namespace-import compatibility pass below.
1139
2718
 
1140
2719
  - **`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.
2720
+ package's namespace exports so the central accessor and YAML rule tooling work
2721
+ with both the current v4 dependency and the upcoming v5 release. `@types/js-yaml`
2722
+ remains temporarily because js-yaml 4.3.0 needs its declarations; remove it
2723
+ with the v5 Dependabot upgrade once the bundled declarations are active.
1145
2724
 
1146
2725
  - **`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
2726
 
@@ -1154,17 +2733,17 @@ All notable changes to pi-lens will be documented in this file.
1154
2733
  - **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
2734
 
1156
2735
  - **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.
2736
+ test harness keeps its 20s local default, accepts the
2737
+ `PI_LENS_TEST_TIMEOUT_SCALE` multiplier, and the analyze-graph smoke pays
2738
+ server startup through a throwaway health call before its assertions.
1160
2739
 
1161
2740
  - **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
2741
 
1163
2742
  - **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
2743
 
1165
2744
  - **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.
2745
+ server-policy fixture now pins its nearest `package.json` inside the temporary
2746
+ workspace, so a marker in the host user's profile cannot change the result.
1168
2747
 
1169
2748
  - **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
2749
 
@@ -1368,6 +2947,8 @@ All notable changes to pi-lens will be documented in this file.
1368
2947
  - **`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
2948
  - **`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
2949
  - **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).
2950
+ - **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).
2951
+
1371
2952
  - **`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
2953
  - **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
2954
  - **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.