pi-lens 4.2.0 → 4.3.0

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 (157) hide show
  1. package/CHANGELOG.md +543 -0
  2. package/README.md +45 -2
  3. package/dist/clients/actionable-warnings.js +44 -16
  4. package/dist/clients/advisory-provenance.js +187 -51
  5. package/dist/clients/cache-manager.js +64 -5
  6. package/dist/clients/cascade-format.js +24 -0
  7. package/dist/clients/child-unref.js +17 -8
  8. package/dist/clients/config-diagnostic-codes.js +2 -0
  9. package/dist/clients/config-locations.js +237 -9
  10. package/dist/clients/config-resolve.js +7 -1
  11. package/dist/clients/degradation-ledger.js +33 -3
  12. package/dist/clients/dependency-checker.js +87 -64
  13. package/dist/clients/diagnostic-dispositions.js +39 -3
  14. package/dist/clients/dispatch/finding-policy.js +118 -1
  15. package/dist/clients/dispatch/indent-detect.js +8 -2
  16. package/dist/clients/dispatch/inline-suppressions.js +23 -4
  17. package/dist/clients/dispatch/rule-ignores.js +14 -7
  18. package/dist/clients/dispatch/runners/actionlint.js +26 -28
  19. package/dist/clients/dispatch/runners/biome-check.js +38 -25
  20. package/dist/clients/dispatch/runners/cpp-check.js +32 -49
  21. package/dist/clients/dispatch/runners/credo.js +16 -14
  22. package/dist/clients/dispatch/runners/cue-vet.js +25 -7
  23. package/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  24. package/dist/clients/dispatch/runners/detekt.js +11 -4
  25. package/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  26. package/dist/clients/dispatch/runners/elixir-check.js +31 -46
  27. package/dist/clients/dispatch/runners/eslint.js +29 -46
  28. package/dist/clients/dispatch/runners/gleam-check.js +91 -11
  29. package/dist/clients/dispatch/runners/go-vet.js +16 -2
  30. package/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  31. package/dist/clients/dispatch/runners/hadolint.js +27 -15
  32. package/dist/clients/dispatch/runners/htmlhint.js +17 -8
  33. package/dist/clients/dispatch/runners/javac.js +31 -21
  34. package/dist/clients/dispatch/runners/ktlint.js +34 -60
  35. package/dist/clients/dispatch/runners/lsp.js +6 -5
  36. package/dist/clients/dispatch/runners/markdownlint.js +12 -5
  37. package/dist/clients/dispatch/runners/mypy.js +2 -1
  38. package/dist/clients/dispatch/runners/oxlint.js +22 -6
  39. package/dist/clients/dispatch/runners/php-lint.js +39 -16
  40. package/dist/clients/dispatch/runners/phpstan.js +1 -1
  41. package/dist/clients/dispatch/runners/rubocop.js +27 -18
  42. package/dist/clients/dispatch/runners/ruff.js +18 -19
  43. package/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  44. package/dist/clients/dispatch/runners/shellcheck.js +6 -2
  45. package/dist/clients/dispatch/runners/spellcheck.js +10 -3
  46. package/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  47. package/dist/clients/dispatch/runners/stylelint.js +10 -2
  48. package/dist/clients/dispatch/runners/swiftlint.js +11 -2
  49. package/dist/clients/dispatch/runners/taplo.js +12 -6
  50. package/dist/clients/dispatch/runners/tflint.js +30 -15
  51. package/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  52. package/dist/clients/dispatch/runners/trivy-config.js +8 -2
  53. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  54. package/dist/clients/dispatch/runners/vale.js +15 -3
  55. package/dist/clients/dispatch/runners/yamllint.js +12 -2
  56. package/dist/clients/dispatch/runners/zig-check.js +27 -39
  57. package/dist/clients/dispatch/suppress-writer.js +13 -3
  58. package/dist/clients/effective-config.js +3 -1
  59. package/dist/clients/extension-log.js +48 -1
  60. package/dist/clients/finding-delivery-gate.js +53 -23
  61. package/dist/clients/formatters.js +49 -11
  62. package/dist/clients/git-guard.js +247 -34
  63. package/dist/clients/gradle-ktfmt-style.js +121 -25
  64. package/dist/clients/inline-blocker-dispositions.js +186 -0
  65. package/dist/clients/install-diagnostics.js +19 -6
  66. package/dist/clients/installer/index.js +281 -32
  67. package/dist/clients/jscpd-client.js +8 -0
  68. package/dist/clients/latency-logger.js +8 -0
  69. package/dist/clients/lens-config.js +66 -8
  70. package/dist/clients/lsp/cascade-tier.js +3 -0
  71. package/dist/clients/lsp/client.js +139 -11
  72. package/dist/clients/lsp/config.js +9 -7
  73. package/dist/clients/lsp/content-limits.js +41 -0
  74. package/dist/clients/lsp/index.js +42 -8
  75. package/dist/clients/lsp/launch.js +23 -2
  76. package/dist/clients/lsp/server.js +106 -25
  77. package/dist/clients/lsp/sync-kind.js +14 -0
  78. package/dist/clients/lsp/wait-policy/strategies.js +105 -16
  79. package/dist/clients/mcp/analyze.js +9 -2
  80. package/dist/clients/mcp/ipc.js +232 -44
  81. package/dist/clients/mcp/review.js +53 -7
  82. package/dist/clients/opengrep-client.js +11 -6
  83. package/dist/clients/path-utils.js +51 -9
  84. package/dist/clients/persistent-reverify.js +271 -0
  85. package/dist/clients/pipeline.js +136 -67
  86. package/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  87. package/dist/clients/project-lens-config.js +11 -3
  88. package/dist/clients/review-graph/builder.js +34 -26
  89. package/dist/clients/review-graph/git-identity.js +16 -10
  90. package/dist/clients/ruff-client.js +31 -4
  91. package/dist/clients/runtime-coordinator.js +38 -4
  92. package/dist/clients/runtime-tool-call.js +16 -5
  93. package/dist/clients/runtime-tool-result.js +27 -12
  94. package/dist/clients/runtime-turn.js +571 -287
  95. package/dist/clients/safe-spawn.js +256 -65
  96. package/dist/clients/skills-resolver.js +38 -2
  97. package/dist/clients/spawn-output-cap.js +84 -1
  98. package/dist/clients/tree-sitter-client.js +119 -58
  99. package/dist/clients/turn-end/lane.js +51 -0
  100. package/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  101. package/dist/clients/turn-end/lanes/secrets.js +204 -0
  102. package/dist/clients/warm-attach.js +59 -0
  103. package/dist/clients/widget-state.js +294 -17
  104. package/dist/clients/workspace-topology.js +57 -3
  105. package/dist/clients/zizmor-config.js +26 -13
  106. package/dist/index.js +6342 -3995
  107. package/dist/mcp/analyze-cli.js +4 -0
  108. package/dist/mcp/server.js +33 -21
  109. package/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  110. package/dist/tools/lens-diagnostic-mark.js +14 -2
  111. package/dist/tools/lens-diagnostics.js +127 -33
  112. package/dist/tools/lsp-diagnostics.js +150 -12
  113. package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  114. package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  115. package/docs/adr/0003-git-guard-latch-writer.md +203 -0
  116. package/docs/adr/0004-disposition-policy-seam.md +38 -0
  117. package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  118. package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  119. package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  120. package/docs/adr/0008-turn-end-lane-interface.md +165 -0
  121. package/docs/adr/0009-reported-path-attribution.md +114 -0
  122. package/docs/adr/README.md +26 -0
  123. package/docs/agent-tools.md +18 -1
  124. package/docs/ast-grep_rules_catalog.md +4 -3
  125. package/docs/configuration.md +55 -1
  126. package/docs/engineering-principles.md +202 -0
  127. package/docs/environment-variables.md +61 -4
  128. package/docs/features.md +2 -2
  129. package/docs/globalconfig.md +6 -1
  130. package/docs/language-coverage.md +17 -0
  131. package/docs/lsp-capability-matrix.md +98 -31
  132. package/docs/pi-lens-fixer.md +57 -3
  133. package/docs/pi-lens-investigator.md +9 -0
  134. package/docs/pi-lens-reviewer.md +16 -0
  135. package/docs/pi-lens-subagent.md +9 -0
  136. package/docs/public-api-stability.md +1 -0
  137. package/docs/release-qa-baseline.md +2 -0
  138. package/docs/servercapabilities.md +21 -11
  139. package/docs/settings.md +12 -3
  140. package/docs/subagent-compat.md +1 -1
  141. package/package.json +5 -3
  142. package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  143. package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  144. package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  145. package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  146. package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  147. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  148. package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  149. package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  150. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  151. package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  152. package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  153. package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  154. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  155. package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  156. package/scripts/install-selftest.mjs +21 -17
  157. package/scripts/lib/web-tree-sitter-dir.mjs +123 -0
@@ -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";
@@ -40,8 +38,11 @@ import { getLSPService } from "./lsp/index.js";
40
38
  import { drainPendingAuxCapEvictedCount, drainPendingAuxiliaryCoverage, isPendingAuxiliaryPastRearmTtl, rearmPendingAuxiliaryCoverage, MAX_LATE_AUX_REARMS, pendingAuxiliaryCoverageSize, } from "./lsp/pending-aux-coverage.js";
41
39
  import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
42
40
  import { retagAuxiliaryDiagnostics } from "./dispatch/auxiliary-lsp.js";
43
- import { applyFindingPolicy, loadProjectRulePolicyMap, renderedRuleIdentities, } from "./dispatch/finding-policy.js";
41
+ import { runPersistentReverify, } from "./persistent-reverify.js";
42
+ import { cascadeCarrySuffix } from "./cascade-format.js";
43
+ import { applyPushedFindingPolicy, filterFindingsByDisposition, } from "./dispatch/finding-policy.js";
44
44
  import { detectFileRole } from "./file-role.js";
45
+ import { applyInlineBlockerPolicy, summarizeInlineBlockerPolicy, } from "./inline-blocker-dispositions.js";
45
46
  import { drainPendingRunnerFindings, dropStaleRunnerFindings, pendingRunnerFindingsSize, } from "./dispatch/pending-runner-findings.js";
46
47
  // #1631 review V2: moved to its own leaf module so a low-level store
47
48
  // (widget-state.ts) can use the marker without importing this orchestrator —
@@ -281,40 +282,6 @@ export async function runTestTargetsBounded(args) {
281
282
  stopReason,
282
283
  };
283
284
  }
284
- /**
285
- * #1617: turn_end reads gitleaks/govulncheck/trivy straight from their
286
- * session-scan caches and formats them into advisory/blocker text — a
287
- * reporting lane parallel to (and, before this fix, entirely bypassing)
288
- * `dispatcher.ts:924`'s `applyDispositions` filter. An agent-marked
289
- * false-positive/won't-fix on one of these findings never suppressed it
290
- * here, so it re-reported on every turn.
291
- *
292
- * Filters `findings` through the SAME anchor derivation the dispatch path
293
- * and `lens_diagnostics mode=full` use (`applyDispositionsMultiFile` in
294
- * `diagnostic-dispositions.ts`), keyed off each lane's own canonical
295
- * `ProjectDiagnostic` adapter (`toDiagnostic`) — the exact tool/rule/message
296
- * identity `lens_diagnostics` already surfaces and `lens_diagnostic_mark`
297
- * already anchors a mark against, not a second, cloned identity that would
298
- * silently diverge from what the agent actually marked.
299
- *
300
- * Returns the surviving findings plus how many were dropped, so a caller can
301
- * still surface a "suppressed by disposition: N" trace (the #1616
302
- * suppressed-bucket rule — a security finding must never vanish with no
303
- * trace, even when the disposition that dropped it is working as intended).
304
- */
305
- function filterFindingsByDisposition(findings, cwd, toDiagnostic) {
306
- if (findings.length === 0)
307
- return { kept: findings, suppressed: 0 };
308
- const candidates = findings.map((finding) => ({
309
- finding,
310
- diagnostic: toDiagnostic(finding),
311
- }));
312
- const survivors = new Set(applyDispositionsMultiFile(candidates.map((c) => c.diagnostic), cwd, (d) => d.filePath));
313
- const kept = candidates
314
- .filter((c) => survivors.has(c.diagnostic))
315
- .map((c) => c.finding);
316
- return { kept, suppressed: findings.length - kept.length };
317
- }
318
285
  /**
319
286
  * Would writing `next` over `prev` throw away a good scan for a failed one?
320
287
  *
@@ -744,9 +711,21 @@ export async function handleTurnEnd(deps) {
744
711
  * branch below.
745
712
  */
746
713
  const pendingDependencyDriftDeliveries = [];
747
- 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;
748
725
  const displayPath = toRunnerDisplayPath(cwd, bPath);
726
+ const tally = { displayPath, sources: record.sources };
749
727
  if (stale) {
728
+ inlinePolicyEntries.push({ ...tally, stale: true });
750
729
  // #1631: demoted — out of the authoritative blocker channel and into the
751
730
  // advisory channel with a stale marker, so the agent is told to re-run
752
731
  // rather than pressured by a verdict that may already be resolved.
@@ -810,10 +789,61 @@ export async function handleTurnEnd(deps) {
810
789
  (retirementNote ? `\n${retirementNote}` : ""));
811
790
  }
812
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
+ : "";
813
813
  // @delivery-surface: runtime-turn:unresolved-inline-blocker
814
- blockerParts.push(`Unresolved from this turn — ${displayPath}:\n${summary}`);
814
+ blockerParts.push(`Unresolved from this turn — ${displayPath}${suppressedNote}:\n${policy.body}`);
815
815
  }
816
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
+ }
817
847
  // Drain the deferred cascade computes kicked off this turn (#450). They ran
818
848
  // concurrently off the write hot path; wait a bounded time for them here so
819
849
  // their runs are available to the merge below. A compute still in flight at
@@ -894,6 +924,24 @@ export async function handleTurnEnd(deps) {
894
924
  return true;
895
925
  });
896
926
  const cascadeResults = cascadeRuns.flatMap((r) => r.result ? [r.result] : []);
927
+ // Fix B (#3167): which results belong to a CARRIED run — the label is
928
+ // per-result at render time, so the run→result pairing must survive the
929
+ // flatMap above (which discards the wrapper).
930
+ // Fix B (#3168 F6): the success-path record — latency.log could not show
931
+ // the feature ever rendered without it (the only phases were the drop/
932
+ // settle records). One bounded record per turn, emitted after both render
933
+ // sections below.
934
+ let carriedRunsRendered = 0;
935
+ let labeledAdvisories = 0;
936
+ const carriedMetaByResult = new Map();
937
+ for (const r of cascadeRuns) {
938
+ if (r.result && (r.carriedTurns ?? 0) > 0 && r.carriedTurns !== undefined) {
939
+ carriedMetaByResult.set(r.result, {
940
+ carriedTurns: r.carriedTurns,
941
+ observedAt: r.observedAt,
942
+ });
943
+ }
944
+ }
897
945
  // #1550 class sweep: every cascade record below summarises `cascadeResults`
898
946
  // — runs, which carry their own paths and can be carried across turns
899
947
  // (#1443) — so labelling them with the turn's first EDITED file is the same
@@ -931,7 +979,16 @@ export async function handleTurnEnd(deps) {
931
979
  const pk = normalizeMapKey(result.filePath);
932
980
  const ownsAny = result.neighbors.some((n) => neighborOwner.get(normalizeMapKey(n.filePath)) === pk);
933
981
  if (ownsAny && result.formatted) {
934
- parts.push(result.formatted);
982
+ // Fix B (#3167/#3168): a carried run's re-rendered blocker is labeled
983
+ // so the agent can tell it from a fresh observation — with the
984
+ // run's own observation age (#3168 F3).
985
+ const carryMeta = carriedMetaByResult.get(result);
986
+ const carrySuffix = cascadeCarrySuffix(carryMeta?.carriedTurns, carryMeta?.observedAt);
987
+ parts.push(carrySuffix
988
+ ? `${result.formatted}\n${carrySuffix}`
989
+ : result.formatted);
990
+ if (carrySuffix)
991
+ carriedRunsRendered += 1;
935
992
  injectedNeighborCount += result.neighbors.length;
936
993
  injectedDiagnosticCount += result.neighbors.reduce((s, n) => s + n.diagnostics.length, 0);
937
994
  }
@@ -1081,27 +1138,59 @@ export async function handleTurnEnd(deps) {
1081
1138
  // "ℹ️ Advisory — no action required this turn:" label, so an imperative
1082
1139
  // ("review dependents manually") would contradict it. The #533 substance
1083
1140
  // stays: a clean cascade result does NOT cover these files' dependents.
1084
- const graphAdvisory = buildAdvisory(graphRuns, {
1141
+ // Fix B (#3167/#3168 F4): a coverage advisory computed from a CARRIED
1142
+ // indeterminate run describes the previous turn's evidence — label it so
1143
+ // the absence-of-coverage statement is not read as current. A MIXED
1144
+ // bucket (some carried, some fresh) is left UNLABELED: a Math.max
1145
+ // suffix on the finished multi-line advisory would attach the carry to
1146
+ // a file that was not carried.
1147
+ const withCarryLabel = (advisory, runs) => {
1148
+ if (advisory === undefined)
1149
+ return undefined;
1150
+ const carried = runs.filter((r) => (r.carriedTurns ?? 0) > 0);
1151
+ if (carried.length === 0 || carried.length !== runs.length) {
1152
+ return advisory;
1153
+ }
1154
+ // #3168 F13: the bucket's age is the OLDEST carried observation, and
1155
+ // only when EVERY carried run carries one. A `Math.min` sentinel
1156
+ // (`?? Number.MAX_SAFE_INTEGER`) silently ignored the unstamped runs
1157
+ // and stated a confident age for a bucket that contains an unaged
1158
+ // one; one missing stamp collapses the age half to the helper's
1159
+ // neutral "scan age unknown" wording instead.
1160
+ const stamps = carried.map((r) => r.observedAt);
1161
+ const observedAt = stamps.every((s) => s !== undefined)
1162
+ ? Math.min(...stamps)
1163
+ : undefined;
1164
+ const suffix = cascadeCarrySuffix(carried[0]?.carriedTurns, observedAt);
1165
+ if (!suffix)
1166
+ return advisory;
1167
+ labeledAdvisories += 1;
1168
+ // #3168 F11: newline, as the blocker path does above. A space join
1169
+ // welded the suffix onto the LAST bullet of a multi-bullet advisory,
1170
+ // so the carry label read as a property of that one file.
1171
+ return `${advisory}\n${suffix}`;
1172
+ };
1173
+ const graphAdvisory = withCarryLabel(buildAdvisory(graphRuns, {
1085
1174
  lead: (fileCount, reasons) => `Cascade could not compute downstream impact for ${fileCount} edited file(s) this turn — ` +
1086
1175
  `the review graph was unavailable (${reasons}), so their dependents were not ` +
1087
1176
  `cascade-checked and a clean cascade result does not cover them.`,
1088
1177
  fallbackDetail: (r) => r.indeterminate?.reason === "missing_node"
1089
1178
  ? "changed file not in the review graph"
1090
1179
  : "review graph unavailable",
1091
- });
1180
+ }), graphRuns);
1092
1181
  // @delivery-surface: runtime-turn:cascade-coverage-advisory
1093
1182
  if (graphAdvisory)
1094
1183
  advisoryParts.push(graphAdvisory);
1095
- const bindingAdvisory = buildAdvisory(bindingRuns, {
1184
+ const bindingAdvisory = withCarryLabel(buildAdvisory(bindingRuns, {
1096
1185
  lead: (fileCount, reasons) => `Cascade identified dependents for ${fileCount} edited file(s) this turn, but their ` +
1097
1186
  `diagnostics could not be freshly confirmed (${reasons}) and were withheld — a clean ` +
1098
1187
  `cascade result does not cover them.`,
1099
1188
  fallbackDetail: () => "cascade diagnostics withheld (binding rejected)",
1100
- });
1189
+ }), bindingRuns);
1101
1190
  // @delivery-surface: runtime-turn:cascade-coverage-advisory
1102
1191
  if (bindingAdvisory)
1103
1192
  advisoryParts.push(bindingAdvisory);
1104
- const budgetAdvisory = buildAdvisory(budgetRuns, {
1193
+ const budgetAdvisory = withCarryLabel(buildAdvisory(budgetRuns, {
1105
1194
  lead: (fileCount, reasons) => `Cascade checked the selected neighbors for ${fileCount} edited file(s) this turn, ` +
1106
1195
  `but some eligible dependents were not checked because the cascade budget ` +
1107
1196
  `was exhausted (${reasons}); a clean cascade result does not cover them.`,
@@ -1114,7 +1203,7 @@ export async function handleTurnEnd(deps) {
1114
1203
  ? `${detail}; transitive expansion was capped before all eligible dependents were enumerated`
1115
1204
  : detail;
1116
1205
  },
1117
- });
1206
+ }), budgetRuns);
1118
1207
  // @delivery-surface: runtime-turn:cascade-coverage-advisory
1119
1208
  if (budgetAdvisory)
1120
1209
  advisoryParts.push(budgetAdvisory);
@@ -1152,6 +1241,14 @@ export async function handleTurnEnd(deps) {
1152
1241
  },
1153
1242
  });
1154
1243
  }
1244
+ // Fix B (#3168 F6): the success-path record — see the counters above.
1245
+ if (carriedRunsRendered > 0 || labeledAdvisories > 0) {
1246
+ logCascade({
1247
+ phase: "cascade_carry_rendered",
1248
+ filePath: cascadeLogFilePath,
1249
+ metadata: { carriedRunsRendered, labeledAdvisories },
1250
+ });
1251
+ }
1155
1252
  const cascadeSkipped = {
1156
1253
  blockers: 0,
1157
1254
  non_code: 0,
@@ -1220,6 +1317,9 @@ export async function handleTurnEnd(deps) {
1220
1317
  totalIssues: knipResult.issues.length,
1221
1318
  newIssues: 0,
1222
1319
  blockerIssues: 0,
1320
+ // #3248: bounded per-turn, on the row this lane already writes —
1321
+ // never one record per finding.
1322
+ dispositionSuppressed: 0,
1223
1323
  ...(!knipResult.success && { reason: knipResult.summary }),
1224
1324
  ...(knipResult.failureKind && { failureKind: knipResult.failureKind }),
1225
1325
  ...(knipWouldPoison && { cacheKept: true }),
@@ -1243,7 +1343,30 @@ export async function handleTurnEnd(deps) {
1243
1343
  projectDiagnosticsDelta.push(...knipIssuesToProjectDiagnostics(cwd, newIssues));
1244
1344
  projectDiagnosticsSources.add("knip");
1245
1345
  }
1246
- 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");
1247
1370
  knipMeta.blockerIssues = blockerIssues.length;
1248
1371
  if (blockerIssues.length > 0) {
1249
1372
  let report = "🔴 New unresolved imports/deps in modified code (Knip):\n";
@@ -1270,7 +1393,7 @@ export async function handleTurnEnd(deps) {
1270
1393
  // drown the blockers and burn context every turn); it's available
1271
1394
  // on demand via lens_diagnostics. The delta also feeds the session-slop
1272
1395
  // record (`projectDiagnosticsDelta`) above.
1273
- const unusedExportDelta = newIssues.filter((i) => i.type === "export" || i.type === "enumMember");
1396
+ const unusedExportDelta = knipDeliverable.kept.filter((i) => i.type === "export" || i.type === "enumMember");
1274
1397
  if (unusedExportDelta.length > 0) {
1275
1398
  let report = "⚠️ Newly unused exports in files you edited — check if callers need updating (Knip):\n";
1276
1399
  for (const issue of unusedExportDelta.slice(0, 5)) {
@@ -1318,6 +1441,8 @@ export async function handleTurnEnd(deps) {
1318
1441
  let modifiedSet = null;
1319
1442
  const modifiedFiles = () => (modifiedSet ??= new Set(files.map((f) => resolveRunnerPath(cwd, f))));
1320
1443
  let newIssueTotal = 0;
1444
+ /** #3248: bounded per-turn on this lane's own row, never per finding. */
1445
+ let deadCodeDispositionSuppressed = 0;
1321
1446
  const reasons = [];
1322
1447
  // A malformed client or deps object must never abort turn_end. Before the
1323
1448
  // per-turn delta this block only read a cache; now it iterates and awaits,
@@ -1403,8 +1528,21 @@ export async function handleTurnEnd(deps) {
1403
1528
  newIssueTotal += newIssues.length;
1404
1529
  projectDiagnosticsDelta.push(...newIssues.map((issue) => deadCodeIssueToProjectDiagnostic(cwd, issue, result.language)));
1405
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
+ }
1406
1544
  // @delivery-surface: runtime-turn:dead-code-advisory
1407
- advisoryParts.push(formatDeadCodeDelta(newIssues, result.language));
1545
+ advisoryParts.push(formatDeadCodeDelta(deadCodeDeliverable.kept, result.language));
1408
1546
  }
1409
1547
  catch (err) {
1410
1548
  dbg(`turn_end: dead-code(${client.id}) failed: ${err}`);
@@ -1417,6 +1555,7 @@ export async function handleTurnEnd(deps) {
1417
1555
  reasons.push("block_threw");
1418
1556
  }
1419
1557
  deadCodeMeta.newIssues = newIssueTotal;
1558
+ deadCodeMeta.dispositionSuppressed = deadCodeDispositionSuppressed;
1420
1559
  if (reasons.length > 0)
1421
1560
  deadCodeMeta.reason = reasons.join(",");
1422
1561
  }
@@ -1444,212 +1583,167 @@ export async function handleTurnEnd(deps) {
1444
1583
  dispositionSuppressedByLane[lane] =
1445
1584
  (dispositionSuppressedByLane[lane] ?? 0) + count;
1446
1585
  }
1447
- // govulncheck — surface session_start-cached Go CVE findings as advisory.
1448
- // No per-turn re-run in this slice; the cache refreshes at next session_start.
1449
- const govCacheEntry = cacheManager.readCache("govulncheck", cwd);
1450
- // #1622: govulncheck renders a call site as `file:line`, and the cache is a
1451
- // session_start snapshot — the same stale-line shape as gitleaks, one tier
1452
- // lower. A CVE is pinned by go.mod, NOT by the call site, so neither an edit
1453
- // nor a deletion may drop it: `onMissing: "demote"` routes a vanished traced
1454
- // file into the same arm as an edited one. This gate only ever decides
1455
- // whether the cited LINE is still worth printing. (Review round H1: the first
1456
- // cut let a deleted trace file drop the CVE, contradicting this comment, and
1457
- // `citedPath` reads only the FIRST filename frame — so one deleted file in a
1458
- // long trace silently killed a CVE that go.mod still pins.)
1459
- const govGate = gateFindingsByPathFreshness({
1460
- store: "govulncheck",
1461
- 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 = {
1650
+ cwd,
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({
1462
1705
  cwd,
1463
- scannedAt: govCacheEntry?.data?.scannedAt,
1464
- citedPath: (finding) => finding.trace.find((t) => t.filename)?.filename,
1465
- onMissing: "demote",
1706
+ sources: {
1707
+ ...govSources,
1708
+ ...secretsSources,
1709
+ },
1466
1710
  });
1467
- const govStale = new Set(govGate.stale);
1468
- // #1625 review round: the #1622 freshness gate runs FIRST — the disposition
1469
- // filter's anchor is derived from each finding's post-demotion identity
1470
- // (this array is already the gate's live+stale partition, never the raw
1471
- // pre-gate cache). #1627's own post-gate guard (`if (govFindings.length)`)
1472
- // and this round's disposition guard are the SAME guard — compose them,
1473
- // never fall back to a raw-cache-length check (that would print the header
1474
- // with zero rows beneath it whenever either filter empties the list).
1475
- const govFiltered = filterFindingsByDisposition([...govGate.live, ...govGate.stale], cwd, (f) => govulncheckFindingToProjectDiagnostic(cwd, f));
1476
- recordDispositionSuppressed("govulncheck", govFiltered.suppressed);
1477
- const govFindings = govFiltered.kept;
1478
- if (govFindings.length) {
1479
- const findings = govFindings.slice(0, 5);
1480
- let report = "🛡️ Go CVEs reachable from this code (govulncheck) — upgrade where possible:\n";
1481
- for (const f of findings) {
1482
- const callSite = f.trace.find((t) => t.filename);
1483
- const stale = govStale.has(f);
1484
- const where = callSite?.filename
1485
- ? `${toRunnerDisplayPath(cwd, callSite.filename)}${!stale && callSite.line ? `:${callSite.line}` : ""}${stale ? ` ${STALE_LINE_MARKER}` : ""}`
1486
- : (f.module ?? f.packageName ?? "(module)");
1487
- const fix = f.fixedVersion
1488
- ? ` — upgrade to ${f.fixedVersion} or later`
1489
- : " — no fix yet, track upstream";
1490
- report += ` ${f.osv} (${where})${fix}\n`;
1491
- }
1492
- if (govFindings.length > findings.length) {
1493
- report += ` … and ${govFindings.length - findings.length} more\n`;
1494
- }
1495
- // @delivery-surface: runtime-turn:govulncheck-advisory
1496
- 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);
1497
1717
  }
1498
- const trivyCacheEntry = cacheManager.readCache("trivy", cwd);
1718
+ // @delivery-surface: runtime-turn:govulncheck-advisory
1719
+ advisoryParts.push(...(govDelivery.advisoryParts ?? []));
1499
1720
  // Secrets — UNIFIED surfacing (#131 Mode 3). gitleaks, trivy secret, and the
1500
1721
  // ast-grep hardcoded-secret rules can each flag the SAME line with different
1501
1722
  // rule ids, which the rule-keyed diagnostic dedup can't collapse. Collapse by
1502
1723
  // location so a committed/hardcoded secret is reported ONCE (with combined
1503
1724
  // provenance) — a blocker, since credentials need rotation before merge.
1504
- const gitleaksData = cacheManager.readCache("gitleaks", cwd)?.data;
1505
- const trivySecretsData = trivyCacheEntry?.data;
1506
- // Gitleaks deliberately scans gitignored local files and nested repositories
1507
- // so an explicit security audit can still inspect them. The adapter is the
1508
- // source of truth for whether a finding belongs in a blocking delivery lane;
1509
- // filter here before freshness handling so demoted findings cannot leak into
1510
- // either the blocker or stale-secret turn context.
1511
- const boundedClassification = await bounded(classifyAndFilterFindings(gitleaksData?.findings ?? [], cwd), {
1512
- ms: HOOK_WALL_BUDGET_MS.turn_end,
1513
- signal: deps.signal,
1514
- hook: "turn_end",
1515
- label: "classifyAndFilterFindings",
1516
- });
1517
- const classifiedGitleaksFindings = boundedClassification ?? gitleaksData?.findings ?? [];
1518
- if (boundedClassification === undefined) {
1519
- recordDegradationOnce({
1520
- kind: "gitleaks_classification_timeout",
1521
- subject: cwd,
1522
- reason: "gitleaks classification exceeded the turn_end budget; retained raw findings to fail open",
1523
- });
1524
- }
1525
- const blockingGitleaksFindings = classifiedGitleaksFindings.filter((finding) => gitleaksFindingToProjectDiagnostic(cwd, finding).semantic === "blocking");
1526
- // #1461 slice 1 (#1460): the gitleaks cache is TTL-only, so a finding for a
1527
- // file deleted after the scan is still served as a 🔴 blocker for the rest
1528
- // of the 30-minute window — the live case, and 119 of 126 findings in
1529
- // pi-lens's own cache. This read is the single agent-facing consumer of that
1530
- // store (session_start's read only decides whether to re-scan; the
1531
- // project-diagnostics path re-scans fresh and reconciles at load), so the
1532
- // drop belongs here, before the findings enter the shared secret pipeline.
1533
- // #1622 extends that gate from existence to freshness, and adds trivy
1534
- // secrets — the sibling store with the identical shape. A cited file edited
1535
- // after the scan keeps its finding but loses its line number: the credential
1536
- // may still be there, just not where the snapshot says. Dropping instead
1537
- // would let any edit — malicious or accidental — mute a real secret.
1538
- const gitleaksGate = gateFindingsByPathFreshness({
1539
- store: "gitleaks",
1540
- findings: blockingGitleaksFindings,
1541
- cwd,
1542
- scannedAt: gitleaksData?.scannedAt,
1543
- citedPath: (finding) => finding.file,
1544
- });
1545
- const trivySecretsGate = gateFindingsByPathFreshness({
1546
- store: "trivy-secrets",
1547
- findings: trivySecretsData?.secrets ?? [],
1548
- cwd,
1549
- scannedAt: trivySecretsData?.scannedAt,
1550
- citedPath: (finding) => finding.file,
1551
- });
1552
- // #1617: THE bug this issue exists for — gitleaks findings never passed
1553
- // through `applyDispositions`, so an agent-marked false-positive/won't-fix
1554
- // re-reported as a 🔴 STOP blocker on every turn. Filter through the SAME
1555
- // `gitleaksFindingToProjectDiagnostic` identity `lens_diagnostics
1556
- // mode=full` surfaces (tool="gitleaks", rule="gitleaks:<ruleId>", the
1557
- // exact "Potential secret: …" message) so a mark made against what the
1558
- // agent was shown is honored here too.
1559
- //
1560
- // #1625 review round: filtered AFTER the #1622 freshness gate above — the
1561
- // anchor is derived from each finding's post-demotion identity, never the
1562
- // raw pre-gate cache. Applied to BOTH `gitleaksGate.live` AND
1563
- // `gitleaksGate.stale`: `staleSecretEntries` below derives from the stale
1564
- // arm, and an fp-marked finding that later goes stale must not reappear
1565
- // there — a suppression escape (and a double count against
1566
- // `dispositionSuppressedTotal`) that a live-only filter would have missed.
1567
- //
1568
- // #1628: trivy-secret findings get the SAME treatment, now that
1569
- // `trivySecretFindingToProjectDiagnostic` (project-diagnostics/runner-
1570
- // adapters/trivy.ts) gives them a `lens_diagnostics`-surfaced identity
1571
- // (tool="trivy", rule="trivy-secret:<ruleId>") to anchor a mark against —
1572
- // same pattern as gitleaks above, applied to both the live and stale arms
1573
- // for the same reason.
1574
1725
  //
1575
- // ast-grep secret findings need no filtering here — they already went
1576
- // through dispatch's applyDispositions before reaching
1577
- // `peekActionableWarnings()`.
1578
- const gitleaksLiveFiltered = filterFindingsByDisposition(gitleaksGate.live, cwd, (f) => gitleaksFindingToProjectDiagnostic(cwd, f));
1579
- const gitleaksStaleFiltered = filterFindingsByDisposition(gitleaksGate.stale, cwd, (f) => gitleaksFindingToProjectDiagnostic(cwd, f));
1580
- recordDispositionSuppressed("gitleaks", gitleaksLiveFiltered.suppressed + gitleaksStaleFiltered.suppressed);
1581
- const trivySecretsLiveFiltered = filterFindingsByDisposition(trivySecretsGate.live, cwd, (f) => trivySecretFindingToProjectDiagnostic(cwd, f));
1582
- const trivySecretsStaleFiltered = filterFindingsByDisposition(trivySecretsGate.stale, cwd, (f) => trivySecretFindingToProjectDiagnostic(cwd, f));
1583
- recordDispositionSuppressed("trivy-secrets", trivySecretsLiveFiltered.suppressed + trivySecretsStaleFiltered.suppressed);
1584
- const astSecretWarnings = runtime
1585
- .peekActionableWarnings()
1586
- .filter(isSecretWarning);
1587
- const sessionSecrets = dedupeSecretFindings([
1588
- ...fromGitleaks(gitleaksLiveFiltered.kept),
1589
- ...fromTrivySecrets(trivySecretsLiveFiltered.kept),
1590
- ]);
1591
- // Demoted secrets are addressed by FILE, never by line — the line is the one
1592
- // field the edit invalidated. Rule id and source survive it and must be
1593
- // carried through (review round M1): an agent triages an `aws-access-token`
1594
- // differently from a low-confidence `generic-api-key`, and cannot do that
1595
- // from a bare path. Deduped on file+rule+source so a file with twenty stale
1596
- // hits of one rule is named once.
1597
- const staleSecretEntries = [
1598
- ...gitleaksStaleFiltered.kept.map((f) => ({
1599
- file: toRunnerDisplayPath(cwd, f.file),
1600
- rule: f.ruleId,
1601
- source: "gitleaks",
1602
- })),
1603
- ...trivySecretsStaleFiltered.kept.map((f) => ({
1604
- file: toRunnerDisplayPath(cwd, f.file),
1605
- rule: f.ruleId,
1606
- source: "trivy",
1607
- })),
1608
- ];
1609
- const staleSecrets = [
1610
- ...new Map(staleSecretEntries.map((e) => [`${e.file}|${e.rule}|${e.source}`, e])).values(),
1611
- ];
1612
- // Locations already surfaced as session-scan secret blockers — used to enrich
1613
- // provenance where ast-grep agrees and to suppress the duplicate ast-grep copy
1614
- // from the actionable-warnings advisory below.
1615
- const secretBlockedLocations = new Set(sessionSecrets.map((f) => secretLocationKey(f.file, f.line)));
1616
- if (sessionSecrets.length) {
1617
- // Fold in ast-grep provenance ONLY where it coincides with a session
1618
- // secret — don't promote ast-grep-only findings out of their advisory tier.
1619
- const enriched = dedupeSecretFindings([
1620
- ...sessionSecrets,
1621
- ...fromAstGrepWarnings(astSecretWarnings).filter((a) => secretBlockedLocations.has(secretLocationKey(a.file, a.line))),
1622
- ]);
1623
- const shown = enriched.slice(0, 5);
1624
- let report = "🔴 STOP — hardcoded secrets detected. Rotate the credentials and remove them from source:\n";
1625
- for (const f of shown) {
1626
- const where = `${toRunnerDisplayPath(cwd, f.file)}:${f.line}`;
1627
- report += ` ${where} — ${f.rule} [${f.sources.join(" + ")}]${f.description ? `: ${f.description}` : ""}\n`;
1628
- }
1629
- if (enriched.length > shown.length) {
1630
- report += ` … and ${enriched.length - shown.length} more\n`;
1631
- }
1632
- // @delivery-surface: runtime-turn:secrets-gitleaks,runtime-turn:secrets-trivy
1633
- blockerParts.push(report);
1634
- }
1635
- if (staleSecrets.length) {
1636
- // Its OWN tier, never `advisoryParts` (review round M2). The advisory tier
1637
- // is labelled "no action required this turn", which would sit directly
1638
- // above copy telling the agent to re-scan — a section that contradicts its
1639
- // own heading. This preamble is imperative because the action is real: the
1640
- // finding is unverified, not dismissed.
1641
- const shown = staleSecrets.slice(0, 5);
1642
- let report = `🔑 ACTION NEEDED — secrets were flagged in files that changed after the scan. ${STALE_LINE_MARKER}\n` +
1643
- "The cached line numbers are no longer trustworthy, so they are withheld. Re-run a secrets scan to confirm or clear these:\n";
1644
- for (const entry of shown) {
1645
- report += ` ${entry.file} — ${entry.rule} [${entry.source}]\n`;
1646
- }
1647
- if (staleSecrets.length > shown.length) {
1648
- report += ` … and ${staleSecrets.length - shown.length} more\n`;
1649
- }
1650
- // @delivery-surface: runtime-turn:stale-secrets-tier
1651
- 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);
1652
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 ?? []));
1653
1747
  // trivy — surface session_start-cached dependency CVEs (#131, Phase 1).
1654
1748
  // CRITICAL is a blocker (a known-exploitable CVE in a shipped dep is real
1655
1749
  // production risk); HIGH/MEDIUM/LOW are advisory. The agent gets the upgrade
@@ -1759,11 +1853,6 @@ export async function handleTurnEnd(deps) {
1759
1853
  const depResult = depResults.get(absPath);
1760
1854
  if (!depResult)
1761
1855
  continue;
1762
- if (depResult.localSkips && depResult.localSkips > 0) {
1763
- // Not silent: a skipped LOCAL import means madge couldn't resolve
1764
- // it into the graph, so a cycle through it would be missed.
1765
- dbg(`turn_end: madge skipped ${depResult.localSkips} local file(s) resolving ${file} — possible silent cycle-miss`);
1766
- }
1767
1856
  if (depResult.hasCircular && depResult.circular.length > 0) {
1768
1857
  // Whole-project circular deps are surfaced in lens_diagnostics via the
1769
1858
  // session-start `madge` cache + extractor; this per-file turn-end pass
@@ -2591,10 +2680,13 @@ export async function handleTurnEnd(deps) {
2591
2680
  "the affected files may have unreported callers.");
2592
2681
  }
2593
2682
  else {
2683
+ const callGraphStart = Date.now();
2594
2684
  try {
2595
2685
  const { impact, formatImpact, parseSymbolKey } = await import("./call-graph.js");
2596
2686
  const { callGraphImpactToProjectDiagnostics } = await import("./project-diagnostics/runner-adapters/call-graph-impact.js");
2597
2687
  const impactLines = [];
2688
+ /** #3248: bounded per-turn, never per finding. */
2689
+ let callGraphDispositionSuppressed = 0;
2598
2690
  const impactFindings = [];
2599
2691
  for (const filePath of files.slice(0, 5)) {
2600
2692
  // Turn-state files may be cwd-relative while graph keys are absolute,
@@ -2622,7 +2714,33 @@ export async function handleTurnEnd(deps) {
2622
2714
  });
2623
2715
  if (results.length > 0) {
2624
2716
  impactFindings.push({ calleeKey, results });
2625
- 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) : "";
2626
2744
  if (summary)
2627
2745
  impactLines.push(` ${parseSymbolKey(calleeKey).symbolName ?? calleeKey}: ${summary}`);
2628
2746
  }
@@ -2639,6 +2757,23 @@ export async function handleTurnEnd(deps) {
2639
2757
  projectDiagnosticsSources.add("call-graph");
2640
2758
  }
2641
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
+ }
2642
2777
  // Non-fatal — call graph is best-effort
2643
2778
  }
2644
2779
  catch {
@@ -2672,7 +2807,37 @@ export async function handleTurnEnd(deps) {
2672
2807
  fileSeqByPath.set(filePath, getFileSeq.call(runtime, filePath));
2673
2808
  }
2674
2809
  }
2810
+ let reverify;
2675
2811
  if (getFlag("lens-actionable-warnings")) {
2812
+ // #3170: re-verify carried deferred findings before the advisory
2813
+ // assembles — a finding whose file is unchanged re-serves from the
2814
+ // persisted report without ever being re-observed; root-cause-fixed-
2815
+ // elsewhere findings converge here instead of repeating. Bounded in the
2816
+ // module (≤4 files, wall budget, abort signal). #3176 F1: the
2817
+ // replacement entries fold into THIS turn's single in-band publish
2818
+ // below — a separate replacement publish spends the carry marker and
2819
+ // this publish then drops the entries at the scope guard (the blocker
2820
+ // the review caught).
2821
+ const persistedReport = cacheManager.readCache("actionable-warnings", cwd, Number.MAX_SAFE_INTEGER)?.data;
2822
+ if (persistedReport?.files?.some((entry) => entry.origin === "deferred")) {
2823
+ const reverifyLspService = getLSPService();
2824
+ if (reverifyLspService) {
2825
+ // #2523: the hook-path await is bound-wrapped with the hook's own
2826
+ // budget and signal; the pass's internal deadline (3s) is the
2827
+ // tighter of the two.
2828
+ reverify = await bounded(runPersistentReverify({
2829
+ report: persistedReport,
2830
+ cwd,
2831
+ lspService: reverifyLspService,
2832
+ signal: getAmbientAbortSignal(),
2833
+ }), {
2834
+ ms: HOOK_WALL_BUDGET_MS.turn_end,
2835
+ signal: getAmbientAbortSignal(),
2836
+ hook: "turn_end",
2837
+ label: "persistent_reverify",
2838
+ });
2839
+ }
2840
+ }
2676
2841
  try {
2677
2842
  const report = await buildActionableWarningsReport({
2678
2843
  cwd,
@@ -2680,14 +2845,7 @@ export async function handleTurnEnd(deps) {
2680
2845
  turnIndex: runtime.turnIndex,
2681
2846
  files,
2682
2847
  modifiedRangesByFile,
2683
- // Suppress the ast-grep secret advisory at any location already
2684
- // surfaced in the unified secrets blocker above (#131 Mode 3) — the
2685
- // secret is reported once, not twice.
2686
- dispatchWarnings: runtime
2687
- .peekActionableWarnings()
2688
- .filter((w) => !(isSecretWarning(w) &&
2689
- typeof w.line === "number" &&
2690
- secretBlockedLocations.has(secretLocationKey(w.filePath, w.line)))),
2848
+ dispatchWarnings: runtime.peekActionableWarnings(),
2691
2849
  includeLspCodeActions: !!getFlag("lens-actionable-warning-actions"),
2692
2850
  projectSeqStart: runtime.turnStartProjectSeq,
2693
2851
  projectSeqEnd: runtime.projectSeq,
@@ -2751,6 +2909,24 @@ export async function handleTurnEnd(deps) {
2751
2909
  // forward only entries a DEFERRAL produced, and only while their
2752
2910
  // file has not moved, so a `turn_delta` report does not accumulate
2753
2911
  // every prior turn's findings.
2912
+ // #3176 F1: the re-verify replacements fold into THIS report — one
2913
+ // in-band publish total, so the carry marker is spent exactly once and
2914
+ // the merged report cannot drop the entries behind the publish (the
2915
+ // blocker the review caught).
2916
+ if (reverify?.replacementFiles.length) {
2917
+ const replacementByPath = new Map(reverify.replacementFiles.map((file) => [
2918
+ normalizeMapKey(file.filePath),
2919
+ file,
2920
+ ]));
2921
+ const files = (report.files ?? []).map((file) => replacementByPath.get(normalizeMapKey(file.filePath)) ?? file);
2922
+ for (const replacement of reverify.replacementFiles) {
2923
+ if (!files.some((file) => normalizeMapKey(file.filePath) ===
2924
+ normalizeMapKey(replacement.filePath))) {
2925
+ files.push(replacement);
2926
+ }
2927
+ }
2928
+ report.files = files;
2929
+ }
2754
2930
  const publishResult = publishActionableWarningsReport(cacheManager, cwd, report, {
2755
2931
  origin: "in-band",
2756
2932
  getFileSeq: getFileSeq
@@ -2766,7 +2942,36 @@ export async function handleTurnEnd(deps) {
2766
2942
  // #2521: `cwd` is what resolves the store location the advisory
2767
2943
  // names -- it is the SAME cwd `publishActionableWarningsReport`
2768
2944
  // just wrote through, so the two cannot disagree.
2769
- 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
+ });
2770
2975
  // @delivery-surface: runtime-turn:actionable-warnings-advisory
2771
2976
  if (advisory)
2772
2977
  advisoryParts.push(advisory);
@@ -2810,11 +3015,46 @@ export async function handleTurnEnd(deps) {
2810
3015
  }
2811
3016
  const t5 = Date.now();
2812
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
+ }
2813
3053
  const qualityReport = buildCodeQualityWarningsReport({
2814
3054
  cwd,
2815
3055
  sessionId: runtime.telemetrySessionId,
2816
3056
  turnIndex: runtime.turnIndex,
2817
- warnings: runtime.peekCodeQualityWarnings(),
3057
+ warnings: qualityWarnings,
2818
3058
  modifiedRangesByFile,
2819
3059
  projectSeqStart: runtime.turnStartProjectSeq,
2820
3060
  projectSeqEnd: runtime.projectSeq,
@@ -2832,7 +3072,11 @@ export async function handleTurnEnd(deps) {
2832
3072
  filePath: cwd,
2833
3073
  phase: "code_quality_warnings_report",
2834
3074
  durationMs: Date.now() - t5,
2835
- metadata: qualityReport.summary,
3075
+ metadata: {
3076
+ ...qualityReport.summary,
3077
+ // #3248: bounded per-turn on this lane's own row.
3078
+ dispositionSuppressed: qualityDispositionSuppressed,
3079
+ },
2836
3080
  });
2837
3081
  }
2838
3082
  catch (err) {
@@ -2863,6 +3107,8 @@ export async function handleTurnEnd(deps) {
2863
3107
  let runnerFindingsStale = 0;
2864
3108
  let runnerFindingsFailed = 0;
2865
3109
  let runnerFindingsDropped = 0;
3110
+ /** #3248: bounded per-turn on this lane's own row, never per finding. */
3111
+ let runnerFindingsDispositionSuppressed = 0;
2866
3112
  const runnerFindingsDeliveredIds = [];
2867
3113
  for (const pending of pendingRunnerFindings) {
2868
3114
  const result = pending.result;
@@ -2879,12 +3125,15 @@ export async function handleTurnEnd(deps) {
2879
3125
  const findings = result.diagnostics;
2880
3126
  if (findings.length === 0)
2881
3127
  continue;
2882
- const gate = gateFindingsByPathFreshness({
2883
- store: "late-runner-findings",
2884
- findings,
3128
+ const { "late-runner-findings": gate } = gateFindingsByPathFreshness({
2885
3129
  cwd,
2886
- scannedAt: pending.markedAtMs,
2887
- citedPath: (finding) => finding.filePath,
3130
+ sources: {
3131
+ "late-runner-findings": {
3132
+ findings,
3133
+ scannedAt: pending.markedAtMs,
3134
+ citedPath: (finding) => finding.filePath,
3135
+ },
3136
+ },
2888
3137
  });
2889
3138
  runnerFindingsStale += gate.stale.length;
2890
3139
  if (gate.stale.length > 0) {
@@ -2897,15 +3146,46 @@ export async function handleTurnEnd(deps) {
2897
3146
  if (gate.live.length === 0)
2898
3147
  continue;
2899
3148
  const displayPath = toRunnerDisplayPath(cwd, pending.filePath);
2900
- const lines = gate.live.map((finding) => ` ${displayPath}:${finding.line ?? 1}:${finding.column ?? 1} [${finding.rule ?? finding.id}] ${finding.message}`);
2901
- runnerFindingsDelivered += gate.live.length;
2902
- 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) {
2903
3178
  if (runnerFindingsDeliveredIds.length < 50) {
2904
3179
  runnerFindingsDeliveredIds.push(finding.id);
2905
3180
  }
2906
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
+ : "";
2907
3187
  // @delivery-surface: runtime-turn:late-runner-findings
2908
- 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")}`);
2909
3189
  }
2910
3190
  logLatency({
2911
3191
  type: "phase",
@@ -2919,6 +3199,7 @@ export async function handleTurnEnd(deps) {
2919
3199
  stale: runnerFindingsStale,
2920
3200
  failed: runnerFindingsFailed,
2921
3201
  dropped: runnerFindingsDropped,
3202
+ dispositionSuppressed: runnerFindingsDispositionSuppressed,
2922
3203
  deliveredIds: runnerFindingsDeliveredIds,
2923
3204
  },
2924
3205
  });
@@ -3147,12 +3428,15 @@ export async function handleTurnEnd(deps) {
3147
3428
  // one), so a stale-arm replay would double-report old content.
3148
3429
  // Both drops are COUNTED here and in the latency record below —
3149
3430
  // never silent (shape 10).
3150
- const gate = gateFindingsByPathFreshness({
3151
- store: "late-auxiliary-findings",
3152
- findings: retained,
3431
+ const { "late-auxiliary-findings": gate } = gateFindingsByPathFreshness({
3153
3432
  cwd,
3154
- scannedAt: pair.markedAtMs,
3155
- citedPath: () => lateAuxPath,
3433
+ sources: {
3434
+ "late-auxiliary-findings": {
3435
+ findings: retained,
3436
+ scannedAt: pair.markedAtMs,
3437
+ citedPath: () => lateAuxPath,
3438
+ },
3439
+ },
3156
3440
  });
3157
3441
  lateAuxStale += gate.stale.length;
3158
3442
  lateAuxMissing +=
@@ -3197,15 +3481,15 @@ export async function handleTurnEnd(deps) {
3197
3481
  // a late pair. Runs AFTER the freshness gate, like the #1625
3198
3482
  // govulncheck/secrets filters: the anchor is derived from the
3199
3483
  // post-gate identity, never the raw pre-gate set.
3200
- 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, {
3201
3489
  cwd,
3202
3490
  filePath: lateAuxPath,
3203
- content: lateAuxContent ?? "",
3204
- // mtime-cached, so a drain of several files costs one stat.
3205
- policyMap: loadProjectRulePolicyMap(cwd),
3206
- identities: renderedRuleIdentities,
3491
+ content: lateAuxContent,
3207
3492
  });
3208
- const lateAuxSuppressedHere = gate.live.length - lateAuxKept.length;
3209
3493
  lateAuxDispositionSuppressed += lateAuxSuppressedHere;
3210
3494
  if (lateAuxKept.length === 0) {
3211
3495
  // Every late finding was suppressed. This is a PUSH surface: