@herbertgao/pi-extensions 2026.8.6 → 2026.8.7

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 (343) hide show
  1. package/README.md +5 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +19 -7
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +30 -9
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +272 -22
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +1 -1
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +14 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +55 -17
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
  15. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  16. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  17. package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
  18. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
  19. package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
  21. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
  22. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +17 -13
  23. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
  25. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
  26. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
  27. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
  28. package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
  29. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
  30. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
  31. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +21 -6
  32. package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
  33. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +20 -0
  34. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +1 -0
  35. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +26 -10
  36. package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
  37. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
  38. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +2 -1
  39. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +3 -1
  40. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +2 -2
  41. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/zh.json +2 -2
  42. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +3 -2
  43. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +10 -0
  44. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +3 -1
  45. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +30 -7
  46. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
  47. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +30 -4
  48. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +26 -5
  49. package/node_modules/@narumitw/pi-btw/README.md +120 -111
  50. package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
  51. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
  52. package/node_modules/@narumitw/pi-btw/package.json +10 -6
  53. package/node_modules/@narumitw/pi-btw/src/btw.ts +36 -40
  54. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
  55. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
  56. package/node_modules/@narumitw/pi-btw/src/menu.ts +45 -2
  57. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
  58. package/node_modules/pi-lens/CHANGELOG.md +909 -0
  59. package/node_modules/pi-lens/README.md +11 -3
  60. package/node_modules/pi-lens/config/ruff/core.toml +42 -0
  61. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
  62. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +126 -13
  63. package/node_modules/pi-lens/dist/clients/agent-nudge.js +67 -11
  64. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
  65. package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
  66. package/node_modules/pi-lens/dist/clients/bash-file-access.js +285 -8
  67. package/node_modules/pi-lens/dist/clients/biome-client.js +61 -30
  68. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
  69. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +119 -0
  70. package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
  71. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
  72. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +192 -0
  73. package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
  74. package/node_modules/pi-lens/dist/clients/cascade-format.js +20 -1
  75. package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
  76. package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
  77. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
  78. package/node_modules/pi-lens/dist/clients/dead-code-client.js +123 -31
  79. package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
  80. package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
  81. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +62 -13
  82. package/node_modules/pi-lens/dist/clients/dependency-checker.js +25 -3
  83. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +147 -33
  84. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
  85. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +52 -2
  86. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +256 -88
  87. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
  88. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +60 -53
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -0
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +23 -0
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +14 -0
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +20 -0
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +22 -0
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +10 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +18 -6
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +275 -13
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +21 -6
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +734 -64
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +72 -0
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +9 -0
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +9 -0
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -1
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
  122. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
  123. package/node_modules/pi-lens/dist/clients/extension-log.js +22 -1
  124. package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
  125. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +294 -0
  126. package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
  127. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
  128. package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
  129. package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
  130. package/node_modules/pi-lens/dist/clients/git-guard.js +31 -0
  131. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
  132. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +109 -9
  133. package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
  134. package/node_modules/pi-lens/dist/clients/host-ports.js +8 -0
  135. package/node_modules/pi-lens/dist/clients/installer/index.js +1114 -100
  136. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
  137. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
  138. package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
  139. package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
  140. package/node_modules/pi-lens/dist/clients/jscpd-client.js +82 -6
  141. package/node_modules/pi-lens/dist/clients/knip-client.js +310 -10
  142. package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
  143. package/node_modules/pi-lens/dist/clients/language-profile.js +2 -0
  144. package/node_modules/pi-lens/dist/clients/latency-logger.js +440 -11
  145. package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
  146. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
  147. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  148. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +102 -7
  149. package/node_modules/pi-lens/dist/clients/lsp/client.js +1516 -159
  150. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +83 -1
  151. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
  152. package/node_modules/pi-lens/dist/clients/lsp/index.js +2274 -147
  153. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
  154. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
  155. package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
  156. package/node_modules/pi-lens/dist/clients/lsp/server.js +277 -16
  157. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
  158. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
  159. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +79 -3
  160. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +43 -0
  161. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
  162. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
  163. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
  164. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
  165. package/node_modules/pi-lens/dist/clients/mcp/session.js +10 -2
  166. package/node_modules/pi-lens/dist/clients/module-report.js +8 -0
  167. package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
  168. package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
  169. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
  170. package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
  171. package/node_modules/pi-lens/dist/clients/pipeline.js +154 -33
  172. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
  173. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
  174. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
  175. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +84 -14
  176. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
  177. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
  178. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +5 -0
  179. package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
  180. package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
  181. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
  182. package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
  183. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +1 -1
  184. package/node_modules/pi-lens/dist/clients/read-guard.js +184 -10
  185. package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
  186. package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
  187. package/node_modules/pi-lens/dist/clients/review-graph/query.js +52 -4
  188. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +96 -7
  189. package/node_modules/pi-lens/dist/clients/runtime-context.js +8 -1
  190. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +308 -45
  191. package/node_modules/pi-lens/dist/clients/runtime-session.js +332 -9
  192. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
  193. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +212 -79
  194. package/node_modules/pi-lens/dist/clients/runtime-turn.js +575 -67
  195. package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
  196. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
  197. package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
  198. package/node_modules/pi-lens/dist/clients/security-scan-client.js +157 -24
  199. package/node_modules/pi-lens/dist/clients/sg-runner.js +116 -22
  200. package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
  201. package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
  202. package/node_modules/pi-lens/dist/clients/startup-timing.js +19 -1
  203. package/node_modules/pi-lens/dist/clients/test-runner-client.js +151 -16
  204. package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
  205. package/node_modules/pi-lens/dist/clients/tool-policy.js +125 -20
  206. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
  207. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
  208. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
  209. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
  210. package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
  211. package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
  212. package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
  213. package/node_modules/pi-lens/dist/index.js +67738 -55739
  214. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +466 -54
  215. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +132 -43
  216. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
  217. package/node_modules/pi-lens/docs/agent-guide.md +6 -1
  218. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +515 -506
  219. package/node_modules/pi-lens/docs/dispositions.md +21 -0
  220. package/node_modules/pi-lens/docs/features.md +19 -5
  221. package/node_modules/pi-lens/docs/globalconfig.md +3 -0
  222. package/node_modules/pi-lens/docs/language-coverage.md +71 -5
  223. package/node_modules/pi-lens/docs/mcp.md +2 -2
  224. package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
  225. package/node_modules/pi-lens/docs/settings.md +19 -1
  226. package/node_modules/pi-lens/docs/usage.md +1 -0
  227. package/node_modules/pi-lens/package.json +10 -6
  228. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-js-snapshot.yml +23 -0
  229. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-snapshot.yml +23 -0
  230. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/ts-void-zero-snapshot.yml +23 -0
  231. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-object-param-test.yml +16 -0
  232. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +15 -1
  233. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-compile-call-test.yml +29 -1
  234. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
  235. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
  236. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
  237. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
  238. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
  239. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +2 -0
  240. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +2 -0
  241. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +2 -1
  242. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +2 -1
  243. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
  244. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
  245. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
  246. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
  247. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
  248. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
  249. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
  250. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
  251. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
  252. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
  253. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
  254. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
  255. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
  256. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
  257. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
  258. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
  259. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
  260. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +57 -12
  261. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
  262. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
  263. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
  264. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
  265. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
  266. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
  267. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
  268. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +22 -2
  269. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +23 -2
  270. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +33 -5
  271. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +34 -8
  272. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
  273. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +6 -1
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
  288. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +53 -1
  289. package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
  290. package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
  291. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +2 -0
  292. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +80 -3
  293. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +41 -0
  294. package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
  295. package/node_modules/pi-mcp-adapter/CHANGELOG.md +36 -0
  296. package/node_modules/pi-mcp-adapter/README.md +47 -5
  297. package/node_modules/pi-mcp-adapter/cli.js +163 -1
  298. package/node_modules/pi-mcp-adapter/commands.ts +128 -22
  299. package/node_modules/pi-mcp-adapter/config.ts +25 -7
  300. package/node_modules/pi-mcp-adapter/direct-tools.ts +5 -4
  301. package/node_modules/pi-mcp-adapter/index.ts +219 -32
  302. package/node_modules/pi-mcp-adapter/init.ts +36 -4
  303. package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
  304. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
  305. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
  306. package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
  307. package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
  308. package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
  309. package/node_modules/pi-mcp-adapter/metadata-cache.ts +32 -21
  310. package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
  311. package/node_modules/pi-mcp-adapter/package.json +3 -1
  312. package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
  313. package/node_modules/pi-mcp-adapter/request-headers-command.ts +31 -37
  314. package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
  315. package/node_modules/pi-mcp-adapter/server-manager.ts +140 -2
  316. package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
  317. package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
  318. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
  319. package/node_modules/pi-mcp-adapter/types.ts +2 -0
  320. package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
  321. package/node_modules/pi-mcp-adapter/utils.ts +70 -0
  322. package/node_modules/pi-web-access/CHANGELOG.md +23 -0
  323. package/node_modules/pi-web-access/README.md +27 -9
  324. package/node_modules/pi-web-access/anysearch.ts +4 -2
  325. package/node_modules/pi-web-access/brave.ts +16 -4
  326. package/node_modules/pi-web-access/curator-page.ts +6 -3
  327. package/node_modules/pi-web-access/curator-server.ts +4 -1
  328. package/node_modules/pi-web-access/exa.ts +18 -7
  329. package/node_modules/pi-web-access/extract.ts +28 -3
  330. package/node_modules/pi-web-access/gemini-search.ts +16 -4
  331. package/node_modules/pi-web-access/index.ts +41 -57
  332. package/node_modules/pi-web-access/package.json +1 -1
  333. package/node_modules/pi-web-access/page-query.ts +4 -2
  334. package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
  335. package/node_modules/pi-web-access/parallel.ts +2 -2
  336. package/node_modules/pi-web-access/query-rewrite.ts +51 -0
  337. package/node_modules/pi-web-access/serper.ts +211 -0
  338. package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
  339. package/node_modules/pi-web-access/summary-review.ts +44 -22
  340. package/node_modules/pi-web-access/tavily.ts +16 -4
  341. package/node_modules/pi-web-access/utils.ts +87 -0
  342. package/node_modules/pi-web-access/valyu.ts +199 -0
  343. package/package.json +13 -13
@@ -3,17 +3,24 @@ import { appendActionableWarningsHistory, buildActionableWarningsReport, formatA
3
3
  import { logActionableWarningsEvent } from "./actionable-warnings-logger.js";
4
4
  import { appendCodeQualityWarningsHistory, buildCodeQualityWarningsReport, formatCodeQualityWarningsAdvisory, writeCodeQualityWarningsReport, } from "./code-quality-warnings.js";
5
5
  import { clearGitGuardTestFailure, mergeGitGuardTestFailure, writeGitGuardRecord, } from "./git-guard.js";
6
+ import { cascadeSettleWaitMs } from "./cascade-budget.js";
6
7
  import { logCascade } from "./cascade-logger.js";
7
8
  import { normalizeMapKey } from "./path-utils.js";
8
9
  import { resolveRunnerPath, toRunnerDisplayPath, } from "./dispatch/runner-context.js";
9
10
  import { getKnipIgnorePatterns } from "./file-utils.js";
11
+ import { formatCacheAgeLabel } from "./finding-delivery-gate.js";
12
+ import { getFullScanWallClockMs, isWorkspaceSweepActive, runWhenWorkspaceSweepIdle, SWEEP_IDLE_SAFETY_MARGIN_MS, } from "./lsp/workspace-sweep-hold.js";
10
13
  import { isTestRoleCollateral } from "./collateral-test-role.js";
11
14
  import { dedupeSecretFindings, fromAstGrepWarnings, fromGitleaks, fromTrivySecrets, isSecretWarning, secretLocationKey, } from "./secret-findings.js";
12
- import { deadCodeIssueKey, deadCodeIssues, formatDeadCodeDelta, } from "./dead-code-client.js";
15
+ import { deadCodeIssueKey, deadCodeIssues, formatDeadCodeDelta, stableFindingKey, } from "./dead-code-client.js";
13
16
  import { logDeadCodeScan } from "./dead-code-logger.js";
14
17
  import { PROJECT_DIAGNOSTICS_CACHE_VERSION, writeProjectDiagnosticsDeltaReport, } from "./project-diagnostics/cache.js";
15
18
  import { deadCodeIssueToProjectDiagnostic } from "./project-diagnostics/runner-adapters/dead-code.js";
19
+ import { gitleaksFindingToProjectDiagnostic } from "./project-diagnostics/runner-adapters/gitleaks.js";
20
+ import { govulncheckFindingToProjectDiagnostic } from "./project-diagnostics/runner-adapters/govulncheck.js";
21
+ import { trivyFindingToProjectDiagnostic, trivySecretFindingToProjectDiagnostic, } from "./project-diagnostics/runner-adapters/trivy.js";
16
22
  import { knipIssuesToProjectDiagnostics } from "./project-diagnostics/runner-adapters/knip.js";
23
+ import { applyDispositionsMultiFile } from "./diagnostic-dispositions.js";
17
24
  import { logLatency } from "./latency-logger.js";
18
25
  import { getLspBudgetIdleTimeoutMs, shouldShortenLspIdleTimeout, } from "./lsp-budget.js";
19
26
  import { updateHeartbeat } from "./instance-registry.js";
@@ -21,7 +28,48 @@ import { emitLensTurnFindings } from "./lens-events.js";
21
28
  import { RUNTIME_CONFIG } from "./runtime-config.js";
22
29
  import { isSubagentSession } from "./subagent-mode.js";
23
30
  import { formatRunDurationMs } from "./run-duration.js";
24
- import { MAX_ADVISORY_AFFECTED_FILES, dropFindingsForMissingPaths, snapshotAdvisoryProvenance, } from "./advisory-provenance.js";
31
+ import { MAX_ADVISORY_AFFECTED_FILES, gateFindingsByPathFreshness, snapshotAdvisoryProvenance, } from "./advisory-provenance.js";
32
+ import { sweepInlineBlockerFreshness } from "./blocker-freshness.js";
33
+ import { sweepInlineBlockerPastEof } from "./blocker-past-eof.js";
34
+ // #1631 review V2: moved to its own leaf module so a low-level store
35
+ // (widget-state.ts) can use the marker without importing this orchestrator —
36
+ // see clients/stale-marker.ts's doc comment.
37
+ import { STALE_LINE_MARKER } from "./stale-marker.js";
38
+ import { getWidgetBlockingFilesForSweep, markWidgetFileBlockersStale, } from "./widget-state.js";
39
+ /**
40
+ * #1617: turn_end reads gitleaks/govulncheck/trivy straight from their
41
+ * session-scan caches and formats them into advisory/blocker text — a
42
+ * reporting lane parallel to (and, before this fix, entirely bypassing)
43
+ * `dispatcher.ts:924`'s `applyDispositions` filter. An agent-marked
44
+ * false-positive/won't-fix on one of these findings never suppressed it
45
+ * here, so it re-reported on every turn.
46
+ *
47
+ * Filters `findings` through the SAME anchor derivation the dispatch path
48
+ * and `lens_diagnostics mode=full` use (`applyDispositionsMultiFile` in
49
+ * `diagnostic-dispositions.ts`), keyed off each lane's own canonical
50
+ * `ProjectDiagnostic` adapter (`toDiagnostic`) — the exact tool/rule/message
51
+ * identity `lens_diagnostics` already surfaces and `lens_diagnostic_mark`
52
+ * already anchors a mark against, not a second, cloned identity that would
53
+ * silently diverge from what the agent actually marked.
54
+ *
55
+ * Returns the surviving findings plus how many were dropped, so a caller can
56
+ * still surface a "suppressed by disposition: N" trace (the #1616
57
+ * suppressed-bucket rule — a security finding must never vanish with no
58
+ * trace, even when the disposition that dropped it is working as intended).
59
+ */
60
+ function filterFindingsByDisposition(findings, cwd, toDiagnostic) {
61
+ if (findings.length === 0)
62
+ return { kept: findings, suppressed: 0 };
63
+ const candidates = findings.map((finding) => ({
64
+ finding,
65
+ diagnostic: toDiagnostic(finding),
66
+ }));
67
+ const survivors = new Set(applyDispositionsMultiFile(candidates.map((c) => c.diagnostic), cwd, (d) => d.filePath));
68
+ const kept = candidates
69
+ .filter((c) => survivors.has(c.diagnostic))
70
+ .map((c) => c.finding);
71
+ return { kept, suppressed: findings.length - kept.length };
72
+ }
25
73
  /**
26
74
  * Would writing `next` over `prev` throw away a good scan for a failed one?
27
75
  *
@@ -35,6 +83,13 @@ function wouldPoisonCache(prev, next) {
35
83
  }
36
84
  // LSP idle reset scheduling — prevents thrashing by delaying shutdown
37
85
  let lspIdleResetTimeout = null;
86
+ // #1618: set while this timer's fire is deferred behind an in-flight
87
+ // workspace sweep (see `scheduleLSPIdleReset`'s `isWorkspaceSweepActive`
88
+ // branch). `cancelLSPIdleReset` must be able to cancel THIS too — otherwise
89
+ // an active-editing turn that cancels idle reset while a sweep is still
90
+ // running would have it silently resurrected once the sweep finishes, even
91
+ // though the session is no longer idle.
92
+ let pendingSweepRearm = null;
38
93
  function emitIdleResetReporterWarning(reportErr) {
39
94
  try {
40
95
  process.emitWarning(`pi-lens LSP idle reset error reporter failed: ${reportErr}`, { code: "PI_LENS_LSP_IDLE_RESET_REPORTER_FAILED" });
@@ -53,12 +108,40 @@ function reportIdleResetError(onError, err) {
53
108
  }
54
109
  }
55
110
  function scheduleLSPIdleReset(resetFn, delayMs, options = {}) {
56
- // Clear any pending reset to avoid multiple timers
111
+ // Clear any pending reset to avoid multiple timers. #1618: also cancel a
112
+ // rearm still waiting on a prior sweep's hold — otherwise re-scheduling
113
+ // here (this call) leaves that OLD waiter armed too, and the sweep's
114
+ // eventual release would fire a SECOND, independent `scheduleLSPIdleReset`
115
+ // alongside this fresh one.
57
116
  if (lspIdleResetTimeout) {
58
117
  clearTimeout(lspIdleResetTimeout);
59
118
  }
119
+ if (pendingSweepRearm) {
120
+ pendingSweepRearm.cancelled = true;
121
+ pendingSweepRearm = null;
122
+ }
60
123
  lspIdleResetTimeout = setTimeout(() => {
61
124
  lspIdleResetTimeout = null;
125
+ // #1618: a full workspace sweep (`lens_diagnostics mode=full`) grants
126
+ // itself a wall-clock ceiling that can outlive this timer's delay — this
127
+ // used to fire straight into an in-flight sweep and destroy the very
128
+ // service the sweep was actively touching, mislabeling every file the
129
+ // sweep had not yet reached as budget exhaustion. Defer instead of
130
+ // firing: re-arm a FRESH `delayMs` timer once the sweep releases its
131
+ // hold, rather than resuming a countdown that's already elapsed (which
132
+ // would fire the instant the hold releases) or destroying mid-sweep.
133
+ if (isWorkspaceSweepActive()) {
134
+ const rearmToken = { cancelled: false };
135
+ pendingSweepRearm = rearmToken;
136
+ runWhenWorkspaceSweepIdle(() => {
137
+ if (rearmToken.cancelled)
138
+ return;
139
+ if (pendingSweepRearm === rearmToken)
140
+ pendingSweepRearm = null;
141
+ scheduleLSPIdleReset(resetFn, delayMs, options);
142
+ });
143
+ return;
144
+ }
62
145
  try {
63
146
  if (options.isCurrentSession && !options.isCurrentSession()) {
64
147
  return;
@@ -77,17 +160,58 @@ function scheduleLSPIdleReset(resetFn, delayMs, options = {}) {
77
160
  // exit after completing its work, not wait 240 seconds for this to fire)
78
161
  lspIdleResetTimeout.unref();
79
162
  }
163
+ // #1618 acceptance criterion 6: FULL_SCAN_WALL_CLOCK_MS (the full-sweep wall
164
+ // clock ceiling, `tools/lens-diagnostics.ts`) must stay under EVERY idle
165
+ // reset delay this module can arm — derived, not asserted, so the constants
166
+ // can't drift back into a relationship where a still-running sweep can
167
+ // outlive the timer. The AC1 hold above already makes a mid-sweep fire
168
+ // impossible regardless of this margin; this is defense in depth against a
169
+ // future caller that touches the LSP service outside
170
+ // `runWorkspaceDiagnostics`' hold. `SWEEP_IDLE_SAFETY_MARGIN_MS` is
171
+ // single-sourced from `workspace-sweep-hold.ts`, which also uses it for its
172
+ // own max-hold-age failsafe — one tunable, not two.
173
+ const DEFAULT_LSP_IDLE_RESET_MS = 240_000;
174
+ function sweepDerivedFloorMs() {
175
+ return getFullScanWallClockMs() + SWEEP_IDLE_SAFETY_MARGIN_MS;
176
+ }
177
+ /** The normal (non-subagent, non-budget-pressured) idle-reset delay. */
178
+ function getBaseLspIdleResetMs() {
179
+ return Math.max(DEFAULT_LSP_IDLE_RESET_MS, sweepDerivedFloorMs());
180
+ }
181
+ /**
182
+ * #1618 (R4): the subagent-light (#713) and cross-process-budget-pressured
183
+ * (#449) paths used to arm a flat, much SHORTER delay (60s default) than the
184
+ * sweep's own 300s ceiling — a 5:1 inversion covered only by the AC1 hold.
185
+ * Deriving this path too means AC6 ("the sweep's ceiling stays under every
186
+ * idle-reset delay") holds universally, not just for the common path, and an
187
+ * env override to either constant can never invert it (`Math.max` floors at
188
+ * the derived value no matter how small the override pushes the other side).
189
+ *
190
+ * Accepted cost (deliberate, not incidental — see R6 in the PR body): under
191
+ * default settings this now ALSO arms the ~360s derived floor rather than a
192
+ * true 60s teardown, trading some of #713's "release a short-lived
193
+ * subagent's fleet fast" benefit for AC6 holding without exceptions.
194
+ */
195
+ function getShortenedLspIdleResetMs() {
196
+ return Math.max(getLspBudgetIdleTimeoutMs(), sweepDerivedFloorMs());
197
+ }
198
+ /** The idle-reset delay `handleTurnEnd` actually arms on a file-less turn —
199
+ * exported so tests assert against the REAL computed value instead of a
200
+ * hand-derived literal that can silently drift from this function. */
201
+ export function getEffectiveLspIdleResetMs() {
202
+ return isSubagentSession() || shouldShortenLspIdleTimeout()
203
+ ? getShortenedLspIdleResetMs()
204
+ : getBaseLspIdleResetMs();
205
+ }
80
206
  export function cancelLSPIdleReset() {
81
207
  if (lspIdleResetTimeout) {
82
208
  clearTimeout(lspIdleResetTimeout);
83
209
  lspIdleResetTimeout = null;
84
210
  }
85
- }
86
- // Bounded wait for the turn's deferred cascade computes (#450) to settle before
87
- // they are merged below. A late compute is carried over to the next turn_end.
88
- function cascadeSettleWaitMs() {
89
- const raw = Number(process.env.PI_LENS_CASCADE_SETTLE_WAIT_MS);
90
- return Number.isFinite(raw) && raw >= 0 ? raw : 5000;
211
+ if (pendingSweepRearm) {
212
+ pendingSweepRearm.cancelled = true;
213
+ pendingSweepRearm = null;
214
+ }
91
215
  }
92
216
  function capTurnEndMessage(content) {
93
217
  const maxLines = RUNTIME_CONFIG.turnEnd.maxLines;
@@ -165,14 +289,15 @@ export async function handleTurnEnd(deps) {
165
289
  cacheManager.clearCache("turn-end-findings", cwd);
166
290
  }
167
291
  }
168
- // #713: subagent sessions use a shorter idle reset (60s) — a short-lived
169
- // task agent holding a warm fleet for 4 minutes after its last turn is
170
- // pure waste under fan-out. Classify ONCE here so every tick in this call
171
- // path shares the same answer. PI_LENS_SUBAGENT_FULL=1 restores 240s via
172
- // isSubagentSession() returning false.
173
- const idleResetMs = isSubagentSession() || shouldShortenLspIdleTimeout()
174
- ? getLspBudgetIdleTimeoutMs()
175
- : 240_000;
292
+ // #713: subagent sessions use a shorter idle reset (nominally 60s) — a
293
+ // short-lived task agent holding a warm fleet for 4 minutes after its
294
+ // last turn is pure waste under fan-out. Classify ONCE here so every
295
+ // tick in this call path shares the same answer. PI_LENS_SUBAGENT_FULL=1
296
+ // restores the base delay via isSubagentSession() returning false.
297
+ // #1618: both branches route through `getEffectiveLspIdleResetMs` so
298
+ // AC6's derivation applies universally — see that function's doc for
299
+ // why the "shorter" path is not always literally 60s anymore.
300
+ const idleResetMs = getEffectiveLspIdleResetMs();
176
301
  dbg(`turn_end: no modified files, scheduling LSP idle reset (${idleResetMs / 1000}s)`);
177
302
  if (!getFlag("no-lsp")) {
178
303
  const sessionGeneration = runtime.sessionGeneration;
@@ -184,8 +309,14 @@ export async function handleTurnEnd(deps) {
184
309
  resetFormatService();
185
310
  return;
186
311
  }
187
- // Cancel any pending idle reset since we're actively working
188
- if (lspIdleResetTimeout) {
312
+ // Cancel any pending idle reset since we're actively working. #1618: also
313
+ // checks `pendingSweepRearm` — a timer deferred behind an in-flight
314
+ // workspace sweep already nulled `lspIdleResetTimeout` (the setTimeout
315
+ // callback clears it before checking the hold), so this guard used to
316
+ // read "nothing pending" and skip the cancel while a rearm was still
317
+ // queued to fire the instant the sweep released its hold — resurrecting
318
+ // idle reset on a session that had since gone back to active editing.
319
+ if (lspIdleResetTimeout || pendingSweepRearm) {
189
320
  cancelLSPIdleReset();
190
321
  dbg("turn_end: cancelled pending LSP idle reset (active editing)");
191
322
  }
@@ -199,22 +330,91 @@ export async function handleTurnEnd(deps) {
199
330
  }
200
331
  const turnEndStart = Date.now();
201
332
  const blockerParts = [];
333
+ /**
334
+ * #1622 review M2: findings the freshness gate demoted. A third tier between
335
+ * blockers and advisories — not a blocker, because the cached coordinate is
336
+ * untrustworthy; not an advisory, because the advisory label reads "no action
337
+ * required this turn" and these DO require a re-scan. Each part carries its
338
+ * own imperative preamble rather than inheriting that label.
339
+ */
340
+ const staleSecretParts = [];
202
341
  const advisoryParts = [];
203
342
  const projectDiagnosticsDelta = [];
204
343
  const projectDiagnosticsSources = new Set();
344
+ // #1641: past-EOF gate. Runs BEFORE the dependency-drift sweep below — a
345
+ // cheap statSync per cited file is worth paying first so the pricier
346
+ // import-parsing sweep can skip anything already taken out of the
347
+ // authoritative channel this turn (see blocker-past-eof.ts's module doc
348
+ // for the full composition rule with #1631's gate).
349
+ const blockerPastEofStart = Date.now();
350
+ const blockerPastEof = sweepInlineBlockerPastEof(runtime, cwd);
351
+ logLatency({
352
+ type: "phase",
353
+ toolName: "turn_end",
354
+ filePath: cwd,
355
+ phase: "blocker_past_eof_sweep",
356
+ durationMs: Date.now() - blockerPastEofStart,
357
+ metadata: {
358
+ total: blockerPastEof.total,
359
+ checked: blockerPastEof.checked,
360
+ demoted: blockerPastEof.demoted,
361
+ healed: blockerPastEof.healed,
362
+ },
363
+ });
364
+ // #1631: freshness gate. A cached blocker is a verdict about the file AND
365
+ // everything it imports; before re-serving it, sweep for out-of-band drift of
366
+ // the file or its forward imports and demote drifted entries to a
367
+ // `[stale — re-run to confirm]` advisory instead of re-asserting them at full
368
+ // authority (#1419 demote-not-drop).
369
+ const blockerFreshnessStart = Date.now();
370
+ // #1790: widen the sweep's population with widget-store rows a cache-served
371
+ // replay populated without ever touching RuntimeCoordinator's inline-blocker
372
+ // map — see blocker-freshness.ts's `WidgetSweepBlockerEntry` doc for why this
373
+ // is injected here rather than imported by blocker-freshness.ts itself.
374
+ const blockerFreshness = await sweepInlineBlockerFreshness(runtime, cwd, {
375
+ additionalEntries: getWidgetBlockingFilesForSweep().map((row) => ({
376
+ filePath: row.filePath,
377
+ recordedAtMs: row.recordedAtMs,
378
+ demote: () => markWidgetFileBlockersStale(row.filePath, "dependency-drift"),
379
+ })),
380
+ });
381
+ logLatency({
382
+ type: "phase",
383
+ toolName: "turn_end",
384
+ filePath: cwd,
385
+ phase: "blocker_freshness_sweep",
386
+ durationMs: Date.now() - blockerFreshnessStart,
387
+ metadata: {
388
+ total: blockerFreshness.total,
389
+ kept: blockerFreshness.kept,
390
+ revalidated: blockerFreshness.revalidated,
391
+ alreadyStale: blockerFreshness.alreadyStale,
392
+ truncatedImports: blockerFreshness.truncatedImports,
393
+ },
394
+ });
205
395
  // Re-surface inline blockers from this turn that the agent didn't fix.
206
396
  // These were shown inline during write/edit but the agent moved on without resolving them.
207
397
  const unresolvedBlockers = runtime.getInlineBlockersSnapshot();
208
- for (const { filePath: bPath, summary } of unresolvedBlockers) {
398
+ for (const { filePath: bPath, summary, stale } of unresolvedBlockers) {
209
399
  const displayPath = toRunnerDisplayPath(cwd, bPath);
210
- blockerParts.push(`Unresolved from this turn — ${displayPath}:\n${summary}`);
400
+ if (stale) {
401
+ // #1631: demoted — out of the authoritative blocker channel and into the
402
+ // advisory channel with a stale marker, so the agent is told to re-run
403
+ // rather than pressured by a verdict that may already be resolved.
404
+ // @delivery-surface: runtime-turn:unresolved-inline-blocker
405
+ advisoryParts.push(`${STALE_LINE_MARKER} ${displayPath}:\n${summary}`);
406
+ }
407
+ else {
408
+ // @delivery-surface: runtime-turn:unresolved-inline-blocker
409
+ blockerParts.push(`Unresolved from this turn — ${displayPath}:\n${summary}`);
410
+ }
211
411
  }
212
412
  // Drain the deferred cascade computes kicked off this turn (#450). They ran
213
413
  // concurrently off the write hot path; wait a bounded time for them here so
214
414
  // their runs are available to the merge below. A compute still in flight at
215
415
  // the cap is carried over to the next turn_end (never dropped).
216
416
  const cascadeSettleStart = Date.now();
217
- const { settled, timedOut } = await runtime.settleCascadeRuns(cascadeSettleWaitMs());
417
+ const { settled, timedOut } = await runtime.settleCascadeRuns(cascadeSettleWaitMs(), { trackTurnEndClock: true });
218
418
  logLatency({
219
419
  type: "phase",
220
420
  toolName: "turn_end",
@@ -260,7 +460,10 @@ export async function handleTurnEnd(deps) {
260
460
  if (changedSince.length > 0) {
261
461
  const changedSet = new Set(changedSince);
262
462
  const primaryKey = normalizeMapKey(path.resolve(run.filePath));
263
- const neighborKeys = (run.result?.neighbors ?? []).map((n) => normalizeMapKey(path.resolve(n.filePath)));
463
+ const neighborKeys = [
464
+ ...(run.result?.neighbors ?? []).map((n) => n.filePath),
465
+ ...(run.selectedNeighborPaths ?? []),
466
+ ].map((filePath) => normalizeMapKey(path.resolve(filePath)));
264
467
  const supersededByOwnFile = changedSet.has(primaryKey) ||
265
468
  neighborKeys.some((k) => changedSet.has(k));
266
469
  if (supersededByOwnFile) {
@@ -286,6 +489,15 @@ export async function handleTurnEnd(deps) {
286
489
  return true;
287
490
  });
288
491
  const cascadeResults = cascadeRuns.flatMap((r) => r.result ? [r.result] : []);
492
+ // #1550 class sweep: every cascade record below summarises `cascadeResults`
493
+ // — runs, which carry their own paths and can be carried across turns
494
+ // (#1443) — so labelling them with the turn's first EDITED file is the same
495
+ // mis-attribution the `cascade_indeterminate` fix removes. On a read-only
496
+ // drain turn `files` is empty and the old `?? cwd` fallback stamped a bare
497
+ // DIRECTORY as the record's file. These three are turn-level AGGREGATES (no
498
+ // per-file cause is claimed), so one label suffices; the edited file and cwd
499
+ // stay as fallbacks.
500
+ const cascadeLogFilePath = cascadeResults[0]?.filePath ?? files[0] ?? cwd;
289
501
  if (cascadeResults.length > 0) {
290
502
  const seen = new Map();
291
503
  for (const result of cascadeResults) {
@@ -339,7 +551,7 @@ export async function handleTurnEnd(deps) {
339
551
  // recorded on the same phase rather than only logging on a hit.
340
552
  logCascade({
341
553
  phase: "cascade_test_targets",
342
- filePath: files[0] ?? cwd,
554
+ filePath: cascadeLogFilePath,
343
555
  neighborCount: uniqueNeighborFiles.length,
344
556
  metadata: {
345
557
  neighborFiles: uniqueNeighborFiles.slice(0, 10),
@@ -362,6 +574,7 @@ export async function handleTurnEnd(deps) {
362
574
  }
363
575
  if (parts.length > 0) {
364
576
  const section = parts.join("\n\n");
577
+ // @delivery-surface: runtime-turn:cascade-blocker
365
578
  blockerParts.push(section);
366
579
  // #1446 item 1: proves the cascade section reached `blockerParts` —
367
580
  // i.e. it was QUEUED for persistence into the turn-end advisory — not
@@ -374,7 +587,7 @@ export async function handleTurnEnd(deps) {
374
587
  // the agent ever saw the text.
375
588
  logCascade({
376
589
  phase: "cascade_injected",
377
- filePath: files[0] ?? cwd,
590
+ filePath: cascadeLogFilePath,
378
591
  neighborCount: injectedNeighborCount,
379
592
  diagnosticCount: injectedDiagnosticCount,
380
593
  metadata: {
@@ -386,7 +599,7 @@ export async function handleTurnEnd(deps) {
386
599
  }
387
600
  logCascade({
388
601
  phase: "cascade_turn_end",
389
- filePath: files[0] ?? cwd,
602
+ filePath: cascadeLogFilePath,
390
603
  neighborCount: cascadeResults.reduce((s, r) => s + r.neighbors.length, 0),
391
604
  diagnosticCount: cascadeResults.reduce((s, r) => s + r.neighbors.reduce((ns, n) => ns + n.diagnostics.length, 0), 0),
392
605
  metadata: {
@@ -396,8 +609,9 @@ export async function handleTurnEnd(deps) {
396
609
  });
397
610
  }
398
611
  // #1023: surface an HONEST note whenever a cascade run could not compute
399
- // downstream impact (degraded/over-cap graph, missing node, or a thrown
400
- // compute) — never a silent all-clear (#533). This goes to the ADVISORY tier,
612
+ // downstream impact (degraded/over-cap graph, missing node, a thrown compute,
613
+ // or a deliberately budget-truncated neighbor set) — never a silent all-clear
614
+ // (#533). This goes to the ADVISORY tier,
401
615
  // NOT the blocker tier: in an over-cap monorepo the graph is `skipped` on
402
616
  // every edit, so a blocker would fire hard and never clear turn state every
403
617
  // turn (over-escalation — the mirror of the silent-all-clear bug). Advisory
@@ -434,7 +648,8 @@ export async function handleTurnEnd(deps) {
434
648
  const more = files.length > 5 ? ` (+${files.length - 5} more)` : "";
435
649
  lines.push(` • ${detail}: ${shown}${more}`);
436
650
  }
437
- const fileCount = new Set(runs.map((r) => normalizeMapKey(r.filePath))).size;
651
+ const fileCount = new Set(runs.map((r) => normalizeMapKey(r.filePath)))
652
+ .size;
438
653
  const reasons = [...byDetail.keys()].join("; ");
439
654
  return `${frame.lead(fileCount, reasons)}\n${lines.join("\n")}`;
440
655
  };
@@ -446,8 +661,15 @@ export async function handleTurnEnd(deps) {
446
661
  // info-level) so the log can tell an intentional exclusion from a real
447
662
  // graph gap, but it never reaches `buildAdvisory`/the agent.
448
663
  const graphRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason !== "lsp_binding_rejected" &&
449
- r.indeterminate?.reason !== "excluded_by_role");
450
- const bindingRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason === "lsp_binding_rejected");
664
+ r.indeterminate?.reason !== "excluded_by_role" &&
665
+ r.indeterminate?.reason !== "budget_truncated" &&
666
+ r.indeterminate?.budget === undefined);
667
+ const bindingRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason === "lsp_binding_rejected" &&
668
+ r.indeterminate?.budget === undefined);
669
+ // Budget coverage can be merged into a graph or binding marker, so its
670
+ // advisory bucket follows the evidence rather than replacing that reason.
671
+ const budgetRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason === "budget_truncated" ||
672
+ r.indeterminate?.budget !== undefined);
451
673
  // Factual/informational phrasing — the advisory tier wraps this with an
452
674
  // "ℹ️ Advisory — no action required this turn:" label, so an imperative
453
675
  // ("review dependents manually") would contradict it. The #533 substance
@@ -460,6 +682,7 @@ export async function handleTurnEnd(deps) {
460
682
  ? "changed file not in the review graph"
461
683
  : "review graph unavailable",
462
684
  });
685
+ // @delivery-surface: runtime-turn:cascade-coverage-advisory
463
686
  if (graphAdvisory)
464
687
  advisoryParts.push(graphAdvisory);
465
688
  const bindingAdvisory = buildAdvisory(bindingRuns, {
@@ -468,15 +691,57 @@ export async function handleTurnEnd(deps) {
468
691
  `cascade result does not cover them.`,
469
692
  fallbackDetail: () => "cascade diagnostics withheld (binding rejected)",
470
693
  });
694
+ // @delivery-surface: runtime-turn:cascade-coverage-advisory
471
695
  if (bindingAdvisory)
472
696
  advisoryParts.push(bindingAdvisory);
697
+ const budgetAdvisory = buildAdvisory(budgetRuns, {
698
+ lead: (fileCount, reasons) => `Cascade checked the selected neighbors for ${fileCount} edited file(s) this turn, ` +
699
+ `but some eligible dependents were not checked because the cascade budget ` +
700
+ `was exhausted (${reasons}); a clean cascade result does not cover them.`,
701
+ fallbackDetail: (r) => {
702
+ const budget = r.indeterminate?.budget;
703
+ if (!budget)
704
+ return "cascade budget omitted eligible dependents";
705
+ const detail = `cascade budget checked ${budget.selectedCount} of ${budget.eligibleCount} eligible dependents (${budget.truncatedCount} omitted)`;
706
+ return budget.transitiveTruncated
707
+ ? `${detail}; transitive expansion was capped before all eligible dependents were enumerated`
708
+ : detail;
709
+ },
710
+ });
711
+ // @delivery-surface: runtime-turn:cascade-coverage-advisory
712
+ if (budgetAdvisory)
713
+ advisoryParts.push(budgetAdvisory);
473
714
  const fileCount = new Set(indeterminateRuns.map((r) => normalizeMapKey(r.filePath))).size;
715
+ // #1550: attribute each reason to the file that PRODUCED it. This record
716
+ // used to stamp `filePath: files[0] ?? cwd` — the turn's first EDITED file
717
+ // — and a bare `reasons` array with no file association. The two sets are
718
+ // disjoint: a run can be carried across turns (#1443), and an edited file
719
+ // can skip the graph entirely (markdown/JSON return `non_code` before
720
+ // computeImpactCascade ever runs). So the log blamed a file that could not
721
+ // have produced the reason — a markdown file credited with `missing_node`,
722
+ // a non-test source file credited with `excluded_by_role` — and the defect
723
+ // read as "concentrated on test files" only because the first edited file
724
+ // of a turn usually is one. `fileCount` and the agent-facing advisory
725
+ // already keyed off `r.filePath`; only this record's labels did not.
726
+ const byFile = indeterminateRuns.map((r) => ({
727
+ file: toRunnerDisplayPath(cwd, r.filePath),
728
+ reason: r.indeterminate?.reason,
729
+ ...(r.indeterminate?.detail && { detail: r.indeterminate.detail }),
730
+ ...(r.indeterminate?.budget && { budget: r.indeterminate.budget }),
731
+ ...(r.indeterminate?.diagnostic && {
732
+ diagnostic: r.indeterminate.diagnostic,
733
+ }),
734
+ }));
474
735
  logCascade({
475
736
  phase: "cascade_indeterminate",
476
- filePath: files[0] ?? cwd,
737
+ // The first indeterminate run's own file. `files[0] ?? cwd` survives only
738
+ // as a last resort for a run with no path at all.
739
+ filePath: indeterminateRuns[0]?.filePath ?? files[0] ?? cwd,
477
740
  metadata: {
478
741
  fileCount,
479
742
  reasons: indeterminateRuns.map((r) => r.indeterminate?.reason),
743
+ byFile: byFile.slice(0, 20),
744
+ ...(byFile.length > 20 && { byFileTruncated: byFile.length - 20 }),
480
745
  },
481
746
  });
482
747
  }
@@ -530,7 +795,9 @@ export async function handleTurnEnd(deps) {
530
795
  knipMeta = { skipped: true, reason: prevKnip.data.summary };
531
796
  }
532
797
  else {
533
- const knipResult = await knipClient.analyze(cwd, getKnipIgnorePatterns());
798
+ const knipResult = await knipClient.analyze(cwd, getKnipIgnorePatterns(), {
799
+ projectSeq: runtime.projectSeq,
800
+ });
534
801
  // Never overwrite a good scan with a failure (#925, #1467): the last
535
802
  // good result stays until a new successful scan replaces it.
536
803
  const knipWouldPoison = wouldPoisonCache(prevKnip, knipResult);
@@ -541,6 +808,7 @@ export async function handleTurnEnd(deps) {
541
808
  cacheManager.writeCache("knip", knipResult, cwd);
542
809
  }
543
810
  knipMeta = {
811
+ execution: knipResult.execution ?? "executed",
544
812
  success: knipResult.success,
545
813
  totalIssues: knipResult.issues.length,
546
814
  newIssues: 0,
@@ -550,7 +818,9 @@ export async function handleTurnEnd(deps) {
550
818
  ...(knipWouldPoison && { cacheKept: true }),
551
819
  };
552
820
  if (knipResult.success && knipResult.issues.length > 0) {
553
- const issueKey = (i) => `${i.type}:${i.file ?? ""}:${i.name}:${i.line ?? 0}:${i.package ?? ""}`;
821
+ // Deliberately excludes the line number see stableFindingKey's
822
+ // doc comment (#1483: mirrors the dead-code fix in #1477).
823
+ const issueKey = (i) => stableFindingKey(i.type, i.file, i.name, i.package);
554
824
  const prevKeys = new Set((prevKnip?.data?.issues ?? []).map(issueKey));
555
825
  const modifiedSet = new Set(files.map((f) => resolveRunnerPath(cwd, f)));
556
826
  const newIssues = knipResult.issues.filter((issue) => {
@@ -582,6 +852,7 @@ export async function handleTurnEnd(deps) {
582
852
  if (firstPath) {
583
853
  report += ` First location: ${firstPath}\n`;
584
854
  }
855
+ // @delivery-surface: runtime-turn:knip-blocker
585
856
  blockerParts.push(report);
586
857
  }
587
858
  // Turn-end injects only this turn's HIGH-CONFIDENCE, ATTRIBUTABLE
@@ -601,6 +872,7 @@ export async function handleTurnEnd(deps) {
601
872
  : "(unknown)";
602
873
  report += ` ${display}${issue.line ? `:${issue.line}` : ""} — ${issue.name}\n`;
603
874
  }
875
+ // @delivery-surface: runtime-turn:knip-advisory
604
876
  advisoryParts.push(report);
605
877
  }
606
878
  }
@@ -724,6 +996,7 @@ export async function handleTurnEnd(deps) {
724
996
  newIssueTotal += newIssues.length;
725
997
  projectDiagnosticsDelta.push(...newIssues.map((issue) => deadCodeIssueToProjectDiagnostic(cwd, issue, result.language)));
726
998
  projectDiagnosticsSources.add("dead-code");
999
+ // @delivery-surface: runtime-turn:dead-code-advisory
727
1000
  advisoryParts.push(formatDeadCodeDelta(newIssues, result.language));
728
1001
  }
729
1002
  catch (err) {
@@ -748,25 +1021,71 @@ export async function handleTurnEnd(deps) {
748
1021
  durationMs: Date.now() - tDeadCode,
749
1022
  metadata: deadCodeMeta,
750
1023
  });
1024
+ // #1617: running total of findings this turn's advisory/blocker sections
1025
+ // dropped because an agent/user marked them false-positive/won't-fix —
1026
+ // the #1616 suppressed-bucket rule applied to turn_end's own reporting
1027
+ // lanes, so a mark's effect stays visible even though the finding itself
1028
+ // no longer appears above. Review-round F4 (#1625): kept per-lane, not
1029
+ // just a bare total, so the eventual trace says WHICH lane's marks did
1030
+ // the suppressing.
1031
+ let dispositionSuppressedTotal = 0;
1032
+ const dispositionSuppressedByLane = {};
1033
+ function recordDispositionSuppressed(lane, count) {
1034
+ if (count <= 0)
1035
+ return;
1036
+ dispositionSuppressedTotal += count;
1037
+ dispositionSuppressedByLane[lane] =
1038
+ (dispositionSuppressedByLane[lane] ?? 0) + count;
1039
+ }
751
1040
  // govulncheck — surface session_start-cached Go CVE findings as advisory.
752
1041
  // No per-turn re-run in this slice; the cache refreshes at next session_start.
753
1042
  const govCacheEntry = cacheManager.readCache("govulncheck", cwd);
754
- if (govCacheEntry?.data?.findings?.length) {
755
- const findings = govCacheEntry.data.findings.slice(0, 5);
1043
+ // #1622: govulncheck renders a call site as `file:line`, and the cache is a
1044
+ // session_start snapshot — the same stale-line shape as gitleaks, one tier
1045
+ // lower. A CVE is pinned by go.mod, NOT by the call site, so neither an edit
1046
+ // nor a deletion may drop it: `onMissing: "demote"` routes a vanished traced
1047
+ // file into the same arm as an edited one. This gate only ever decides
1048
+ // whether the cited LINE is still worth printing. (Review round H1: the first
1049
+ // cut let a deleted trace file drop the CVE, contradicting this comment, and
1050
+ // `citedPath` reads only the FIRST filename frame — so one deleted file in a
1051
+ // long trace silently killed a CVE that go.mod still pins.)
1052
+ const govGate = gateFindingsByPathFreshness({
1053
+ store: "govulncheck",
1054
+ findings: govCacheEntry?.data?.findings ?? [],
1055
+ cwd,
1056
+ scannedAt: govCacheEntry?.data?.scannedAt,
1057
+ citedPath: (finding) => finding.trace.find((t) => t.filename)?.filename,
1058
+ onMissing: "demote",
1059
+ });
1060
+ const govStale = new Set(govGate.stale);
1061
+ // #1625 review round: the #1622 freshness gate runs FIRST — the disposition
1062
+ // filter's anchor is derived from each finding's post-demotion identity
1063
+ // (this array is already the gate's live+stale partition, never the raw
1064
+ // pre-gate cache). #1627's own post-gate guard (`if (govFindings.length)`)
1065
+ // and this round's disposition guard are the SAME guard — compose them,
1066
+ // never fall back to a raw-cache-length check (that would print the header
1067
+ // with zero rows beneath it whenever either filter empties the list).
1068
+ const govFiltered = filterFindingsByDisposition([...govGate.live, ...govGate.stale], cwd, (f) => govulncheckFindingToProjectDiagnostic(cwd, f));
1069
+ recordDispositionSuppressed("govulncheck", govFiltered.suppressed);
1070
+ const govFindings = govFiltered.kept;
1071
+ if (govFindings.length) {
1072
+ const findings = govFindings.slice(0, 5);
756
1073
  let report = "🛡️ Go CVEs reachable from this code (govulncheck) — upgrade where possible:\n";
757
1074
  for (const f of findings) {
758
1075
  const callSite = f.trace.find((t) => t.filename);
1076
+ const stale = govStale.has(f);
759
1077
  const where = callSite?.filename
760
- ? `${toRunnerDisplayPath(cwd, callSite.filename)}${callSite.line ? `:${callSite.line}` : ""}`
1078
+ ? `${toRunnerDisplayPath(cwd, callSite.filename)}${!stale && callSite.line ? `:${callSite.line}` : ""}${stale ? ` ${STALE_LINE_MARKER}` : ""}`
761
1079
  : (f.module ?? f.packageName ?? "(module)");
762
1080
  const fix = f.fixedVersion
763
1081
  ? ` — upgrade to ${f.fixedVersion} or later`
764
1082
  : " — no fix yet, track upstream";
765
1083
  report += ` ${f.osv} (${where})${fix}\n`;
766
1084
  }
767
- if (govCacheEntry.data.findings.length > findings.length) {
768
- report += ` … and ${govCacheEntry.data.findings.length - findings.length} more\n`;
1085
+ if (govFindings.length > findings.length) {
1086
+ report += ` … and ${govFindings.length - findings.length} more\n`;
769
1087
  }
1088
+ // @delivery-surface: runtime-turn:govulncheck-advisory
770
1089
  advisoryParts.push(report);
771
1090
  }
772
1091
  const trivyCacheEntry = cacheManager.readCache("trivy", cwd);
@@ -776,6 +1095,7 @@ export async function handleTurnEnd(deps) {
776
1095
  // location so a committed/hardcoded secret is reported ONCE (with combined
777
1096
  // provenance) — a blocker, since credentials need rotation before merge.
778
1097
  const gitleaksData = cacheManager.readCache("gitleaks", cwd)?.data;
1098
+ const trivySecretsData = trivyCacheEntry?.data;
779
1099
  // #1461 slice 1 (#1460): the gitleaks cache is TTL-only, so a finding for a
780
1100
  // file deleted after the scan is still served as a 🔴 blocker for the rest
781
1101
  // of the 30-minute window — the live case, and 119 of 126 findings in
@@ -783,20 +1103,85 @@ export async function handleTurnEnd(deps) {
783
1103
  // store (session_start's read only decides whether to re-scan; the
784
1104
  // project-diagnostics path re-scans fresh and reconciles at load), so the
785
1105
  // drop belongs here, before the findings enter the shared secret pipeline.
786
- // gitleaks only in this slice trivy secrets are slice 1's sibling store.
787
- const gitleaksFindings = dropFindingsForMissingPaths({
1106
+ // #1622 extends that gate from existence to freshness, and adds trivy
1107
+ // secrets the sibling store with the identical shape. A cited file edited
1108
+ // after the scan keeps its finding but loses its line number: the credential
1109
+ // may still be there, just not where the snapshot says. Dropping instead
1110
+ // would let any edit — malicious or accidental — mute a real secret.
1111
+ const gitleaksGate = gateFindingsByPathFreshness({
788
1112
  store: "gitleaks",
789
1113
  findings: gitleaksData?.findings ?? [],
790
1114
  cwd,
1115
+ scannedAt: gitleaksData?.scannedAt,
791
1116
  citedPath: (finding) => finding.file,
792
1117
  });
1118
+ const trivySecretsGate = gateFindingsByPathFreshness({
1119
+ store: "trivy-secrets",
1120
+ findings: trivySecretsData?.secrets ?? [],
1121
+ cwd,
1122
+ scannedAt: trivySecretsData?.scannedAt,
1123
+ citedPath: (finding) => finding.file,
1124
+ });
1125
+ // #1617: THE bug this issue exists for — gitleaks findings never passed
1126
+ // through `applyDispositions`, so an agent-marked false-positive/won't-fix
1127
+ // re-reported as a 🔴 STOP blocker on every turn. Filter through the SAME
1128
+ // `gitleaksFindingToProjectDiagnostic` identity `lens_diagnostics
1129
+ // mode=full` surfaces (tool="gitleaks", rule="gitleaks:<ruleId>", the
1130
+ // exact "Potential secret: …" message) so a mark made against what the
1131
+ // agent was shown is honored here too.
1132
+ //
1133
+ // #1625 review round: filtered AFTER the #1622 freshness gate above — the
1134
+ // anchor is derived from each finding's post-demotion identity, never the
1135
+ // raw pre-gate cache. Applied to BOTH `gitleaksGate.live` AND
1136
+ // `gitleaksGate.stale`: `staleSecretEntries` below derives from the stale
1137
+ // arm, and an fp-marked finding that later goes stale must not reappear
1138
+ // there — a suppression escape (and a double count against
1139
+ // `dispositionSuppressedTotal`) that a live-only filter would have missed.
1140
+ //
1141
+ // #1628: trivy-secret findings get the SAME treatment, now that
1142
+ // `trivySecretFindingToProjectDiagnostic` (project-diagnostics/runner-
1143
+ // adapters/trivy.ts) gives them a `lens_diagnostics`-surfaced identity
1144
+ // (tool="trivy", rule="trivy-secret:<ruleId>") to anchor a mark against —
1145
+ // same pattern as gitleaks above, applied to both the live and stale arms
1146
+ // for the same reason.
1147
+ //
1148
+ // ast-grep secret findings need no filtering here — they already went
1149
+ // through dispatch's applyDispositions before reaching
1150
+ // `peekActionableWarnings()`.
1151
+ const gitleaksLiveFiltered = filterFindingsByDisposition(gitleaksGate.live, cwd, (f) => gitleaksFindingToProjectDiagnostic(cwd, f));
1152
+ const gitleaksStaleFiltered = filterFindingsByDisposition(gitleaksGate.stale, cwd, (f) => gitleaksFindingToProjectDiagnostic(cwd, f));
1153
+ recordDispositionSuppressed("gitleaks", gitleaksLiveFiltered.suppressed + gitleaksStaleFiltered.suppressed);
1154
+ const trivySecretsLiveFiltered = filterFindingsByDisposition(trivySecretsGate.live, cwd, (f) => trivySecretFindingToProjectDiagnostic(cwd, f));
1155
+ const trivySecretsStaleFiltered = filterFindingsByDisposition(trivySecretsGate.stale, cwd, (f) => trivySecretFindingToProjectDiagnostic(cwd, f));
1156
+ recordDispositionSuppressed("trivy-secrets", trivySecretsLiveFiltered.suppressed + trivySecretsStaleFiltered.suppressed);
793
1157
  const astSecretWarnings = runtime
794
1158
  .peekActionableWarnings()
795
1159
  .filter(isSecretWarning);
796
1160
  const sessionSecrets = dedupeSecretFindings([
797
- ...fromGitleaks(gitleaksFindings),
798
- ...fromTrivySecrets(trivyCacheEntry?.data?.secrets ?? []),
1161
+ ...fromGitleaks(gitleaksLiveFiltered.kept),
1162
+ ...fromTrivySecrets(trivySecretsLiveFiltered.kept),
799
1163
  ]);
1164
+ // Demoted secrets are addressed by FILE, never by line — the line is the one
1165
+ // field the edit invalidated. Rule id and source survive it and must be
1166
+ // carried through (review round M1): an agent triages an `aws-access-token`
1167
+ // differently from a low-confidence `generic-api-key`, and cannot do that
1168
+ // from a bare path. Deduped on file+rule+source so a file with twenty stale
1169
+ // hits of one rule is named once.
1170
+ const staleSecretEntries = [
1171
+ ...gitleaksStaleFiltered.kept.map((f) => ({
1172
+ file: toRunnerDisplayPath(cwd, f.file),
1173
+ rule: f.ruleId,
1174
+ source: "gitleaks",
1175
+ })),
1176
+ ...trivySecretsStaleFiltered.kept.map((f) => ({
1177
+ file: toRunnerDisplayPath(cwd, f.file),
1178
+ rule: f.ruleId,
1179
+ source: "trivy",
1180
+ })),
1181
+ ];
1182
+ const staleSecrets = [
1183
+ ...new Map(staleSecretEntries.map((e) => [`${e.file}|${e.rule}|${e.source}`, e])).values(),
1184
+ ];
800
1185
  // Locations already surfaced as session-scan secret blockers — used to enrich
801
1186
  // provenance where ast-grep agrees and to suppress the duplicate ast-grep copy
802
1187
  // from the actionable-warnings advisory below.
@@ -817,14 +1202,48 @@ export async function handleTurnEnd(deps) {
817
1202
  if (enriched.length > shown.length) {
818
1203
  report += ` … and ${enriched.length - shown.length} more\n`;
819
1204
  }
1205
+ // @delivery-surface: runtime-turn:secrets-gitleaks,runtime-turn:secrets-trivy
820
1206
  blockerParts.push(report);
821
1207
  }
1208
+ if (staleSecrets.length) {
1209
+ // Its OWN tier, never `advisoryParts` (review round M2). The advisory tier
1210
+ // is labelled "no action required this turn", which would sit directly
1211
+ // above copy telling the agent to re-scan — a section that contradicts its
1212
+ // own heading. This preamble is imperative because the action is real: the
1213
+ // finding is unverified, not dismissed.
1214
+ const shown = staleSecrets.slice(0, 5);
1215
+ let report = `🔑 ACTION NEEDED — secrets were flagged in files that changed after the scan. ${STALE_LINE_MARKER}\n` +
1216
+ "The cached line numbers are no longer trustworthy, so they are withheld. Re-run a secrets scan to confirm or clear these:\n";
1217
+ for (const entry of shown) {
1218
+ report += ` ${entry.file} — ${entry.rule} [${entry.source}]\n`;
1219
+ }
1220
+ if (staleSecrets.length > shown.length) {
1221
+ report += ` … and ${staleSecrets.length - shown.length} more\n`;
1222
+ }
1223
+ // @delivery-surface: runtime-turn:stale-secrets-tier
1224
+ staleSecretParts.push(report);
1225
+ }
822
1226
  // trivy — surface session_start-cached dependency CVEs (#131, Phase 1).
823
1227
  // CRITICAL is a blocker (a known-exploitable CVE in a shipped dep is real
824
1228
  // production risk); HIGH/MEDIUM/LOW are advisory. The agent gets the upgrade
825
1229
  // target as a hint and decides — we never auto-edit lockfiles.
826
- if (trivyCacheEntry?.data?.findings?.length) {
827
- const all = trivyCacheEntry.data.findings;
1230
+ //
1231
+ // #1634: these three trivy reports (critical blocker, non-critical
1232
+ // advisory, license advisory below) name a PACKAGE, not a file:line — there
1233
+ // is no cited path for `gateFindingsByPathFreshness` to stat, so unlike the
1234
+ // secrets/govulncheck stores above this store cannot be freshness-GATED.
1235
+ // It is the delivery gate's explicit-label escape hatch instead
1236
+ // (`clients/finding-delivery-gate.ts`, surfaces `runtime-turn:trivy-*`):
1237
+ // the session_start cache can be arbitrarily old, so its age is stated
1238
+ // plainly rather than presenting a CRITICAL blocker as if it were current.
1239
+ // This runs on top of (not instead of) #1625's disposition filter below —
1240
+ // a suppressed finding never reaches this render at all, so the two only
1241
+ // ever compose.
1242
+ const trivyAgeLabel = formatCacheAgeLabel(trivyCacheEntry?.data?.scannedAt);
1243
+ const trivyFindingsFiltered = filterFindingsByDisposition(trivyCacheEntry?.data?.findings ?? [], cwd, (f) => trivyFindingToProjectDiagnostic(cwd, f));
1244
+ recordDispositionSuppressed("trivy", trivyFindingsFiltered.suppressed);
1245
+ if (trivyFindingsFiltered.kept.length) {
1246
+ const all = trivyFindingsFiltered.kept;
828
1247
  const critical = all.filter((f) => f.severity === "CRITICAL");
829
1248
  const advisory = all.filter((f) => f.severity !== "CRITICAL");
830
1249
  const fmt = (f) => {
@@ -838,32 +1257,35 @@ export async function handleTurnEnd(deps) {
838
1257
  };
839
1258
  if (critical.length) {
840
1259
  const shown = critical.slice(0, 5);
841
- let report = "🔴 STOP — CRITICAL dependency CVEs (trivy). Upgrade before shipping:\n";
1260
+ let report = `🔴 STOP — CRITICAL dependency CVEs (trivy, ${trivyAgeLabel}). Upgrade before shipping:\n`;
842
1261
  for (const f of shown)
843
1262
  report += fmt(f);
844
1263
  if (critical.length > shown.length) {
845
1264
  report += ` … and ${critical.length - shown.length} more\n`;
846
1265
  }
1266
+ // @delivery-surface: runtime-turn:trivy-critical-blocker
847
1267
  blockerParts.push(report);
848
1268
  }
849
1269
  if (advisory.length) {
850
1270
  const shown = advisory.slice(0, 5);
851
- let report = "🛡️ Dependency CVEs (trivy) — upgrade where possible:\n";
1271
+ let report = `🛡️ Dependency CVEs (trivy, ${trivyAgeLabel}) — upgrade where possible:\n`;
852
1272
  for (const f of shown)
853
1273
  report += fmt(f);
854
1274
  if (advisory.length > shown.length) {
855
1275
  report += ` … and ${advisory.length - shown.length} more\n`;
856
1276
  }
1277
+ // @delivery-surface: runtime-turn:trivy-cve-advisory
857
1278
  advisoryParts.push(report);
858
1279
  }
859
1280
  }
860
1281
  // trivy — dependency license risk (#131 Mode 4). Advisory only: a copyleft /
861
1282
  // restricted license in a proprietary tree is a compliance signal, not a
862
- // build break. Surfaced from the same cached `trivy fs` pass.
1283
+ // build break. Surfaced from the same cached `trivy fs` pass — same #1634
1284
+ // explicit-label rationale as the CVE reports above (no cited path to gate).
863
1285
  const licenses = trivyCacheEntry?.data?.licenses ?? [];
864
1286
  if (licenses.length) {
865
1287
  const shown = licenses.slice(0, 5);
866
- let report = "📜 Dependency license risk (trivy) — review for compliance:\n";
1288
+ let report = `📜 Dependency license risk (trivy, ${trivyAgeLabel}) — review for compliance:\n`;
867
1289
  for (const l of shown) {
868
1290
  const cat = l.category ? `, ${l.category}` : "";
869
1291
  report += ` ${l.pkgName} — ${l.license} (${l.severity}${cat})\n`;
@@ -871,11 +1293,30 @@ export async function handleTurnEnd(deps) {
871
1293
  if (licenses.length > shown.length) {
872
1294
  report += ` … and ${licenses.length - shown.length} more\n`;
873
1295
  }
1296
+ // @delivery-surface: runtime-turn:trivy-license-advisory
874
1297
  advisoryParts.push(report);
875
1298
  }
1299
+ // #1616 suppressed-bucket rule: surface the running disposition-drop total
1300
+ // as its own advisory line so a mark's effect is visible, not a silent
1301
+ // absence — trace, not a vanish.
1302
+ if (dispositionSuppressedTotal > 0) {
1303
+ // Review-round F4 (#1625): per-lane attribution, e.g.
1304
+ // "gitleaks 2, govulncheck 1" — not just a bare total.
1305
+ const byLane = Object.entries(dispositionSuppressedByLane)
1306
+ .map(([lane, count]) => `${lane} ${count}`)
1307
+ .join(", ");
1308
+ // @delivery-surface: runtime-turn:disposition-suppressed-notice
1309
+ advisoryParts.push(`suppressed by disposition: ${dispositionSuppressedTotal} finding(s) ` +
1310
+ `dropped from this turn's gitleaks/govulncheck/trivy sections (${byLane}) ` +
1311
+ "(marked false-positive or won't-fix).");
1312
+ }
876
1313
  const t3 = Date.now();
877
1314
  let madgeStats;
878
- if (await depChecker.ensureAvailable()) {
1315
+ // Off by default (#766): this pass only writes debug output, and user-facing
1316
+ // madge diagnostics come from the session-start `madge` cache + the
1317
+ // `lens_diagnostics` extractor. Enabled with `--lens-turn-end-madge` /
1318
+ // `turnEnd.madge.enabled=true` for those who want the per-edit circular note.
1319
+ if (getFlag("lens-turn-end-madge") && (await depChecker.ensureAvailable())) {
879
1320
  const madgeFiles = cacheManager.getFilesForMadge(cwd);
880
1321
  if (madgeFiles.length > 0) {
881
1322
  dbg(`turn_end: madge checking ${madgeFiles.length} file(s) for circular deps`);
@@ -911,7 +1352,9 @@ export async function handleTurnEnd(deps) {
911
1352
  filePath: cwd,
912
1353
  phase: "madge",
913
1354
  durationMs: Date.now() - t3,
914
- ...(madgeStats && { metadata: madgeStats }),
1355
+ // A ~0ms entry with no metadata is indistinguishable from "ran and was
1356
+ // fast" when re-analyzing the #766 tail — mark the skipped case.
1357
+ metadata: madgeStats ?? { skipped: true },
915
1358
  });
916
1359
  // --- Test runner: fire once per turn after all edits are done ---
917
1360
  // Runs for each unique test target across modified files; results appear
@@ -966,8 +1409,14 @@ export async function handleTurnEnd(deps) {
966
1409
  const priorTestCache = cacheManager.readCache("test-runner-findings", cwd)?.data;
967
1410
  const testRunGeneration = (priorTestCache?.testRunGeneration ?? 0) + 1;
968
1411
  const provenanceFiles = [
969
- ...candidates.map((candidate) => ({ path: candidate.abs, role: "source" })),
970
- ...targets.map((target) => ({ path: target.testFile, role: "test" })),
1412
+ ...candidates.map((candidate) => ({
1413
+ path: candidate.abs,
1414
+ role: "source",
1415
+ })),
1416
+ ...targets.map((target) => ({
1417
+ path: target.testFile,
1418
+ role: "test",
1419
+ })),
971
1420
  ];
972
1421
  const launchedFrom = snapshotAdvisoryProvenance({
973
1422
  cwd,
@@ -1029,7 +1478,16 @@ export async function handleTurnEnd(deps) {
1029
1478
  ? `error: ${error}`
1030
1479
  : `${verdict} ${passed}p/${failed}f (${elapsed})`;
1031
1480
  dbg(`turn_end: ${stale ? "[stale] " : ""}test ${runner} ${shortFile} → ${summary}`);
1032
- if (failed > 0) {
1481
+ // #1524: also fires on `error` alone, not just `failed > 0`.
1482
+ // A runner-error result (the suite never started — spawn/
1483
+ // config failure) has `failed === 0` by construction, so
1484
+ // gating on `failed > 0` alone dropped it silently: the
1485
+ // agent got no context at all, and the empty `failures`
1486
+ // array below sent this result down the "all tests
1487
+ // passed" branch, clearing any prior real test-failure
1488
+ // git-guard blocker. `formatResult` already renders the
1489
+ // error-only case as "Could not run tests: ...".
1490
+ if (failed > 0 || error) {
1033
1491
  const formatted = testRunnerClient.formatResult(r.value);
1034
1492
  if (formatted)
1035
1493
  failures.push(formatted);
@@ -1037,7 +1495,8 @@ export async function handleTurnEnd(deps) {
1037
1495
  }
1038
1496
  if (failures.length > 0) {
1039
1497
  const currentGeneration = cacheManager.readCache("test-runner-findings", cwd)?.data?.testRunGeneration;
1040
- if (currentGeneration !== undefined && currentGeneration > testRunGeneration) {
1498
+ if (currentGeneration !== undefined &&
1499
+ currentGeneration > testRunGeneration) {
1041
1500
  dbg(`turn_end: test generation ${testRunGeneration} superseded by ${currentGeneration}`);
1042
1501
  return;
1043
1502
  }
@@ -1055,9 +1514,25 @@ export async function handleTurnEnd(deps) {
1055
1514
  superseded,
1056
1515
  }, cwd);
1057
1516
  if (getFlag("lens-guard") && firedSessionId === runtime.telemetrySessionId) {
1058
- clearGitGuardTestFailure(cacheManager, cwd, runtime, resultValues
1059
- .filter((value) => value.failed === 0)
1060
- .map((value) => value.file));
1517
+ // #1524: `&& !value.error` — a runner-error result has
1518
+ // `failed === 0` (the suite never ran, so nothing could
1519
+ // fail), but it is not a pass. Without the filter it
1520
+ // would clear a prior real test-failure git-guard
1521
+ // blocker on the strength of a suite that never
1522
+ // started. And the call itself is skipped when this
1523
+ // list is empty rather than passed as `[]`:
1524
+ // `clearGitGuardTestFailure`'s own empty-array
1525
+ // fallback treats "no files named" as "clear every
1526
+ // blocked file", so an all-error batch (one go file,
1527
+ // runner-error, zero clean files) would otherwise
1528
+ // clear every blocker through that fallback instead
1529
+ // of clearing none.
1530
+ const cleanFiles = resultValues
1531
+ .filter((value) => value.failed === 0 && !value.error)
1532
+ .map((value) => value.file);
1533
+ if (cleanFiles.length > 0) {
1534
+ clearGitGuardTestFailure(cacheManager, cwd, runtime, cleanFiles);
1535
+ }
1061
1536
  mergeGitGuardTestFailure(cacheManager, cwd, runtime, content, resultValues
1062
1537
  .filter((value) => value.failed > 0)
1063
1538
  .map((value) => value.file));
@@ -1065,7 +1540,8 @@ export async function handleTurnEnd(deps) {
1065
1540
  dbg(`turn_end: ${failures.length} test failure(s) cached for next context injection${stale ? " (stale — turn advanced while tests ran)" : ""}`);
1066
1541
  }
1067
1542
  else if (results.length > 0) {
1068
- if (getFlag("lens-guard") && firedSessionId === runtime.telemetrySessionId) {
1543
+ if (getFlag("lens-guard") &&
1544
+ firedSessionId === runtime.telemetrySessionId) {
1069
1545
  clearGitGuardTestFailure(cacheManager, cwd, runtime, resultValues.map((value) => value.file));
1070
1546
  }
1071
1547
  dbg(`turn_end: all tests passed${stale ? " (stale — turn advanced while tests ran)" : ""}`);
@@ -1098,6 +1574,7 @@ export async function handleTurnEnd(deps) {
1098
1574
  // into an authoritative-looking clean result for the rest of the file.
1099
1575
  // Keep the limitation visible and require a complete graph for this
1100
1576
  // user-facing impact surface (#1070).
1577
+ // @delivery-surface: runtime-turn:call-graph-advisory
1101
1578
  advisoryParts.push("Call-graph impact was not emitted because call-graph extraction coverage is incomplete; " +
1102
1579
  "the affected files may have unreported callers.");
1103
1580
  }
@@ -1115,7 +1592,8 @@ export async function handleTurnEnd(deps) {
1115
1592
  const changedFileKey = normalizeMapKey(resolveRunnerPath(cwd, filePath));
1116
1593
  const fileCallerKeys = [...runtime.callGraph.callers.keys()].filter((k) => {
1117
1594
  const graphFilePath = parseSymbolKey(k).filePath;
1118
- return normalizeMapKey(resolveRunnerPath(cwd, graphFilePath)) === changedFileKey;
1595
+ return (normalizeMapKey(resolveRunnerPath(cwd, graphFilePath)) ===
1596
+ changedFileKey);
1119
1597
  });
1120
1598
  for (const calleeKey of fileCallerKeys.slice(0, 3)) {
1121
1599
  // #1080: drop KNOWN test-role callers BEFORE both the human advisory
@@ -1139,6 +1617,7 @@ export async function handleTurnEnd(deps) {
1139
1617
  }
1140
1618
  }
1141
1619
  if (impactLines.length > 0) {
1620
+ // @delivery-surface: runtime-turn:call-graph-advisory
1142
1621
  advisoryParts.push(`📊 Call-graph impact (changed symbols have callers):\n${impactLines.join("\n")}`);
1143
1622
  }
1144
1623
  if (impactFindings.length > 0) {
@@ -1207,6 +1686,7 @@ export async function handleTurnEnd(deps) {
1207
1686
  writeActionableWarningsReport(cacheManager, cwd, report);
1208
1687
  appendActionableWarningsHistory(cwd, report);
1209
1688
  const advisory = formatActionableWarningsAdvisory(report);
1689
+ // @delivery-surface: runtime-turn:actionable-warnings-advisory
1210
1690
  if (advisory)
1211
1691
  advisoryParts.push(advisory);
1212
1692
  logActionableWarningsEvent({
@@ -1256,6 +1736,7 @@ export async function handleTurnEnd(deps) {
1256
1736
  writeCodeQualityWarningsReport(cacheManager, cwd, qualityReport);
1257
1737
  appendCodeQualityWarningsHistory(cwd, qualityReport);
1258
1738
  const advisory = formatCodeQualityWarningsAdvisory(qualityReport);
1739
+ // @delivery-surface: runtime-turn:code-quality-warnings-advisory
1259
1740
  if (advisory)
1260
1741
  advisoryParts.push(advisory);
1261
1742
  logLatency({
@@ -1283,7 +1764,13 @@ export async function handleTurnEnd(deps) {
1283
1764
  }
1284
1765
  cacheManager.incrementTurnCycle(cwd, currentOwner);
1285
1766
  const labeledAdvisoryParts = advisoryParts.map((p) => `ℹ️ Advisory — no action required this turn:\n${p}`);
1286
- const findingParts = [...blockerParts, ...labeledAdvisoryParts];
1767
+ // Stale-secret parts sit between the two tiers and are NOT relabelled — they
1768
+ // ship the imperative preamble they were built with (#1622 review M2).
1769
+ const findingParts = [
1770
+ ...blockerParts,
1771
+ ...staleSecretParts,
1772
+ ...labeledAdvisoryParts,
1773
+ ];
1287
1774
  if (findingParts.length > 0) {
1288
1775
  dbg(`turn_end: ${blockerParts.length} blocker section(s), ${advisoryParts.length} advisory section(s) found, persisting for next context`);
1289
1776
  const content = capTurnEndMessage(findingParts.join("\n\n"));
@@ -1308,7 +1795,12 @@ export async function handleTurnEnd(deps) {
1308
1795
  blockingFiles: blockerParts.length > 0 ? existingGuard.affectedFiles : undefined,
1309
1796
  projectSeqStart: runtime.turnStartProjectSeq,
1310
1797
  projectSeqEnd: runtime.projectSeq,
1311
- fileSeqByPath: Object.fromEntries(runtime.getFileSeqEntries().map(([filePath, seq]) => [normalizeMapKey(path.resolve(filePath)), seq])),
1798
+ fileSeqByPath: Object.fromEntries(runtime
1799
+ .getFileSeqEntries()
1800
+ .map(([filePath, seq]) => [
1801
+ normalizeMapKey(path.resolve(filePath)),
1802
+ seq,
1803
+ ])),
1312
1804
  fileContentHashes: {},
1313
1805
  consumed: false,
1314
1806
  });
@@ -1361,8 +1853,7 @@ export async function handleTurnEnd(deps) {
1361
1853
  .filter((neighbor) => neighbor.diagnostics.length > 0)
1362
1854
  .map((neighbor) => resolveRunnerPath(cwd, neighbor.filePath))),
1363
1855
  ];
1364
- const affectedFiles = [...new Set(allAffectedFiles)]
1365
- .slice(0, MAX_ADVISORY_AFFECTED_FILES);
1856
+ const affectedFiles = [...new Set(allAffectedFiles)].slice(0, MAX_ADVISORY_AFFECTED_FILES);
1366
1857
  const affectedFilesTruncated = new Set(allAffectedFiles).size > affectedFiles.length;
1367
1858
  cacheManager.writeCache("turn-end-findings", {
1368
1859
  content,
@@ -1372,7 +1863,10 @@ export async function handleTurnEnd(deps) {
1372
1863
  cwd,
1373
1864
  runtime,
1374
1865
  generation: 0,
1375
- files: affectedFiles.map((file) => ({ path: file, role: "affected" })),
1866
+ files: affectedFiles.map((file) => ({
1867
+ path: file,
1868
+ role: "affected",
1869
+ })),
1376
1870
  truncated: affectedFilesTruncated,
1377
1871
  }),
1378
1872
  }, cwd);
@@ -1395,7 +1889,13 @@ export async function handleTurnEnd(deps) {
1395
1889
  }
1396
1890
  if (blockerParts.length === 0) {
1397
1891
  cacheManager.clearTurnState(cwd, currentOwner);
1398
- if (getFlag("lens-guard") && advisoryParts.length === 0 && !runtime.gitGuardHasBlockers) {
1892
+ // `staleSecretParts` counts here too (#1622 review M2): clearing the
1893
+ // findings record while a stale secret is still unverified would drop the
1894
+ // only surviving trace of it.
1895
+ if (getFlag("lens-guard") &&
1896
+ advisoryParts.length === 0 &&
1897
+ staleSecretParts.length === 0 &&
1898
+ !runtime.gitGuardHasBlockers) {
1399
1899
  const guardRecord = cacheManager.readCache("turn-end-findings", cwd)?.data;
1400
1900
  if (guardRecord?.sessionId === runtime.telemetrySessionId &&
1401
1901
  guardRecord.testFailures !== true) {
@@ -1411,10 +1911,18 @@ export async function handleTurnEnd(deps) {
1411
1911
  toolName: "turn_end",
1412
1912
  filePath: cwd,
1413
1913
  durationMs: Date.now() - turnEndStart,
1414
- result: blockerParts.length > 0 ? "blockers_found" : "clean",
1914
+ // #1622 review M2: a pending stale secret is NOT a clean turn. It gets its
1915
+ // own result rather than being promoted to `blockers_found`, which would
1916
+ // undo the demotion the freshness gate just made.
1917
+ result: blockerParts.length > 0
1918
+ ? "blockers_found"
1919
+ : staleSecretParts.length > 0
1920
+ ? "stale_secrets_pending"
1921
+ : "clean",
1415
1922
  metadata: {
1416
1923
  fileCount: files.length,
1417
1924
  blockerSections: blockerParts.length,
1925
+ staleSecretSections: staleSecretParts.length,
1418
1926
  advisorySections: advisoryParts.length,
1419
1927
  },
1420
1928
  });