@herbertgao/pi-extensions 2026.9.15 → 2026.9.17

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 (197) hide show
  1. package/README.md +5 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
  4. package/node_modules/@herbertgao/sol-pi/package.json +1 -1
  5. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +11 -53
  6. package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
  7. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  8. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  9. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
  10. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
  11. package/node_modules/pi-lens/CHANGELOG.md +498 -0
  12. package/node_modules/pi-lens/README.md +45 -2
  13. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
  14. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
  15. package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
  16. package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
  17. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  18. package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
  19. package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
  20. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
  21. package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
  22. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
  23. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
  24. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
  25. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
  26. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
  27. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
  28. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
  29. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
  30. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
  31. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
  32. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  33. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
  34. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  40. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
  41. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
  64. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
  65. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
  66. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
  67. package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
  68. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
  69. package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
  70. package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
  71. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
  72. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
  73. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
  74. package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
  75. package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
  76. package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
  77. package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
  78. package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
  79. package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
  80. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
  81. package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
  82. package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
  83. package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
  84. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
  85. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
  86. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
  87. package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
  88. package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
  89. package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
  90. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  91. package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
  92. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
  93. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
  94. package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
  95. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
  96. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
  97. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
  98. package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
  99. package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
  100. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
  101. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
  102. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
  103. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  104. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
  105. package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
  106. package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
  107. package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
  108. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
  109. package/node_modules/pi-lens/dist/index.js +5695 -3733
  110. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
  111. package/node_modules/pi-lens/dist/mcp/server.js +33 -21
  112. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  113. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
  114. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
  115. package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  116. package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  117. package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
  118. package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
  119. package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  120. package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  121. package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  122. package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
  123. package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
  124. package/node_modules/pi-lens/docs/adr/README.md +26 -0
  125. package/node_modules/pi-lens/docs/agent-tools.md +18 -1
  126. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
  127. package/node_modules/pi-lens/docs/configuration.md +55 -1
  128. package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
  129. package/node_modules/pi-lens/docs/environment-variables.md +61 -4
  130. package/node_modules/pi-lens/docs/features.md +2 -2
  131. package/node_modules/pi-lens/docs/globalconfig.md +6 -1
  132. package/node_modules/pi-lens/docs/language-coverage.md +17 -0
  133. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
  134. package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
  135. package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
  136. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
  137. package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
  138. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  139. package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
  140. package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
  141. package/node_modules/pi-lens/docs/settings.md +12 -3
  142. package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
  143. package/node_modules/pi-lens/package.json +5 -3
  144. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  145. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  146. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  147. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  148. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  149. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  150. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  151. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  152. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  153. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  154. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  155. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  156. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  157. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  158. package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
  159. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  160. package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
  161. package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
  162. package/node_modules/pi-mcp-adapter/README.md +12 -5
  163. package/node_modules/pi-mcp-adapter/config.ts +31 -8
  164. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
  165. package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
  166. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  167. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
  168. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
  169. package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
  170. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
  171. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
  172. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
  173. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  174. package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
  175. package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
  176. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
  177. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
  178. package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
  179. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  180. package/node_modules/pi-mcp-adapter/index.ts +37 -20
  181. package/node_modules/pi-mcp-adapter/init.ts +14 -15
  182. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
  183. package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
  184. package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
  185. package/node_modules/pi-mcp-adapter/package.json +3 -2
  186. package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
  187. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
  188. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
  189. package/node_modules/pi-mcp-adapter/types.ts +2 -2
  190. package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
  191. package/node_modules/pi-mcp-adapter/utils.ts +11 -3
  192. package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
  193. package/node_modules/pi-next-cue/LICENSE +21 -0
  194. package/node_modules/pi-next-cue/README.md +81 -0
  195. package/node_modules/pi-next-cue/index.ts +358 -0
  196. package/node_modules/pi-next-cue/package.json +28 -0
  197. package/package.json +9 -6
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { buildActionableWarningsReport, formatActionableWarningsAdvisory, publishActionableWarningsReport, writeDeferredActionableWarningsReport, } from "./actionable-warnings.js";
4
4
  import { logActionableWarningsEvent } from "./actionable-warnings-logger.js";
5
5
  import { appendCodeQualityWarningsHistory, buildCodeQualityWarningsReport, formatCodeQualityWarningsAdvisory, writeCodeQualityWarningsReport, } from "./code-quality-warnings.js";
6
- import { clearGitGuardTestFailure, mergeGitGuardTestFailure, writeGitGuardRecord, } from "./git-guard.js";
6
+ import { clearGitGuardTestFailure, mergeGitGuardTestFailure, resyncGitGuardAfterInlinePolicy, writeGitGuardRecord, } from "./git-guard.js";
7
7
  import { cascadeSettleWaitMs } from "./cascade-budget.js";
8
8
  import { logCascade } from "./cascade-logger.js";
9
9
  import { normalizeMapKey } from "./path-utils.js";
@@ -13,17 +13,15 @@ import { getKnipIgnorePatterns } from "./file-utils.js";
13
13
  import { formatCacheAgeLabel } from "./finding-delivery-gate.js";
14
14
  import { getFullScanWallClockMs, isWorkspaceSweepActive, runWhenWorkspaceSweepIdle, SWEEP_IDLE_SAFETY_MARGIN_MS, } from "./lsp/workspace-sweep-hold.js";
15
15
  import { isTestRoleCollateral } from "./collateral-test-role.js";
16
- import { classifyAndFilterFindings, } from "./gitleaks-client.js";
17
- import { dedupeSecretFindings, fromAstGrepWarnings, fromGitleaks, fromTrivySecrets, isSecretWarning, secretLocationKey, } from "./secret-findings.js";
16
+ import { isSecretWarning, secretLocationKey } from "./secret-findings.js";
17
+ import { govulncheckLane } from "./turn-end/lanes/govulncheck.js";
18
+ import { secretsLane } from "./turn-end/lanes/secrets.js";
18
19
  import { deadCodeIssueKey, deadCodeIssues, formatDeadCodeDelta, stableFindingKey, } from "./dead-code-client.js";
19
20
  import { logDeadCodeScan } from "./dead-code-logger.js";
20
21
  import { PROJECT_DIAGNOSTICS_CACHE_VERSION, writeProjectDiagnosticsDeltaReport, } from "./project-diagnostics/cache.js";
21
22
  import { deadCodeIssueToProjectDiagnostic } from "./project-diagnostics/runner-adapters/dead-code.js";
22
- import { gitleaksFindingToProjectDiagnostic } from "./project-diagnostics/runner-adapters/gitleaks.js";
23
- import { govulncheckFindingToProjectDiagnostic } from "./project-diagnostics/runner-adapters/govulncheck.js";
24
- import { trivyFindingToProjectDiagnostic, trivySecretFindingToProjectDiagnostic, } from "./project-diagnostics/runner-adapters/trivy.js";
23
+ import { trivyFindingToProjectDiagnostic } from "./project-diagnostics/runner-adapters/trivy.js";
25
24
  import { knipIssuesToProjectDiagnostics } from "./project-diagnostics/runner-adapters/knip.js";
26
- import { applyDispositionsMultiFile } from "./diagnostic-dispositions.js";
27
25
  import { logLatency } from "./latency-logger.js";
28
26
  import { getLspBudgetIdleTimeoutMs, shouldShortenLspIdleTimeout, } from "./lsp-budget.js";
29
27
  import { updateHeartbeat } from "./instance-registry.js";
@@ -42,8 +40,9 @@ import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
42
40
  import { retagAuxiliaryDiagnostics } from "./dispatch/auxiliary-lsp.js";
43
41
  import { runPersistentReverify, } from "./persistent-reverify.js";
44
42
  import { cascadeCarrySuffix } from "./cascade-format.js";
45
- import { applyFindingPolicy, loadProjectRulePolicyMap, renderedRuleIdentities, } from "./dispatch/finding-policy.js";
43
+ import { applyPushedFindingPolicy, filterFindingsByDisposition, } from "./dispatch/finding-policy.js";
46
44
  import { detectFileRole } from "./file-role.js";
45
+ import { applyInlineBlockerPolicy, summarizeInlineBlockerPolicy, } from "./inline-blocker-dispositions.js";
47
46
  import { drainPendingRunnerFindings, dropStaleRunnerFindings, pendingRunnerFindingsSize, } from "./dispatch/pending-runner-findings.js";
48
47
  // #1631 review V2: moved to its own leaf module so a low-level store
49
48
  // (widget-state.ts) can use the marker without importing this orchestrator —
@@ -283,40 +282,6 @@ export async function runTestTargetsBounded(args) {
283
282
  stopReason,
284
283
  };
285
284
  }
286
- /**
287
- * #1617: turn_end reads gitleaks/govulncheck/trivy straight from their
288
- * session-scan caches and formats them into advisory/blocker text — a
289
- * reporting lane parallel to (and, before this fix, entirely bypassing)
290
- * `dispatcher.ts:924`'s `applyDispositions` filter. An agent-marked
291
- * false-positive/won't-fix on one of these findings never suppressed it
292
- * here, so it re-reported on every turn.
293
- *
294
- * Filters `findings` through the SAME anchor derivation the dispatch path
295
- * and `lens_diagnostics mode=full` use (`applyDispositionsMultiFile` in
296
- * `diagnostic-dispositions.ts`), keyed off each lane's own canonical
297
- * `ProjectDiagnostic` adapter (`toDiagnostic`) — the exact tool/rule/message
298
- * identity `lens_diagnostics` already surfaces and `lens_diagnostic_mark`
299
- * already anchors a mark against, not a second, cloned identity that would
300
- * silently diverge from what the agent actually marked.
301
- *
302
- * Returns the surviving findings plus how many were dropped, so a caller can
303
- * still surface a "suppressed by disposition: N" trace (the #1616
304
- * suppressed-bucket rule — a security finding must never vanish with no
305
- * trace, even when the disposition that dropped it is working as intended).
306
- */
307
- function filterFindingsByDisposition(findings, cwd, toDiagnostic) {
308
- if (findings.length === 0)
309
- return { kept: findings, suppressed: 0 };
310
- const candidates = findings.map((finding) => ({
311
- finding,
312
- diagnostic: toDiagnostic(finding),
313
- }));
314
- const survivors = new Set(applyDispositionsMultiFile(candidates.map((c) => c.diagnostic), cwd, (d) => d.filePath));
315
- const kept = candidates
316
- .filter((c) => survivors.has(c.diagnostic))
317
- .map((c) => c.finding);
318
- return { kept, suppressed: findings.length - kept.length };
319
- }
320
285
  /**
321
286
  * Would writing `next` over `prev` throw away a good scan for a failed one?
322
287
  *
@@ -746,9 +711,21 @@ export async function handleTurnEnd(deps) {
746
711
  * branch below.
747
712
  */
748
713
  const pendingDependencyDriftDeliveries = [];
749
- for (const { filePath: bPath, summary, stale, staleReason, } of unresolvedBlockers) {
714
+ /** #3246: one bounded record per TURN for the policy pass, never per finding. */
715
+ const inlinePolicyEntries = [];
716
+ /**
717
+ * #3248: files whose EVERY blocker this pass suppressed — the post-policy
718
+ * survivor set, emptied per file. The commit gate is recomputed from it
719
+ * below, once, after the loop.
720
+ */
721
+ const policySuppressedPaths = [];
722
+ const inlinePolicyStart = Date.now();
723
+ for (const record of unresolvedBlockers) {
724
+ const { filePath: bPath, summary, stale, staleReason } = record;
750
725
  const displayPath = toRunnerDisplayPath(cwd, bPath);
726
+ const tally = { displayPath, sources: record.sources };
751
727
  if (stale) {
728
+ inlinePolicyEntries.push({ ...tally, stale: true });
752
729
  // #1631: demoted — out of the authoritative blocker channel and into the
753
730
  // advisory channel with a stale marker, so the agent is told to re-run
754
731
  // rather than pressured by a verdict that may already be resolved.
@@ -812,10 +789,61 @@ export async function handleTurnEnd(deps) {
812
789
  (retirementNote ? `\n${retirementNote}` : ""));
813
790
  }
814
791
  else {
792
+ // #3246: the agent may have marked one of these blockers
793
+ // `false-positive` AFTER the record was written, via
794
+ // `lens_diagnostic_mark` — which every other findings surface honors.
795
+ // Re-derive the body from the record's structured diagnostics through
796
+ // the shared `dispatch/finding-policy.ts` stack against the file's
797
+ // CURRENT bytes, exactly as the late-auxiliary lane below does.
798
+ const policy = applyInlineBlockerPolicy(record, cwd);
799
+ inlinePolicyEntries.push({ ...tally, stale: false, outcome: policy });
800
+ if (policy.body === undefined) {
801
+ // Every blocker on this file was suppressed. This is a PUSH
802
+ // surface: silence after a mark is the mark working, not a clean
803
+ // verdict, so the count rides the bounded per-turn record below
804
+ // instead of announcing the suppression on every later turn.
805
+ policySuppressedPaths.push(bPath);
806
+ continue;
807
+ }
808
+ // #1616 suppressed-bucket rule: a delivery that still has something to
809
+ // say states what it dropped, once per delivery.
810
+ const suppressedNote = policy.suppressed > 0
811
+ ? ` (suppressed by disposition: ${policy.suppressed} finding(s))`
812
+ : "";
815
813
  // @delivery-surface: runtime-turn:unresolved-inline-blocker
816
- blockerParts.push(`Unresolved from this turn — ${displayPath}:\n${summary}`);
814
+ blockerParts.push(`Unresolved from this turn — ${displayPath}${suppressedNote}:\n${policy.body}`);
817
815
  }
818
816
  }
817
+ // #3248: the post-policy survivor set per file is FINAL here. The commit
818
+ // gate reads a latch (`gitGuardHasBlockers`) before it ever reads the
819
+ // persisted record, and the policy wrote neither — so a file whose every
820
+ // blocker was just suppressed kept blocking `git commit` while the banner
821
+ // above said nothing about it. Recompute the latch from this set; the
822
+ // persisted record is rewritten or cleared further down by the writer that
823
+ // already owns it (`:4287` / `:4379`), from the same survivor set, and that
824
+ // clear is gated on the latch recomputed here.
825
+ const guardLatchBefore = runtime.gitGuardHasBlockers;
826
+ resyncGitGuardAfterInlinePolicy({
827
+ runtime,
828
+ suppressedFilePaths: policySuppressedPaths,
829
+ });
830
+ if (inlinePolicyEntries.length > 0) {
831
+ logLatency({
832
+ type: "phase",
833
+ toolName: "turn_end",
834
+ filePath: cwd,
835
+ phase: "inline_blocker_policy",
836
+ durationMs: Date.now() - inlinePolicyStart,
837
+ metadata: {
838
+ ...summarizeInlineBlockerPolicy(inlinePolicyEntries),
839
+ // #3248: one row per TURN for the gate outcome, never per finding
840
+ // — the flip is the event a reader needs to explain why a commit
841
+ // that was blocked is now allowed.
842
+ guardFilesSuppressed: policySuppressedPaths.length,
843
+ guardLatchCleared: guardLatchBefore && !runtime.gitGuardHasBlockers,
844
+ },
845
+ });
846
+ }
819
847
  // Drain the deferred cascade computes kicked off this turn (#450). They ran
820
848
  // concurrently off the write hot path; wait a bounded time for them here so
821
849
  // their runs are available to the merge below. A compute still in flight at
@@ -1289,6 +1317,9 @@ export async function handleTurnEnd(deps) {
1289
1317
  totalIssues: knipResult.issues.length,
1290
1318
  newIssues: 0,
1291
1319
  blockerIssues: 0,
1320
+ // #3248: bounded per-turn, on the row this lane already writes —
1321
+ // never one record per finding.
1322
+ dispositionSuppressed: 0,
1292
1323
  ...(!knipResult.success && { reason: knipResult.summary }),
1293
1324
  ...(knipResult.failureKind && { failureKind: knipResult.failureKind }),
1294
1325
  ...(knipWouldPoison && { cacheKept: true }),
@@ -1312,7 +1343,30 @@ export async function handleTurnEnd(deps) {
1312
1343
  projectDiagnosticsDelta.push(...knipIssuesToProjectDiagnostics(cwd, newIssues));
1313
1344
  projectDiagnosticsSources.add("knip");
1314
1345
  }
1315
- const blockerIssues = newIssues.filter((i) => i.type === "unlisted" || i.type === "bin");
1346
+ // #3248: what the agent READS goes through the same stored-
1347
+ // disposition filter every other findings surface applies, keyed off
1348
+ // knip's OWN `ProjectDiagnostic` adapter — the identity
1349
+ // `lens_diagnostics` surfaces and `lens_diagnostic_mark` anchors
1350
+ // against — so a marked finding stops re-reporting here. The
1351
+ // `projectDiagnosticsDelta` push above deliberately keeps the
1352
+ // UNFILTERED set: that record is what the scan found, and its reader
1353
+ // (`lens_diagnostics`) applies dispositions on read, so filtering it
1354
+ // here would apply the same policy twice on one lane.
1355
+ // Paired through `flatMap` rather than indexing the adapter's array:
1356
+ // `knipIssuesToProjectDiagnostics` is a straight `issues.map(...)`
1357
+ // (one diagnostic per issue, never empty), so this keeps the pairing
1358
+ // total while staying honest under `noUncheckedIndexedAccess`.
1359
+ const knipPaired = newIssues.flatMap((issue) => knipIssuesToProjectDiagnostics(cwd, [issue]).map((diagnostic) => ({
1360
+ issue,
1361
+ diagnostic,
1362
+ })));
1363
+ const knipFiltered = filterFindingsByDisposition(knipPaired, cwd, (pair) => pair.diagnostic);
1364
+ const knipDeliverable = {
1365
+ kept: knipFiltered.kept.map((pair) => pair.issue),
1366
+ suppressed: knipFiltered.suppressed,
1367
+ };
1368
+ knipMeta.dispositionSuppressed = knipDeliverable.suppressed;
1369
+ const blockerIssues = knipDeliverable.kept.filter((i) => i.type === "unlisted" || i.type === "bin");
1316
1370
  knipMeta.blockerIssues = blockerIssues.length;
1317
1371
  if (blockerIssues.length > 0) {
1318
1372
  let report = "🔴 New unresolved imports/deps in modified code (Knip):\n";
@@ -1339,7 +1393,7 @@ export async function handleTurnEnd(deps) {
1339
1393
  // drown the blockers and burn context every turn); it's available
1340
1394
  // on demand via lens_diagnostics. The delta also feeds the session-slop
1341
1395
  // record (`projectDiagnosticsDelta`) above.
1342
- const unusedExportDelta = newIssues.filter((i) => i.type === "export" || i.type === "enumMember");
1396
+ const unusedExportDelta = knipDeliverable.kept.filter((i) => i.type === "export" || i.type === "enumMember");
1343
1397
  if (unusedExportDelta.length > 0) {
1344
1398
  let report = "⚠️ Newly unused exports in files you edited — check if callers need updating (Knip):\n";
1345
1399
  for (const issue of unusedExportDelta.slice(0, 5)) {
@@ -1387,6 +1441,8 @@ export async function handleTurnEnd(deps) {
1387
1441
  let modifiedSet = null;
1388
1442
  const modifiedFiles = () => (modifiedSet ??= new Set(files.map((f) => resolveRunnerPath(cwd, f))));
1389
1443
  let newIssueTotal = 0;
1444
+ /** #3248: bounded per-turn on this lane's own row, never per finding. */
1445
+ let deadCodeDispositionSuppressed = 0;
1390
1446
  const reasons = [];
1391
1447
  // A malformed client or deps object must never abort turn_end. Before the
1392
1448
  // per-turn delta this block only read a cache; now it iterates and awaits,
@@ -1472,8 +1528,21 @@ export async function handleTurnEnd(deps) {
1472
1528
  newIssueTotal += newIssues.length;
1473
1529
  projectDiagnosticsDelta.push(...newIssues.map((issue) => deadCodeIssueToProjectDiagnostic(cwd, issue, result.language)));
1474
1530
  projectDiagnosticsSources.add("dead-code");
1531
+ // #3248: the rendered advisory takes the stored-disposition
1532
+ // filter, keyed off this lane's OWN adapter. The delta record
1533
+ // above keeps the unfiltered set — its reader applies the policy
1534
+ // on read, so filtering both would double-apply on one lane.
1535
+ const deadCodeDeliverable = filterFindingsByDisposition(newIssues, cwd, (issue) => deadCodeIssueToProjectDiagnostic(cwd, issue, result.language));
1536
+ deadCodeDispositionSuppressed += deadCodeDeliverable.suppressed;
1537
+ // Every finding on this scan was marked: a PUSH surface stays
1538
+ // silent rather than re-announcing that the mark is working; the
1539
+ // count rides this lane's bounded per-turn row.
1540
+ if (deadCodeDeliverable.kept.length === 0) {
1541
+ reasons.push(`${client.id}:all_disposed`);
1542
+ continue;
1543
+ }
1475
1544
  // @delivery-surface: runtime-turn:dead-code-advisory
1476
- advisoryParts.push(formatDeadCodeDelta(newIssues, result.language));
1545
+ advisoryParts.push(formatDeadCodeDelta(deadCodeDeliverable.kept, result.language));
1477
1546
  }
1478
1547
  catch (err) {
1479
1548
  dbg(`turn_end: dead-code(${client.id}) failed: ${err}`);
@@ -1486,6 +1555,7 @@ export async function handleTurnEnd(deps) {
1486
1555
  reasons.push("block_threw");
1487
1556
  }
1488
1557
  deadCodeMeta.newIssues = newIssueTotal;
1558
+ deadCodeMeta.dispositionSuppressed = deadCodeDispositionSuppressed;
1489
1559
  if (reasons.length > 0)
1490
1560
  deadCodeMeta.reason = reasons.join(",");
1491
1561
  }
@@ -1513,212 +1583,167 @@ export async function handleTurnEnd(deps) {
1513
1583
  dispositionSuppressedByLane[lane] =
1514
1584
  (dispositionSuppressedByLane[lane] ?? 0) + count;
1515
1585
  }
1516
- // govulncheck — surface session_start-cached Go CVE findings as advisory.
1517
- // No per-turn re-run in this slice; the cache refreshes at next session_start.
1518
- const govCacheEntry = cacheManager.readCache("govulncheck", cwd);
1519
- // #1622: govulncheck renders a call site as `file:line`, and the cache is a
1520
- // session_start snapshot — the same stale-line shape as gitleaks, one tier
1521
- // lower. A CVE is pinned by go.mod, NOT by the call site, so neither an edit
1522
- // nor a deletion may drop it: `onMissing: "demote"` routes a vanished traced
1523
- // file into the same arm as an edited one. This gate only ever decides
1524
- // whether the cited LINE is still worth printing. (Review round H1: the first
1525
- // cut let a deleted trace file drop the CVE, contradicting this comment, and
1526
- // `citedPath` reads only the FIRST filename frame — so one deleted file in a
1527
- // long trace silently killed a CVE that go.mod still pins.)
1528
- const govGate = gateFindingsByPathFreshness({
1529
- store: "govulncheck",
1530
- findings: govCacheEntry?.data?.findings ?? [],
1586
+ // #1892: ONE read per scanner store per turn_end. The inline lanes got this
1587
+ // for free by reading each cache into a local; now that a lane reads its own
1588
+ // stores (`TurnEndLaneContext.readScannerCache`), the composer and the lanes
1589
+ // must still share one envelope per store — two reads of one store inside
1590
+ // one delivery is the parallel-store shape this umbrella exists to kill, and
1591
+ // the cache's TTL boundary can fall between them, so the secrets tier and
1592
+ // the CVE tier would disagree about the trivy store they both read.
1593
+ //
1594
+ // #3274: the memo holds the PROMISE of each store's envelope, not the
1595
+ // envelope. The read itself moved to `CacheManager.readCacheAsync`, which
1596
+ // suspends on `fs.promises`, so `bounded()` finally has something to bound:
1597
+ // the sync `readCache` completed during ARGUMENT EVALUATION, before
1598
+ // `bounded()` was ever handed a promise, and a wrapper around it registered
1599
+ // a turn_end budget that could never be spent (probed on #3274 with an
1600
+ // already-aborted signal and `ms: 0` — bounded returned undefined and the
1601
+ // read had already parsed). Memoizing the promise is also what keeps the
1602
+ // one-envelope-per-store rule under concurrency: two lanes that await the
1603
+ // same store share ONE read and ONE TTL boundary even when neither has
1604
+ // settled yet, which a value memo could not do.
1605
+ //
1606
+ // An abandoned read yields null, never a throw and never a late envelope:
1607
+ // `bounded()` resolves `undefined` on the budget or the hook's signal and
1608
+ // records ONE `hook-await-exceeded` row per (hook, label) for the deadline
1609
+ // arm, and the memoized promise is already settled by the time the
1610
+ // abandoned read resolves, so nothing mutates after the delivery composed.
1611
+ // A lane that receives null behaves exactly as it does for a cold cache.
1612
+ const scannerCacheReads = new Map();
1613
+ // Stores this delivery composed WITHOUT because a bound fired, in read
1614
+ // order. `bounded()` records the AWAIT's own row (`hook-await-exceeded`),
1615
+ // but only for the deadline arm and only about the await; the agent-facing
1616
+ // consequence — the secrets tier went out with no gitleaks store behind it,
1617
+ // which looks exactly like a clean scan (AGENTS.md defect shape 10) — has no
1618
+ // record otherwise, on either arm. One row per DELIVERY, below, never one
1619
+ // per store and never on a healthy turn.
1620
+ const scannerStoresUnread = [];
1621
+ function readScannerCache(scanner) {
1622
+ let pending = scannerCacheReads.get(scanner);
1623
+ if (pending === undefined) {
1624
+ // An already-aborted turn does not start the read at all. `bounded()`
1625
+ // abandons the await but cannot cancel work already dispatched, so
1626
+ // without this the cancelled turn still pays six file reads whose
1627
+ // result nothing can use. Checked AFTER the memo lookup: a signal that
1628
+ // fires mid-delivery must not give the second lane a different answer
1629
+ // from the first (that is the TTL-boundary split this memo exists for).
1630
+ // It records nothing, on purpose: an already-cancelled turn is Escape,
1631
+ // which `bounded()` also keeps off the ledger — the row below is for a
1632
+ // delivery that went out degraded, not one the user stopped.
1633
+ pending = deps.signal?.aborted
1634
+ ? Promise.resolve(null)
1635
+ : bounded(cacheManager.readCacheAsync(scanner, cwd), {
1636
+ ms: HOOK_WALL_BUDGET_MS.turn_end,
1637
+ signal: deps.signal,
1638
+ hook: "turn_end",
1639
+ label: `readScannerCache:${scanner}`,
1640
+ }).then((entry) => {
1641
+ if (entry === undefined)
1642
+ scannerStoresUnread.push(scanner);
1643
+ return entry ?? null;
1644
+ });
1645
+ scannerCacheReads.set(scanner, pending);
1646
+ }
1647
+ return pending;
1648
+ }
1649
+ const laneCtx = {
1531
1650
  cwd,
1532
- scannedAt: govCacheEntry?.data?.scannedAt,
1533
- citedPath: (finding) => finding.trace.find((t) => t.filename)?.filename,
1534
- onMissing: "demote",
1651
+ signal: deps.signal,
1652
+ readScannerCache,
1653
+ peekActionableWarnings: () => runtime.peekActionableWarnings(),
1654
+ };
1655
+ // govulncheck — the session_start-cached Go CVE store, delivered as ONE
1656
+ // advisory tier by the govulncheck LANE
1657
+ // (`clients/turn-end/lanes/govulncheck.ts`), which owns every rule this block
1658
+ // used to state inline: the `onMissing: "demote"` freshness declaration and
1659
+ // its first-filename-frame `citedPath` (#1622 H1), the disposition anchor
1660
+ // over BOTH freshness arms (#1694 F1), the stale-line withholding and marker,
1661
+ // the module/package fallback, the fix hint and the display cap. No per-turn
1662
+ // re-run in this slice; the cache refreshes at next session_start. Like every
1663
+ // lane it does NOT gate itself — the freshness pass below is shared.
1664
+ const govSources = await govulncheckLane.collect(laneCtx);
1665
+ const trivyCacheEntry = await readScannerCache("trivy");
1666
+ // The secrets lane (`clients/turn-end/lanes/secrets.ts`) reads the gitleaks
1667
+ // and trivy stores, classifies, and states the freshness policy its rows
1668
+ // need; every rendering and disposition rule for the two secrets tiers lives
1669
+ // there. It does NOT gate itself either.
1670
+ const secretsSources = await secretsLane.collect(laneCtx);
1671
+ // #1892: ONE freshness pass for the three cached scanner stores that cite a
1672
+ // file. Each store keeps its own `scannedAt` and its own `onMissing` — the
1673
+ // gate carries source identity, so gitleaks' older scan cannot demote a
1674
+ // trivy secret its newer scan covers, and govulncheck's `demote` verdict for
1675
+ // a deleted path cannot reach gitleaks, which must drop it. What IS shared
1676
+ // is the filesystem: one `statSync` per unique cited path per delivery, one
1677
+ // stat budget, and one bounded drop/demote record instead of up to six.
1678
+ //
1679
+ // #1461 slice 1 (#1460) on gitleaks: the cache is TTL-only, so a finding for
1680
+ // a file deleted after the scan was served as a 🔴 blocker for the rest of
1681
+ // the 30-minute window — 119 of 126 findings in pi-lens's own cache. This
1682
+ // read is the single agent-facing consumer of that store (session_start's
1683
+ // read only decides whether to re-scan; the project-diagnostics path
1684
+ // re-scans fresh and reconciles at load), so the drop belongs here, before
1685
+ // the findings enter the shared secret pipeline. #1622 extends the gate from
1686
+ // existence to freshness, and adds trivy secrets — the sibling store with
1687
+ // the identical shape. A cited file edited after the scan keeps its finding
1688
+ // but loses its line number: the credential may still be there, just not
1689
+ // where the snapshot says. Dropping instead would let any edit — malicious
1690
+ // or accidental — mute a real secret.
1691
+ if (scannerStoresUnread.length > 0) {
1692
+ logLatency({
1693
+ type: "phase",
1694
+ toolName: "turn_end",
1695
+ filePath: cwd,
1696
+ phase: "scanner_cache_read_abandoned",
1697
+ durationMs: 0,
1698
+ metadata: {
1699
+ stores: scannerStoresUnread.join("+"),
1700
+ aborted: deps.signal?.aborted === true,
1701
+ },
1702
+ });
1703
+ }
1704
+ const scannerGates = gateFindingsByPathFreshness({
1705
+ cwd,
1706
+ sources: {
1707
+ ...govSources,
1708
+ ...secretsSources,
1709
+ },
1535
1710
  });
1536
- const govStale = new Set(govGate.stale);
1537
- // #1625 review round: the #1622 freshness gate runs FIRST — the disposition
1538
- // filter's anchor is derived from each finding's post-demotion identity
1539
- // (this array is already the gate's live+stale partition, never the raw
1540
- // pre-gate cache). #1627's own post-gate guard (`if (govFindings.length)`)
1541
- // and this round's disposition guard are the SAME guard — compose them,
1542
- // never fall back to a raw-cache-length check (that would print the header
1543
- // with zero rows beneath it whenever either filter empties the list).
1544
- const govFiltered = filterFindingsByDisposition([...govGate.live, ...govGate.stale], cwd, (f) => govulncheckFindingToProjectDiagnostic(cwd, f));
1545
- recordDispositionSuppressed("govulncheck", govFiltered.suppressed);
1546
- const govFindings = govFiltered.kept;
1547
- if (govFindings.length) {
1548
- const findings = govFindings.slice(0, 5);
1549
- let report = "🛡️ Go CVEs reachable from this code (govulncheck) — upgrade where possible:\n";
1550
- for (const f of findings) {
1551
- const callSite = f.trace.find((t) => t.filename);
1552
- const stale = govStale.has(f);
1553
- const where = callSite?.filename
1554
- ? `${toRunnerDisplayPath(cwd, callSite.filename)}${!stale && callSite.line ? `:${callSite.line}` : ""}${stale ? ` ${STALE_LINE_MARKER}` : ""}`
1555
- : (f.module ?? f.packageName ?? "(module)");
1556
- const fix = f.fixedVersion
1557
- ? ` — upgrade to ${f.fixedVersion} or later`
1558
- : " — no fix yet, track upstream";
1559
- report += ` ${f.osv} (${where})${fix}\n`;
1560
- }
1561
- if (govFindings.length > findings.length) {
1562
- report += ` … and ${govFindings.length - findings.length} more\n`;
1563
- }
1564
- // @delivery-surface: runtime-turn:govulncheck-advisory
1565
- advisoryParts.push(report);
1711
+ const govGate = scannerGates.govulncheck;
1712
+ const gitleaksGate = scannerGates.gitleaks;
1713
+ const trivySecretsGate = scannerGates["trivy-secrets"];
1714
+ const govDelivery = govulncheckLane.render(govulncheckLane.gate({ govulncheck: govGate }, laneCtx), laneCtx);
1715
+ for (const [store, count] of Object.entries(govDelivery.dispositionSuppressed ?? {})) {
1716
+ recordDispositionSuppressed(store, count);
1566
1717
  }
1567
- const trivyCacheEntry = cacheManager.readCache("trivy", cwd);
1718
+ // @delivery-surface: runtime-turn:govulncheck-advisory
1719
+ advisoryParts.push(...(govDelivery.advisoryParts ?? []));
1568
1720
  // Secrets — UNIFIED surfacing (#131 Mode 3). gitleaks, trivy secret, and the
1569
1721
  // ast-grep hardcoded-secret rules can each flag the SAME line with different
1570
1722
  // rule ids, which the rule-keyed diagnostic dedup can't collapse. Collapse by
1571
1723
  // location so a committed/hardcoded secret is reported ONCE (with combined
1572
1724
  // provenance) — a blocker, since credentials need rotation before merge.
1573
- const gitleaksData = cacheManager.readCache("gitleaks", cwd)?.data;
1574
- const trivySecretsData = trivyCacheEntry?.data;
1575
- // Gitleaks deliberately scans gitignored local files and nested repositories
1576
- // so an explicit security audit can still inspect them. The adapter is the
1577
- // source of truth for whether a finding belongs in a blocking delivery lane;
1578
- // filter here before freshness handling so demoted findings cannot leak into
1579
- // either the blocker or stale-secret turn context.
1580
- const boundedClassification = await bounded(classifyAndFilterFindings(gitleaksData?.findings ?? [], cwd), {
1581
- ms: HOOK_WALL_BUDGET_MS.turn_end,
1582
- signal: deps.signal,
1583
- hook: "turn_end",
1584
- label: "classifyAndFilterFindings",
1585
- });
1586
- const classifiedGitleaksFindings = boundedClassification ?? gitleaksData?.findings ?? [];
1587
- if (boundedClassification === undefined) {
1588
- recordDegradationOnce({
1589
- kind: "gitleaks_classification_timeout",
1590
- subject: cwd,
1591
- reason: "gitleaks classification exceeded the turn_end budget; retained raw findings to fail open",
1592
- });
1593
- }
1594
- const blockingGitleaksFindings = classifiedGitleaksFindings.filter((finding) => gitleaksFindingToProjectDiagnostic(cwd, finding).semantic === "blocking");
1595
- // #1461 slice 1 (#1460): the gitleaks cache is TTL-only, so a finding for a
1596
- // file deleted after the scan is still served as a 🔴 blocker for the rest
1597
- // of the 30-minute window — the live case, and 119 of 126 findings in
1598
- // pi-lens's own cache. This read is the single agent-facing consumer of that
1599
- // store (session_start's read only decides whether to re-scan; the
1600
- // project-diagnostics path re-scans fresh and reconciles at load), so the
1601
- // drop belongs here, before the findings enter the shared secret pipeline.
1602
- // #1622 extends that gate from existence to freshness, and adds trivy
1603
- // secrets — the sibling store with the identical shape. A cited file edited
1604
- // after the scan keeps its finding but loses its line number: the credential
1605
- // may still be there, just not where the snapshot says. Dropping instead
1606
- // would let any edit — malicious or accidental — mute a real secret.
1607
- const gitleaksGate = gateFindingsByPathFreshness({
1608
- store: "gitleaks",
1609
- findings: blockingGitleaksFindings,
1610
- cwd,
1611
- scannedAt: gitleaksData?.scannedAt,
1612
- citedPath: (finding) => finding.file,
1613
- });
1614
- const trivySecretsGate = gateFindingsByPathFreshness({
1615
- store: "trivy-secrets",
1616
- findings: trivySecretsData?.secrets ?? [],
1617
- cwd,
1618
- scannedAt: trivySecretsData?.scannedAt,
1619
- citedPath: (finding) => finding.file,
1620
- });
1621
- // #1617: THE bug this issue exists for — gitleaks findings never passed
1622
- // through `applyDispositions`, so an agent-marked false-positive/won't-fix
1623
- // re-reported as a 🔴 STOP blocker on every turn. Filter through the SAME
1624
- // `gitleaksFindingToProjectDiagnostic` identity `lens_diagnostics
1625
- // mode=full` surfaces (tool="gitleaks", rule="gitleaks:<ruleId>", the
1626
- // exact "Potential secret: …" message) so a mark made against what the
1627
- // agent was shown is honored here too.
1628
1725
  //
1629
- // #1625 review round: filtered AFTER the #1622 freshness gate above — the
1630
- // anchor is derived from each finding's post-demotion identity, never the
1631
- // raw pre-gate cache. Applied to BOTH `gitleaksGate.live` AND
1632
- // `gitleaksGate.stale`: `staleSecretEntries` below derives from the stale
1633
- // arm, and an fp-marked finding that later goes stale must not reappear
1634
- // there — a suppression escape (and a double count against
1635
- // `dispositionSuppressedTotal`) that a live-only filter would have missed.
1636
- //
1637
- // #1628: trivy-secret findings get the SAME treatment, now that
1638
- // `trivySecretFindingToProjectDiagnostic` (project-diagnostics/runner-
1639
- // adapters/trivy.ts) gives them a `lens_diagnostics`-surfaced identity
1640
- // (tool="trivy", rule="trivy-secret:<ruleId>") to anchor a mark against —
1641
- // same pattern as gitleaks above, applied to both the live and stale arms
1642
- // for the same reason.
1643
- //
1644
- // ast-grep secret findings need no filtering here — they already went
1645
- // through dispatch's applyDispositions before reaching
1646
- // `peekActionableWarnings()`.
1647
- const gitleaksLiveFiltered = filterFindingsByDisposition(gitleaksGate.live, cwd, (f) => gitleaksFindingToProjectDiagnostic(cwd, f));
1648
- const gitleaksStaleFiltered = filterFindingsByDisposition(gitleaksGate.stale, cwd, (f) => gitleaksFindingToProjectDiagnostic(cwd, f));
1649
- recordDispositionSuppressed("gitleaks", gitleaksLiveFiltered.suppressed + gitleaksStaleFiltered.suppressed);
1650
- const trivySecretsLiveFiltered = filterFindingsByDisposition(trivySecretsGate.live, cwd, (f) => trivySecretFindingToProjectDiagnostic(cwd, f));
1651
- const trivySecretsStaleFiltered = filterFindingsByDisposition(trivySecretsGate.stale, cwd, (f) => trivySecretFindingToProjectDiagnostic(cwd, f));
1652
- recordDispositionSuppressed("trivy-secrets", trivySecretsLiveFiltered.suppressed + trivySecretsStaleFiltered.suppressed);
1653
- const astSecretWarnings = runtime
1654
- .peekActionableWarnings()
1655
- .filter(isSecretWarning);
1656
- const sessionSecrets = dedupeSecretFindings([
1657
- ...fromGitleaks(gitleaksLiveFiltered.kept),
1658
- ...fromTrivySecrets(trivySecretsLiveFiltered.kept),
1659
- ]);
1660
- // Demoted secrets are addressed by FILE, never by line — the line is the one
1661
- // field the edit invalidated. Rule id and source survive it and must be
1662
- // carried through (review round M1): an agent triages an `aws-access-token`
1663
- // differently from a low-confidence `generic-api-key`, and cannot do that
1664
- // from a bare path. Deduped on file+rule+source so a file with twenty stale
1665
- // hits of one rule is named once.
1666
- const staleSecretEntries = [
1667
- ...gitleaksStaleFiltered.kept.map((f) => ({
1668
- file: toRunnerDisplayPath(cwd, f.file),
1669
- rule: f.ruleId,
1670
- source: "gitleaks",
1671
- })),
1672
- ...trivySecretsStaleFiltered.kept.map((f) => ({
1673
- file: toRunnerDisplayPath(cwd, f.file),
1674
- rule: f.ruleId,
1675
- source: "trivy",
1676
- })),
1677
- ];
1678
- const staleSecrets = [
1679
- ...new Map(staleSecretEntries.map((e) => [`${e.file}|${e.rule}|${e.source}`, e])).values(),
1680
- ];
1681
- // Locations already surfaced as session-scan secret blockers — used to enrich
1682
- // provenance where ast-grep agrees and to suppress the duplicate ast-grep copy
1683
- // from the actionable-warnings advisory below.
1684
- const secretBlockedLocations = new Set(sessionSecrets.map((f) => secretLocationKey(f.file, f.line)));
1685
- if (sessionSecrets.length) {
1686
- // Fold in ast-grep provenance ONLY where it coincides with a session
1687
- // secret — don't promote ast-grep-only findings out of their advisory tier.
1688
- const enriched = dedupeSecretFindings([
1689
- ...sessionSecrets,
1690
- ...fromAstGrepWarnings(astSecretWarnings).filter((a) => secretBlockedLocations.has(secretLocationKey(a.file, a.line))),
1691
- ]);
1692
- const shown = enriched.slice(0, 5);
1693
- let report = "🔴 STOP — hardcoded secrets detected. Rotate the credentials and remove them from source:\n";
1694
- for (const f of shown) {
1695
- const where = `${toRunnerDisplayPath(cwd, f.file)}:${f.line}`;
1696
- report += ` ${where} — ${f.rule} [${f.sources.join(" + ")}]${f.description ? `: ${f.description}` : ""}\n`;
1697
- }
1698
- if (enriched.length > shown.length) {
1699
- report += ` … and ${enriched.length - shown.length} more\n`;
1700
- }
1701
- // @delivery-surface: runtime-turn:secrets-gitleaks,runtime-turn:secrets-trivy
1702
- blockerParts.push(report);
1703
- }
1704
- if (staleSecrets.length) {
1705
- // Its OWN tier, never `advisoryParts` (review round M2). The advisory tier
1706
- // is labelled "no action required this turn", which would sit directly
1707
- // above copy telling the agent to re-scan — a section that contradicts its
1708
- // own heading. This preamble is imperative because the action is real: the
1709
- // finding is unverified, not dismissed.
1710
- const shown = staleSecrets.slice(0, 5);
1711
- let report = `🔑 ACTION NEEDED — secrets were flagged in files that changed after the scan. ${STALE_LINE_MARKER}\n` +
1712
- "The cached line numbers are no longer trustworthy, so they are withheld. Re-run a secrets scan to confirm or clear these:\n";
1713
- for (const entry of shown) {
1714
- report += ` ${entry.file} — ${entry.rule} [${entry.source}]\n`;
1715
- }
1716
- if (staleSecrets.length > shown.length) {
1717
- report += ` … and ${staleSecrets.length - shown.length} more\n`;
1718
- }
1719
- // @delivery-surface: runtime-turn:stale-secrets-tier
1720
- staleSecretParts.push(report);
1726
+ // #1892: both tiers are rendered by the secrets LANE
1727
+ // (`clients/turn-end/lanes/secrets.ts`), which owns every rule this block
1728
+ // used to state inline — the two stores' disposition anchors over BOTH
1729
+ // freshness arms (#1617/#1625/#1628), the location dedupe and ast-grep
1730
+ // provenance enrichment, the demoted tier's file+rule+source identity
1731
+ // (#1622 M1) and its own-tier placement (#1622 M2). The composer keeps only
1732
+ // what is not one lane's rule: the gated arms it hands over, the order the
1733
+ // tiers are pushed in, and the per-lane suppression counts that fold into
1734
+ // the one notice below.
1735
+ const secretsDelivery = secretsLane.render(secretsLane.gate({
1736
+ gitleaks: gitleaksGate,
1737
+ "trivy-secrets": trivySecretsGate,
1738
+ }, laneCtx), laneCtx);
1739
+ for (const [store, count] of Object.entries(secretsDelivery.dispositionSuppressed ?? {})) {
1740
+ recordDispositionSuppressed(store, count);
1721
1741
  }
1742
+ const secretBlockedLocations = secretsDelivery.deliveredLocationKeys ?? new Set();
1743
+ // @delivery-surface: runtime-turn:secrets-gitleaks,runtime-turn:secrets-trivy
1744
+ blockerParts.push(...(secretsDelivery.blockerParts ?? []));
1745
+ // @delivery-surface: runtime-turn:stale-secrets-tier
1746
+ staleSecretParts.push(...(secretsDelivery.staleSecretParts ?? []));
1722
1747
  // trivy — surface session_start-cached dependency CVEs (#131, Phase 1).
1723
1748
  // CRITICAL is a blocker (a known-exploitable CVE in a shipped dep is real
1724
1749
  // production risk); HIGH/MEDIUM/LOW are advisory. The agent gets the upgrade
@@ -1828,11 +1853,6 @@ export async function handleTurnEnd(deps) {
1828
1853
  const depResult = depResults.get(absPath);
1829
1854
  if (!depResult)
1830
1855
  continue;
1831
- if (depResult.localSkips && depResult.localSkips > 0) {
1832
- // Not silent: a skipped LOCAL import means madge couldn't resolve
1833
- // it into the graph, so a cycle through it would be missed.
1834
- dbg(`turn_end: madge skipped ${depResult.localSkips} local file(s) resolving ${file} — possible silent cycle-miss`);
1835
- }
1836
1856
  if (depResult.hasCircular && depResult.circular.length > 0) {
1837
1857
  // Whole-project circular deps are surfaced in lens_diagnostics via the
1838
1858
  // session-start `madge` cache + extractor; this per-file turn-end pass
@@ -2660,10 +2680,13 @@ export async function handleTurnEnd(deps) {
2660
2680
  "the affected files may have unreported callers.");
2661
2681
  }
2662
2682
  else {
2683
+ const callGraphStart = Date.now();
2663
2684
  try {
2664
2685
  const { impact, formatImpact, parseSymbolKey } = await import("./call-graph.js");
2665
2686
  const { callGraphImpactToProjectDiagnostics } = await import("./project-diagnostics/runner-adapters/call-graph-impact.js");
2666
2687
  const impactLines = [];
2688
+ /** #3248: bounded per-turn, never per finding. */
2689
+ let callGraphDispositionSuppressed = 0;
2667
2690
  const impactFindings = [];
2668
2691
  for (const filePath of files.slice(0, 5)) {
2669
2692
  // Turn-state files may be cwd-relative while graph keys are absolute,
@@ -2691,7 +2714,33 @@ export async function handleTurnEnd(deps) {
2691
2714
  });
2692
2715
  if (results.length > 0) {
2693
2716
  impactFindings.push({ calleeKey, results });
2694
- const summary = formatImpact(results, cwd);
2717
+ // #3248: the rendered line takes the stored-disposition
2718
+ // filter. A result is mapped to its diagnostic by the
2719
+ // lane's OWN adapter, ONE result at a time, so the
2720
+ // identity can never diverge from the one
2721
+ // `lens_diagnostics` surfaces and a mark anchors against
2722
+ // — and a result the adapter does not map (the Review
2723
+ // tier, an unattributable key, a test-role caller) is
2724
+ // kept unconditionally: it is not markable, so nothing
2725
+ // may suppress it. Re-rendered with `formatImpact`, the
2726
+ // same renderer, never a second one.
2727
+ // `flatMap` rather than a push-under-an-`if`: an entry the
2728
+ // adapter does not map simply yields nothing, so the array
2729
+ // is well-typed with no branch whose removal changes no
2730
+ // behaviour (there is nothing here to mutate).
2731
+ const markable = results.flatMap((result) => callGraphImpactToProjectDiagnostics(cwd, [
2732
+ { calleeKey, results: [result] },
2733
+ ]).map((diagnostic) => ({ result, diagnostic })));
2734
+ const filtered = filterFindingsByDisposition(markable, cwd, (entry) => entry.diagnostic);
2735
+ callGraphDispositionSuppressed += filtered.suppressed;
2736
+ const keptEntries = new Set(filtered.kept);
2737
+ const dropped = new Set(markable
2738
+ .filter((entry) => !keptEntries.has(entry))
2739
+ .map((entry) => entry.result));
2740
+ const survivors = dropped.size === 0
2741
+ ? results
2742
+ : results.filter((r) => !dropped.has(r));
2743
+ const summary = survivors.length > 0 ? formatImpact(survivors, cwd) : "";
2695
2744
  if (summary)
2696
2745
  impactLines.push(` ${parseSymbolKey(calleeKey).symbolName ?? calleeKey}: ${summary}`);
2697
2746
  }
@@ -2708,6 +2757,23 @@ export async function handleTurnEnd(deps) {
2708
2757
  projectDiagnosticsSources.add("call-graph");
2709
2758
  }
2710
2759
  }
2760
+ // #3248: this lane wrote nothing per turn, so its delivery decision
2761
+ // — including how many callers a stored disposition dropped — was
2762
+ // unobservable. One bounded row per turn, only when the lane ran.
2763
+ if (impactFindings.length > 0 || callGraphDispositionSuppressed > 0) {
2764
+ logLatency({
2765
+ type: "phase",
2766
+ toolName: "turn_end",
2767
+ filePath: cwd,
2768
+ phase: "call_graph_impact",
2769
+ durationMs: Date.now() - callGraphStart,
2770
+ metadata: {
2771
+ callees: impactFindings.length,
2772
+ lines: impactLines.length,
2773
+ dispositionSuppressed: callGraphDispositionSuppressed,
2774
+ },
2775
+ });
2776
+ }
2711
2777
  // Non-fatal — call graph is best-effort
2712
2778
  }
2713
2779
  catch {
@@ -2779,14 +2845,7 @@ export async function handleTurnEnd(deps) {
2779
2845
  turnIndex: runtime.turnIndex,
2780
2846
  files,
2781
2847
  modifiedRangesByFile,
2782
- // Suppress the ast-grep secret advisory at any location already
2783
- // surfaced in the unified secrets blocker above (#131 Mode 3) — the
2784
- // secret is reported once, not twice.
2785
- dispatchWarnings: runtime
2786
- .peekActionableWarnings()
2787
- .filter((w) => !(isSecretWarning(w) &&
2788
- typeof w.line === "number" &&
2789
- secretBlockedLocations.has(secretLocationKey(w.filePath, w.line)))),
2848
+ dispatchWarnings: runtime.peekActionableWarnings(),
2790
2849
  includeLspCodeActions: !!getFlag("lens-actionable-warning-actions"),
2791
2850
  projectSeqStart: runtime.turnStartProjectSeq,
2792
2851
  projectSeqEnd: runtime.projectSeq,
@@ -2883,7 +2942,36 @@ export async function handleTurnEnd(deps) {
2883
2942
  // #2521: `cwd` is what resolves the store location the advisory
2884
2943
  // names -- it is the SAME cwd `publishActionableWarningsReport`
2885
2944
  // just wrote through, so the two cannot disagree.
2886
- const advisory = formatActionableWarningsAdvisory(publishResult.report, cwd, host);
2945
+ const advisory = formatActionableWarningsAdvisory(publishResult.report, cwd, host, (report) => {
2946
+ let dispositionSuppressed = 0;
2947
+ const files = report.files
2948
+ .map((file) => {
2949
+ const policy = filterFindingsByDisposition(file.warnings, cwd, (warning) => {
2950
+ const { line, column, rule, code } = warning;
2951
+ return {
2952
+ filePath: warning.filePath,
2953
+ severity: warning.severity,
2954
+ semantic: "warning",
2955
+ tool: warning.tool,
2956
+ runner: warning.tool,
2957
+ message: warning.message,
2958
+ source: warning.origin === "lsp" ? "lsp" : "dispatch",
2959
+ ...(line === undefined ? {} : { line: warning.line }),
2960
+ ...(column === undefined ? {} : { column: warning.column }),
2961
+ ...(rule === undefined ? {} : { rule: warning.rule }),
2962
+ ...(code === undefined ? {} : { code }),
2963
+ };
2964
+ });
2965
+ dispositionSuppressed += policy.suppressed;
2966
+ const kept = policy.kept.filter((warning) => !(isSecretWarning(warning) &&
2967
+ typeof warning.line === "number" &&
2968
+ secretBlockedLocations.has(secretLocationKey(warning.filePath, warning.line))));
2969
+ return kept.length > 0 ? { ...file, warnings: kept } : undefined;
2970
+ })
2971
+ .filter((file) => file !== undefined);
2972
+ recordDispositionSuppressed("actionable-warnings", dispositionSuppressed);
2973
+ return { files, suppressed: dispositionSuppressed };
2974
+ });
2887
2975
  // @delivery-surface: runtime-turn:actionable-warnings-advisory
2888
2976
  if (advisory)
2889
2977
  advisoryParts.push(advisory);
@@ -2927,11 +3015,46 @@ export async function handleTurnEnd(deps) {
2927
3015
  }
2928
3016
  const t5 = Date.now();
2929
3017
  try {
3018
+ // #3248: the code-quality records were filtered at DISPATCH time, before
3019
+ // any `lens_diagnostic_mark`. `lens_diagnostics mode=delta` re-applies
3020
+ // dispositions when it re-serves this same cache
3021
+ // (`tools/lens-diagnostics.ts`'s `visibleWarningFiles`), so without this
3022
+ // the turn-end advisory counted warnings the delta view had already
3023
+ // dropped — the two surfaces disagreed about the same records. Filtered
3024
+ // at the report INPUT so the advisory, the persisted report and the delta
3025
+ // view all agree; the delta view's own filter then re-applies to the same
3026
+ // set and is idempotent. Per file, because the anchor is content-bound.
3027
+ const qualityWarningsByFile = new Map();
3028
+ for (const warning of runtime.peekCodeQualityWarnings()) {
3029
+ const group = qualityWarningsByFile.get(warning.filePath);
3030
+ if (group)
3031
+ group.push(warning);
3032
+ else
3033
+ qualityWarningsByFile.set(warning.filePath, [warning]);
3034
+ }
3035
+ const qualityWarnings = [];
3036
+ let qualityDispositionSuppressed = 0;
3037
+ for (const [filePath, group] of qualityWarningsByFile) {
3038
+ let content;
3039
+ try {
3040
+ content = fs.readFileSync(filePath, "utf-8");
3041
+ }
3042
+ catch {
3043
+ content = undefined;
3044
+ }
3045
+ const { kept, suppressed } = applyPushedFindingPolicy(group, {
3046
+ cwd,
3047
+ filePath,
3048
+ content,
3049
+ });
3050
+ qualityWarnings.push(...kept);
3051
+ qualityDispositionSuppressed += suppressed;
3052
+ }
2930
3053
  const qualityReport = buildCodeQualityWarningsReport({
2931
3054
  cwd,
2932
3055
  sessionId: runtime.telemetrySessionId,
2933
3056
  turnIndex: runtime.turnIndex,
2934
- warnings: runtime.peekCodeQualityWarnings(),
3057
+ warnings: qualityWarnings,
2935
3058
  modifiedRangesByFile,
2936
3059
  projectSeqStart: runtime.turnStartProjectSeq,
2937
3060
  projectSeqEnd: runtime.projectSeq,
@@ -2949,7 +3072,11 @@ export async function handleTurnEnd(deps) {
2949
3072
  filePath: cwd,
2950
3073
  phase: "code_quality_warnings_report",
2951
3074
  durationMs: Date.now() - t5,
2952
- metadata: qualityReport.summary,
3075
+ metadata: {
3076
+ ...qualityReport.summary,
3077
+ // #3248: bounded per-turn on this lane's own row.
3078
+ dispositionSuppressed: qualityDispositionSuppressed,
3079
+ },
2953
3080
  });
2954
3081
  }
2955
3082
  catch (err) {
@@ -2980,6 +3107,8 @@ export async function handleTurnEnd(deps) {
2980
3107
  let runnerFindingsStale = 0;
2981
3108
  let runnerFindingsFailed = 0;
2982
3109
  let runnerFindingsDropped = 0;
3110
+ /** #3248: bounded per-turn on this lane's own row, never per finding. */
3111
+ let runnerFindingsDispositionSuppressed = 0;
2983
3112
  const runnerFindingsDeliveredIds = [];
2984
3113
  for (const pending of pendingRunnerFindings) {
2985
3114
  const result = pending.result;
@@ -2996,12 +3125,15 @@ export async function handleTurnEnd(deps) {
2996
3125
  const findings = result.diagnostics;
2997
3126
  if (findings.length === 0)
2998
3127
  continue;
2999
- const gate = gateFindingsByPathFreshness({
3000
- store: "late-runner-findings",
3001
- findings,
3128
+ const { "late-runner-findings": gate } = gateFindingsByPathFreshness({
3002
3129
  cwd,
3003
- scannedAt: pending.markedAtMs,
3004
- citedPath: (finding) => finding.filePath,
3130
+ sources: {
3131
+ "late-runner-findings": {
3132
+ findings,
3133
+ scannedAt: pending.markedAtMs,
3134
+ citedPath: (finding) => finding.filePath,
3135
+ },
3136
+ },
3005
3137
  });
3006
3138
  runnerFindingsStale += gate.stale.length;
3007
3139
  if (gate.stale.length > 0) {
@@ -3014,15 +3146,46 @@ export async function handleTurnEnd(deps) {
3014
3146
  if (gate.live.length === 0)
3015
3147
  continue;
3016
3148
  const displayPath = toRunnerDisplayPath(cwd, pending.filePath);
3017
- const lines = gate.live.map((finding) => ` ${displayPath}:${finding.line ?? 1}:${finding.column ?? 1} [${finding.rule ?? finding.id}] ${finding.message}`);
3018
- runnerFindingsDelivered += gate.live.length;
3019
- for (const finding of gate.live) {
3149
+ // #3248: the survivors are what the agent READS, so they take the same
3150
+ // policy stack the late-AUXILIARY drain below applies — this lane is its
3151
+ // twin (same post-gate `Diagnostic[]`, same rendering) and was the one
3152
+ // push surface still re-reporting a finding the agent had marked. AFTER
3153
+ // the freshness gate, like every other lane: the anchor is derived from
3154
+ // the post-gate identity, never the raw pre-gate set. The file's CURRENT
3155
+ // bytes; an unreadable file fails open inside the helper.
3156
+ let runnerContent;
3157
+ try {
3158
+ runnerContent = fs.readFileSync(pending.filePath, "utf-8");
3159
+ }
3160
+ catch {
3161
+ runnerContent = undefined;
3162
+ }
3163
+ const { kept: runnerKept, suppressed: runnerSuppressedHere } = applyPushedFindingPolicy(gate.live, {
3164
+ cwd,
3165
+ filePath: pending.filePath,
3166
+ content: runnerContent,
3167
+ });
3168
+ runnerFindingsDispositionSuppressed += runnerSuppressedHere;
3169
+ if (runnerKept.length === 0) {
3170
+ // Every late finding was marked. A PUSH surface stays silent rather
3171
+ // than re-announcing that the mark is working; the count rides this
3172
+ // lane's bounded per-turn row below.
3173
+ continue;
3174
+ }
3175
+ const lines = runnerKept.map((finding) => ` ${displayPath}:${finding.line ?? 1}:${finding.column ?? 1} [${finding.rule ?? finding.id}] ${finding.message}`);
3176
+ runnerFindingsDelivered += runnerKept.length;
3177
+ for (const finding of runnerKept) {
3020
3178
  if (runnerFindingsDeliveredIds.length < 50) {
3021
3179
  runnerFindingsDeliveredIds.push(finding.id);
3022
3180
  }
3023
3181
  }
3182
+ // #1616 suppressed-bucket rule: a delivery that still has something to
3183
+ // say states what it dropped, once per delivery.
3184
+ const runnerSuppressedNote = runnerSuppressedHere > 0
3185
+ ? `; suppressed by disposition: ${runnerSuppressedHere} finding(s)`
3186
+ : "";
3024
3187
  // @delivery-surface: runtime-turn:late-runner-findings
3025
- advisoryParts.push(`⏱️ Late runner diagnostics (${pending.runnerId} completed after the edit):\n${lines.join("\n")}`);
3188
+ advisoryParts.push(`⏱️ Late runner diagnostics (${pending.runnerId} completed after the edit${runnerSuppressedNote}):\n${lines.join("\n")}`);
3026
3189
  }
3027
3190
  logLatency({
3028
3191
  type: "phase",
@@ -3036,6 +3199,7 @@ export async function handleTurnEnd(deps) {
3036
3199
  stale: runnerFindingsStale,
3037
3200
  failed: runnerFindingsFailed,
3038
3201
  dropped: runnerFindingsDropped,
3202
+ dispositionSuppressed: runnerFindingsDispositionSuppressed,
3039
3203
  deliveredIds: runnerFindingsDeliveredIds,
3040
3204
  },
3041
3205
  });
@@ -3264,12 +3428,15 @@ export async function handleTurnEnd(deps) {
3264
3428
  // one), so a stale-arm replay would double-report old content.
3265
3429
  // Both drops are COUNTED here and in the latency record below —
3266
3430
  // never silent (shape 10).
3267
- const gate = gateFindingsByPathFreshness({
3268
- store: "late-auxiliary-findings",
3269
- findings: retained,
3431
+ const { "late-auxiliary-findings": gate } = gateFindingsByPathFreshness({
3270
3432
  cwd,
3271
- scannedAt: pair.markedAtMs,
3272
- citedPath: () => lateAuxPath,
3433
+ sources: {
3434
+ "late-auxiliary-findings": {
3435
+ findings: retained,
3436
+ scannedAt: pair.markedAtMs,
3437
+ citedPath: () => lateAuxPath,
3438
+ },
3439
+ },
3273
3440
  });
3274
3441
  lateAuxStale += gate.stale.length;
3275
3442
  lateAuxMissing +=
@@ -3314,15 +3481,15 @@ export async function handleTurnEnd(deps) {
3314
3481
  // a late pair. Runs AFTER the freshness gate, like the #1625
3315
3482
  // govulncheck/secrets filters: the anchor is derived from the
3316
3483
  // post-gate identity, never the raw pre-gate set.
3317
- const { kept: lateAuxKept } = applyFindingPolicy(gate.live, {
3484
+ // #3248: the four arguments moved into
3485
+ // `applyPushedFindingPolicy` so the late-RUNNER drain below
3486
+ // cannot make a second copy of them. Same stack, same
3487
+ // identities, same fail-open content rule.
3488
+ const { kept: lateAuxKept, suppressed: lateAuxSuppressedHere } = applyPushedFindingPolicy(gate.live, {
3318
3489
  cwd,
3319
3490
  filePath: lateAuxPath,
3320
- content: lateAuxContent ?? "",
3321
- // mtime-cached, so a drain of several files costs one stat.
3322
- policyMap: loadProjectRulePolicyMap(cwd),
3323
- identities: renderedRuleIdentities,
3491
+ content: lateAuxContent,
3324
3492
  });
3325
- const lateAuxSuppressedHere = gate.live.length - lateAuxKept.length;
3326
3493
  lateAuxDispositionSuppressed += lateAuxSuppressedHere;
3327
3494
  if (lateAuxKept.length === 0) {
3328
3495
  // Every late finding was suppressed. This is a PUSH surface: