@herbertgao/pi-extensions 2026.9.15 → 2026.9.16

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 (195) 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/@narumitw/pi-btw/dist/index.ts +12 -6
  5. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  6. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  7. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
  8. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
  9. package/node_modules/pi-lens/CHANGELOG.md +498 -0
  10. package/node_modules/pi-lens/README.md +45 -2
  11. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
  12. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
  13. package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
  14. package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
  15. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  16. package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
  17. package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
  18. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
  19. package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
  20. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
  21. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
  22. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
  23. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
  24. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
  25. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
  26. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
  27. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
  28. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
  29. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
  30. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  31. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
  32. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  33. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
  34. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
  40. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
  41. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
  64. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
  65. package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
  66. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
  67. package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
  68. package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
  69. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
  70. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
  71. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
  72. package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
  73. package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
  74. package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
  75. package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
  76. package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
  77. package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
  78. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
  79. package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
  80. package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
  81. package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
  82. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
  83. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
  84. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
  85. package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
  86. package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
  87. package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
  88. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  89. package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
  90. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
  91. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
  92. package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
  93. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
  94. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
  95. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
  96. package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
  97. package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
  98. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
  99. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
  100. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
  101. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  102. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
  103. package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
  104. package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
  105. package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
  106. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
  107. package/node_modules/pi-lens/dist/index.js +5695 -3733
  108. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
  109. package/node_modules/pi-lens/dist/mcp/server.js +33 -21
  110. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  111. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
  112. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
  113. package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  114. package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  115. package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
  116. package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
  117. package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  118. package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  119. package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  120. package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
  121. package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
  122. package/node_modules/pi-lens/docs/adr/README.md +26 -0
  123. package/node_modules/pi-lens/docs/agent-tools.md +18 -1
  124. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
  125. package/node_modules/pi-lens/docs/configuration.md +55 -1
  126. package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
  127. package/node_modules/pi-lens/docs/environment-variables.md +61 -4
  128. package/node_modules/pi-lens/docs/features.md +2 -2
  129. package/node_modules/pi-lens/docs/globalconfig.md +6 -1
  130. package/node_modules/pi-lens/docs/language-coverage.md +17 -0
  131. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
  132. package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
  133. package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
  134. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
  135. package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
  136. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  137. package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
  138. package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
  139. package/node_modules/pi-lens/docs/settings.md +12 -3
  140. package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
  141. package/node_modules/pi-lens/package.json +5 -3
  142. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  143. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  144. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  145. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  146. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  147. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  148. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  149. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  150. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  151. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  152. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  153. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  154. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  155. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  156. package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
  157. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  158. package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
  159. package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
  160. package/node_modules/pi-mcp-adapter/README.md +12 -5
  161. package/node_modules/pi-mcp-adapter/config.ts +31 -8
  162. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
  163. package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
  164. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  165. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
  166. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
  167. package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
  168. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
  169. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
  170. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
  171. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  172. package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
  173. package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
  174. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
  175. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
  176. package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
  177. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  178. package/node_modules/pi-mcp-adapter/index.ts +37 -20
  179. package/node_modules/pi-mcp-adapter/init.ts +14 -15
  180. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
  181. package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
  182. package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
  183. package/node_modules/pi-mcp-adapter/package.json +3 -2
  184. package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
  185. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
  186. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
  187. package/node_modules/pi-mcp-adapter/types.ts +2 -2
  188. package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
  189. package/node_modules/pi-mcp-adapter/utils.ts +11 -3
  190. package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
  191. package/node_modules/pi-next-cue/LICENSE +21 -0
  192. package/node_modules/pi-next-cue/README.md +81 -0
  193. package/node_modules/pi-next-cue/index.ts +358 -0
  194. package/node_modules/pi-next-cue/package.json +28 -0
  195. package/package.json +8 -5
@@ -25,6 +25,7 @@ import { loadDispatchIntegration } from "./dispatch/lazy.js";
25
25
  import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
26
26
  import { classifyProbeFailure, createAvailabilityChecker, createAvailabilityLatch, describeProbeEvidence, logAvailabilityDecision, startHostStallSampler, resolveAvailableOrInstall, resolveCommandArgsWithInstallFallback, resolveToolCommand, resolveToolCommandWithInstallFallback, } from "./dispatch/runners/utils/runner-helpers.js";
27
27
  import { findDetektConfig } from "./dispatch/runners/detekt.js";
28
+ import { formatDiagnostics } from "./dispatch/utils/format-utils.js";
28
29
  import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
29
30
  import { detectFileChangedAfterCommand, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
30
31
  import { logLatency } from "./latency-logger.js";
@@ -40,12 +41,11 @@ import { probeToolAsync } from "./tool-probe.js";
40
41
  import { bounded } from "./deadline-utils.js";
41
42
  import { HOOK_WALL_BUDGET_MS } from "./hook-budgets.js";
42
43
  import { enabledAuxiliaryLspServerIds } from "./dispatch/auxiliary-lsp.js";
43
- import { recordDegradationOnce } from "./degradation-ledger.js";
44
+ import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
44
45
  import { establishToolAgreement } from "./tool-agreement.js";
45
46
  import { dropFindingsForMissingPaths } from "./advisory-provenance.js";
46
47
  import { getAutofixPolicyForFile, getPreferredAutofixTools, getRubocopCommand, hasBiomeConfig, hasDetektConfig, hasEslintConfig, hasGolangciConfig, hasKtfmtConfig, hasKtlintConfig, hasOxlintConfig, hasRubocopConfig, hasSqlfluffConfig, hasStylelintConfig, markdownlintConfigArgs, } from "./tool-policy.js";
47
- const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
48
- const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
48
+ import { exceedsLspSyncLimits } from "./lsp/content-limits.js";
49
49
  const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
50
50
  const AUTOFIX_CHANGED_FILE_SCAN_LIMIT = 5000;
51
51
  /**
@@ -60,10 +60,6 @@ function lspSyncBudgetMs() {
60
60
  const raw = Number(process.env.PI_LENS_LSP_SYNC_BUDGET_MS);
61
61
  return Number.isFinite(raw) && raw > 0 ? raw : 3000;
62
62
  }
63
- // Scan one directory's entries into `snapshot`, pushing walkable subdirs onto
64
- // `stack`. Extracted from the walk loop to keep each function's cognitive
65
- // complexity low. Excluded/ignored dirs are not descended; ignored/vanished
66
- // files are skipped.
67
63
  // Files stat'd between event-loop yields. The walk stays on the tool_result
68
64
  // hot path; yielding every N keeps its longest synchronous stretch well under
69
65
  // the <50ms hook-burst budget even at the AUTOFIX_CHANGED_FILE_SCAN_LIMIT cap.
@@ -144,23 +140,6 @@ async function diffProjectSnapshot(root, before) {
144
140
  }
145
141
  return [...changed].sort((a, b) => a.localeCompare(b));
146
142
  }
147
- function exceedsLspSyncLimits(_filePath, content) {
148
- const sizeBytes = Buffer.byteLength(content, "utf-8");
149
- if (sizeBytes > LSP_MAX_FILE_BYTES) {
150
- return {
151
- tooLarge: true,
152
- reason: `${Math.round(sizeBytes / 1024)}KB exceeds ${Math.round(LSP_MAX_FILE_BYTES / 1024)}KB`,
153
- };
154
- }
155
- const lineCount = content.split("\n").length;
156
- if (lineCount > LSP_MAX_FILE_LINES) {
157
- return {
158
- tooLarge: true,
159
- reason: `${lineCount} lines exceeds ${LSP_MAX_FILE_LINES}`,
160
- };
161
- }
162
- return { tooLarge: false, reason: "" };
163
- }
164
143
  function createPhaseTracker(toolName, filePath) {
165
144
  const phases = [];
166
145
  return {
@@ -677,7 +656,13 @@ export async function resyncLspFile(filePath, fileContent, needsContentRefresh,
677
656
  return;
678
657
  if (!needsContentRefresh && lspSyncCompleted)
679
658
  return;
680
- const limitCheck = exceedsLspSyncLimits(filePath, fileContent);
659
+ // #3405 r2: the bound is `clients/lsp/content-limits.ts` now, shared with the
660
+ // dispatch runner and the didSave payload. THIS call stays: a file past the
661
+ // bound is not synced to the server AT ALL, which is a stricter policy than
662
+ // the save seam's (drop the redundant text, still send the save) and the one
663
+ // a post-write sync owes — removing it would start writing whole-file
664
+ // didOpen frames for files this pipeline has always refused.
665
+ const limitCheck = exceedsLspSyncLimits(fileContent);
681
666
  if (limitCheck.tooLarge)
682
667
  return;
683
668
  try {
@@ -711,6 +696,11 @@ export async function resyncLspFile(filePath, fileContent, needsContentRefresh,
711
696
  source: "lsp_sync",
712
697
  clientScope: "primary",
713
698
  maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
699
+ // #3405: pi-lens just wrote these bytes to disk, so this is the one
700
+ // touch that is a save. A save-triggered server (Expert recompiles
701
+ // the project on didSave and on nothing else) publishes for the edit
702
+ // only because of this flag.
703
+ saved: true,
714
704
  })
715
705
  .then(() => "done")
716
706
  .catch((err) => {
@@ -961,6 +951,14 @@ function buildEnrichedBlockerOutput(blockers, fileContent = "") {
961
951
  export async function runPipeline(ctx, deps) {
962
952
  const { filePath, cwd, toolName, getFlag, getFlagSource, dbg } = ctx;
963
953
  const { getFormatService } = deps;
954
+ const allowAutonomousWriters = ctx.allowAutonomousWriters !== false;
955
+ if (!allowAutonomousWriters) {
956
+ incrementDegradationCount({
957
+ kind: "opaque-mutation-ownership-boundary",
958
+ subject: "pipeline",
959
+ reason: `observed mutation is not evidence of agent authorship (${filePath})`,
960
+ });
961
+ }
964
962
  admitWidgetDiagnosticsWrite(filePath, ctx.telemetry?.writeIndex);
965
963
  const phase = createPhaseTracker(toolName, filePath);
966
964
  const pipelineStart = Date.now();
@@ -984,8 +982,14 @@ export async function runPipeline(ctx, deps) {
984
982
  const piChangedFiles = new Set();
985
983
  const autoformatDisabled = !!getFlag("no-autoformat", filePath);
986
984
  const immediateFormat = !!getFlag("immediate-format");
987
- const formatDeferred = !autoformatDisabled && !immediateFormat && !!fileContent;
988
- if (!autoformatDisabled && immediateFormat && fileContent) {
985
+ const formatDeferred = allowAutonomousWriters &&
986
+ !autoformatDisabled &&
987
+ !immediateFormat &&
988
+ !!fileContent;
989
+ if (allowAutonomousWriters &&
990
+ !autoformatDisabled &&
991
+ immediateFormat &&
992
+ fileContent) {
989
993
  const formatResult = await runFormatPhase(filePath, getFormatService, dbg, ctx.signal);
990
994
  formatChanged = formatResult.formatChanged;
991
995
  formattersUsed = formatResult.formattersUsed;
@@ -1010,6 +1014,9 @@ export async function runPipeline(ctx, deps) {
1010
1014
  else if (formatDeferred) {
1011
1015
  dbg(`autoformat: deferred until agent_end for ${filePath}`);
1012
1016
  }
1017
+ else if (!allowAutonomousWriters) {
1018
+ dbg(`autoformat: skipped for ${filePath} (mutation ownership is unproven)`);
1019
+ }
1013
1020
  else if (autoformatDisabled) {
1014
1021
  const source = getFlagSource?.("no-autoformat", filePath);
1015
1022
  dbg(`autoformat: skipped for ${filePath} (--no-autoformat${source ? `, source=${source}` : ""})`);
@@ -1027,7 +1034,11 @@ export async function runPipeline(ctx, deps) {
1027
1034
  let autofixChangedFiles = [];
1028
1035
  let fixRefresh = false;
1029
1036
  let autofixSkipReason;
1030
- if (ctx.autofixMode === "deferred") {
1037
+ if (!allowAutonomousWriters) {
1038
+ autofixSkipReason = "mutation_ownership_unproven";
1039
+ dbg(`autofix: skipped for ${filePath} (mutation ownership is unproven)`);
1040
+ }
1041
+ else if (ctx.autofixMode === "deferred") {
1031
1042
  autofixSkipReason = "deferred_to_agent_end";
1032
1043
  dbg(`autofix: deferred until agent_end for ${filePath}`);
1033
1044
  }
@@ -1166,34 +1177,22 @@ export async function runPipeline(ctx, deps) {
1166
1177
  });
1167
1178
  }
1168
1179
  }
1169
- const hasBlockers = dispatchResult.hasBlockers;
1170
- const actionableWarnings = dispatchResult.warnings
1171
- .map((diagnostic) => recordFromDispatchDiagnostic(diagnostic, cwd))
1172
- .filter((warning) => Boolean(warning));
1180
+ // Opaque recovery is a valid analysis input, but its bytes are not an agent
1181
+ // authored surface. Keep diagnostics and advisory output visible while
1182
+ // withholding the edit-directed channels that would tell the agent to mutate
1183
+ // the observed artifact (#3226).
1184
+ const hasBlockers = allowAutonomousWriters && dispatchResult.hasBlockers;
1185
+ const actionableWarnings = allowAutonomousWriters
1186
+ ? dispatchResult.warnings
1187
+ .map((diagnostic) => recordFromDispatchDiagnostic(diagnostic, cwd))
1188
+ .filter((warning) => Boolean(warning))
1189
+ : [];
1173
1190
  const codeQualityWarnings = dispatchResult.warnings
1174
1191
  .map((diagnostic) => recordFromCodeQualityDiagnostic(diagnostic, cwd))
1175
1192
  .filter((warning) => Boolean(warning));
1176
1193
  if (dispatchResult.diagnostics.length > 0) {
1177
- const logger = getDiagnosticLogger();
1178
1194
  const tracker = getDiagnosticTracker();
1179
1195
  tracker.trackShown(dispatchResult.diagnostics);
1180
- const toKey = (d) => [
1181
- d.tool || "",
1182
- d.id || "",
1183
- d.rule || "",
1184
- d.filePath || "",
1185
- d.line || 0,
1186
- d.column || 0,
1187
- ].join("|");
1188
- const inlineKeys = new Set([...dispatchResult.blockers, ...dispatchResult.fixed].map(toKey));
1189
- for (const d of dispatchResult.diagnostics) {
1190
- logger.logCaught(d, {
1191
- model: ctx.telemetry?.model ?? "unknown",
1192
- sessionId: ctx.telemetry?.sessionId ?? "unknown",
1193
- turnIndex: ctx.telemetry?.turnIndex ?? 0,
1194
- writeIndex: ctx.telemetry?.writeIndex ?? 0,
1195
- }, inlineKeys.has(toKey(d)));
1196
- }
1197
1196
  }
1198
1197
  if (fixedCount > 0)
1199
1198
  getDiagnosticTracker().trackAutoFixed(fixedCount);
@@ -1207,15 +1206,48 @@ export async function runPipeline(ctx, deps) {
1207
1206
  // the deleted file's content), so it is dropped here rather than re-asserted.
1208
1207
  // One bounded stat per unique cited path, only when blockers exist — zero
1209
1208
  // cost on the clean/fast paths.
1210
- const deliverableBlockers = dispatchResult.hasBlockers
1209
+ const deliverableBlockers = hasBlockers
1211
1210
  ? dropFindingsForMissingPaths({
1212
1211
  store: "stop-blocker",
1213
1212
  findings: dispatchResult.blockers,
1214
1213
  cwd,
1215
1214
  citedPath: (b) => b.filePath || undefined,
1216
1215
  })
1217
- : dispatchResult.blockers;
1218
- if (dispatchResult.hasBlockers && fileContent) {
1216
+ : [];
1217
+ // #3190: ONE gate, applied once here, for EVERY surface. The durable record
1218
+ // this function hands `runtime.recordInlineBlockers` (and through it the
1219
+ // turn-end `Unresolved from this turn` re-serve) used to be built from the
1220
+ // UNGATED `dispatchResult.blockerOutput`/`blockers`, so a blocker retracted
1221
+ // from the tool result above was re-delivered at turn end — and the ungated
1222
+ // verdict still latched the commit gate through `updateGitGuardStatus`. It is
1223
+ // exactly `hasBlockers && nothing-survived-the-gate` inverted: the gate
1224
+ // returns `[]` whenever `hasBlockers` is false, so this one expression is the
1225
+ // whole condition.
1226
+ const hasDeliverableBlockers = deliverableBlockers.length > 0;
1227
+ if (dispatchResult.diagnostics.length > 0) {
1228
+ const logger = getDiagnosticLogger();
1229
+ const toKey = (d) => [
1230
+ d.tool || "",
1231
+ d.id || "",
1232
+ d.rule || "",
1233
+ d.filePath || "",
1234
+ d.line || 0,
1235
+ d.column || 0,
1236
+ ].join("|");
1237
+ const inlineKeys = new Set([...deliverableBlockers, ...dispatchResult.fixed].map(toKey));
1238
+ for (const d of dispatchResult.diagnostics) {
1239
+ logger.logCaught(d, {
1240
+ model: ctx.telemetry?.model ?? "unknown",
1241
+ sessionId: ctx.telemetry?.sessionId ?? "unknown",
1242
+ turnIndex: ctx.telemetry?.turnIndex ?? 0,
1243
+ writeIndex: ctx.telemetry?.writeIndex ?? 0,
1244
+ }, inlineKeys.has(toKey(d)));
1245
+ }
1246
+ }
1247
+ const deliveredOutput = !allowAutonomousWriters && dispatchResult.hasBlockers
1248
+ ? dispatchResult.output.slice(dispatchResult.blockerOutput.length)
1249
+ : dispatchResult.output;
1250
+ if (hasBlockers && fileContent) {
1219
1251
  // Enrich blocker output with a code snippet so the agent can see the
1220
1252
  // exact line it wrote that caused each violation — no re-read needed.
1221
1253
  if (deliverableBlockers.length > 0) {
@@ -1227,11 +1259,11 @@ export async function runPipeline(ctx, deps) {
1227
1259
  if (rest)
1228
1260
  output += rest;
1229
1261
  }
1230
- else if (dispatchResult.output) {
1262
+ else if (deliveredOutput) {
1231
1263
  // #2028 review P3: this path fires when readback FAILED - raw output
1232
1264
  // still cites possibly-deleted files. Re-render from the gated set
1233
1265
  // (no snippets without fileContent) and keep the post-blocker slice.
1234
- if (dispatchResult.hasBlockers &&
1266
+ if (hasBlockers &&
1235
1267
  deliverableBlockers.length !== dispatchResult.blockers.length) {
1236
1268
  let gatedOut = buildEnrichedBlockerOutput(deliverableBlockers);
1237
1269
  const rest = dispatchResult.output.slice(dispatchResult.blockerOutput.length);
@@ -1245,7 +1277,7 @@ export async function runPipeline(ctx, deps) {
1245
1277
  output += `\n\n${gatedOut}`;
1246
1278
  }
1247
1279
  else {
1248
- output += `\n\n${dispatchResult.output}`;
1280
+ output += `\n\n${deliveredOutput}`;
1249
1281
  }
1250
1282
  }
1251
1283
  if (fixedCount > 0) {
@@ -1340,7 +1372,7 @@ export async function runPipeline(ctx, deps) {
1340
1372
  turnIndex: ctx.telemetry?.turnIndex ?? 0,
1341
1373
  writeIndex: ctx.telemetry?.writeIndex ?? 0,
1342
1374
  diagnostics: dispatchResult.diagnostics,
1343
- blockers: dispatchResult.blockers,
1375
+ blockers: hasBlockers ? dispatchResult.blockers : [],
1344
1376
  warnings: dispatchResult.warnings,
1345
1377
  fixed: dispatchResult.fixed,
1346
1378
  resolvedCount: dispatchResult.resolvedCount,
@@ -1351,28 +1383,44 @@ export async function runPipeline(ctx, deps) {
1351
1383
  });
1352
1384
  return {
1353
1385
  output,
1354
- hasBlockers,
1386
+ // #3190: the verdict every consumer reads — `updateGitGuardStatus`'s commit
1387
+ // latch above all — is what was DELIVERED, not what the dispatch found. The
1388
+ // two disagreed whenever the deleted-path gate retracted every blocker: the
1389
+ // tool result said `✓ clean` while the commit gate stayed latched, its
1390
+ // summary falling back to that very all-clear line. `emitLensAnalysisComplete`
1391
+ // above deliberately keeps the raw dispatch counts — it records what was
1392
+ // observed, not what was served.
1393
+ hasBlockers: hasDeliverableBlockers,
1355
1394
  cascadePromise,
1356
1395
  isError: false,
1357
1396
  fileModified,
1358
1397
  postWriteStateHash,
1359
1398
  changedFiles,
1360
- inlineBlockerSummary: dispatchResult.hasBlockers
1361
- ? dispatchResult.blockerOutput.trim() || undefined
1399
+ // #3190: re-rendered from the GATED set with `formatDiagnostics(...,
1400
+ // "blocking")` — the very expression `dispatcher.ts:1409` builds
1401
+ // `blockerOutput` with, over a subset of the very array it rendered. When
1402
+ // the gate drops nothing the two are byte-identical (same renderer, same
1403
+ // array, same display cap), so an unretracted record is unchanged; when it
1404
+ // drops something the stored text can no longer disagree with the tool
1405
+ // result, and `inline-blocker-dispositions.ts`' re-render at turn end still
1406
+ // matches this string exactly.
1407
+ inlineBlockerSummary: hasDeliverableBlockers
1408
+ ? formatDiagnostics(deliverableBlockers, "blocking").trim() || undefined
1362
1409
  : undefined,
1363
1410
  // #1561 F1: taken from the very diagnostics `blockerOutput` was rendered
1364
1411
  // from, so the provenance can never disagree with the text it guards. An
1365
1412
  // untagged diagnostic contributes the literal "unknown", which no verdict
1366
1413
  // claims coverage for — it pins the entry rather than silently widening
1367
1414
  // what an LSP check is allowed to clear.
1368
- inlineBlockerSources: dispatchResult.hasBlockers
1415
+ inlineBlockerSources: hasDeliverableBlockers
1369
1416
  ? [
1370
- ...new Set(dispatchResult.blockers.map((d) => d.tool?.trim() || "unknown")),
1417
+ ...new Set(deliverableBlockers.map((d) => d.tool?.trim() || "unknown")),
1371
1418
  ]
1372
1419
  : undefined,
1373
- inlineBlockerLines: dispatchResult.hasBlockers
1374
- ? dispatchResult.blockers
1375
- // #1641 review F2: `dispatchResult.blockers` is NOT guaranteed to be
1420
+ inlineBlockerLines: hasDeliverableBlockers
1421
+ ? deliverableBlockers
1422
+ // #1641 review F2: the blocker array (#3190: the deliverable subset
1423
+ // of `dispatchResult.blockers`) is NOT guaranteed to be
1376
1424
  // scoped to THIS file — a chart-wide runner (helm-lint, helm-render)
1377
1425
  // reports blocking diagnostics against other files in the chart
1378
1426
  // (e.g. `values.yaml`) alongside `ctx.filePath`. The precedent every
@@ -1418,8 +1466,15 @@ export async function runPipeline(ctx, deps) {
1418
1466
  source: "autofix",
1419
1467
  }
1420
1468
  : undefined,
1421
- inlineBlockerFileContent: dispatchResult.hasBlockers
1469
+ inlineBlockerFileContent: hasBlockers
1422
1470
  ? inlineBlockerFileContent
1423
1471
  : undefined,
1472
+ // #3246: the very array `blockerOutput` above was rendered from, so the
1473
+ // turn-end policy filter and its re-render can never disagree with the
1474
+ // text they guard — the same provenance argument `inlineBlockerSources`
1475
+ // makes one field up.
1476
+ inlineBlockerDiagnostics: hasDeliverableBlockers
1477
+ ? deliverableBlockers
1478
+ : undefined,
1424
1479
  };
1425
1480
  }
@@ -87,7 +87,7 @@ import { applyDispositionsMultiFile } from "../diagnostic-dispositions.js";
87
87
  import { getKnipIgnorePatterns } from "../file-utils.js";
88
88
  import { isAtOrAboveHomeDir, realpathOrResolve } from "../path-utils.js";
89
89
  import { isSameOrWithin } from "../lsp/server.js";
90
- import { incrementDegradationCount } from "../degradation-ledger.js";
90
+ import { incrementDegradationCount, recordDegradationOnce, } from "../degradation-ledger.js";
91
91
  import { GitleaksClient } from "../gitleaks-client.js";
92
92
  import { GovulncheckClient } from "../govulncheck-client.js";
93
93
  import { isToolInstallAllowedByTrust, projectTrustDenialReason, } from "../project-trust.js";
@@ -281,8 +281,15 @@ export async function fetchFreshProjectDiagnostics(cacheManager, cwd, clients, s
281
281
  function record(id, adapted, elapsedMs, analysedRoot, analysis) {
282
282
  if (analysedRoot) {
283
283
  pushUnique(analyzed, id);
284
- if (analysis?.analyzedFiles !== undefined &&
285
- analysis.analyzedFiles.length > 0) {
284
+ // #2962: the entry is pushed on the DECLARATION, not on its size. A
285
+ // coverage producer that analysed the root and proved zero files is a
286
+ // third state, distinct from both "covered, file absent from the set"
287
+ // and "this runner declares no coverage" — and it is the only one of
288
+ // the three that used to fall through to the whole-root id-only arm in
289
+ // `runnerRetirementDecision`, retiring findings for files nothing
290
+ // scanned. The eight non-producer runners pass no `analyzedFiles` at
291
+ // all and are untouched by this.
292
+ if (analysis?.analyzedFiles !== undefined) {
286
293
  const root = realpathOrResolve(analysisRoot);
287
294
  authoritativeCoverage.push({
288
295
  runnerId: id,
@@ -291,6 +298,17 @@ export async function fetchFreshProjectDiagnostics(cacheManager, cwd, clients, s
291
298
  return realpathOrResolve(file);
292
299
  })),
293
300
  });
301
+ if (analysis.analyzedFiles.length === 0) {
302
+ // Bounded at one row per (producer, root) per session: the kept
303
+ // finding is otherwise indistinguishable from a healthy run, and
304
+ // "the only coverage producer proved nothing" is the fact an
305
+ // operator needs when mode=full keeps showing a stale finding.
306
+ recordDegradationOnce({
307
+ kind: "runner-coverage-empty",
308
+ subject: `${id}:${root}`,
309
+ reason: `${id} analysed this root but declared zero scanned files, so retained ${id} findings are kept rather than retired`,
310
+ });
311
+ }
294
312
  }
295
313
  }
296
314
  timings[id] = (timings[id] ?? 0) + elapsedMs;
@@ -64,11 +64,11 @@ import * as path from "node:path";
64
64
  import { toPositiveFinite } from "./env-utils.js";
65
65
  import { FRESHNESS_CADENCE_MS } from "./freshness-cadence.js";
66
66
  import { assignFlagConfigSection, flagConfigSectionKeys, GLOBAL_NON_FLAG_CONFIG_SECTIONS, GLOBAL_ONLY_NON_FLAG_KEYS, hasFlagConfigPath, LENS_FLAGS, PROJECT_FOREIGN_CONFIG_NAMESPACES, PROJECT_FOREIGN_NAMESPACE_HONORED_KEYS, PROJECT_NON_FLAG_CONFIG_SECTIONS, PROJECT_SCOPED_LENS_FLAGS, readFlagConfigValue, } from "./lens-flag-registry.js";
67
- import { configSearchDirs, getPiLensGlobalConfigPath, PROJECT_CONFIG_BASENAMES, PROJECT_CONFIG_LOCATIONS, } from "./config-locations.js";
67
+ import { configSearchDirs, getPiLensGlobalConfigPath, isResolvedGlobalConfigPath, PROJECT_CONFIG_BASENAMES, PROJECT_CONFIG_LOCATIONS, } from "./config-locations.js";
68
68
  import { deprecationRecords, ignoredRecordCollector, projectLocationFor, readConfigDocument, reportConfigReadFailure, reportPiLensConfigRecords, resolveOnePiLensConfigDocument, } from "./config-resolve.js";
69
69
  import { finalizeRecords, } from "./config-core/records.js";
70
70
  import { homeRelativePath, isAtOrAboveHomeDir, walkUpDirs, } from "./path-utils.js";
71
- import { findPiLensConfigMarkerInDir } from "./workspace-topology.js";
71
+ import { dirMtimesStillFresh, findPiLensConfigMarkerInDir, } from "./workspace-topology.js";
72
72
  import { readToolConfig } from "./tool-config.js";
73
73
  /**
74
74
  * Project config basenames, in DESCENDING precedence (first match wins), for
@@ -289,7 +289,7 @@ function safeDirMtimeMs(dir) {
289
289
  }
290
290
  }
291
291
  function discoveryCacheStillFresh(entry) {
292
- if (!entry.dirMtimes.every((cached) => safeDirMtimeMs(cached.dir) === cached.mtimeMs)) {
292
+ if (!dirMtimesStillFresh(entry.dirMtimes)) {
293
293
  return false;
294
294
  }
295
295
  // #2483 round 2. When no config was found anywhere, `bearingDirMtimes`'s
@@ -344,6 +344,12 @@ function discoverPiLensProjectConfig(startDir) {
344
344
  if (!info) {
345
345
  for (const name of PROJECT_CONFIG_BASENAMES) {
346
346
  const candidate = path.join(dir, name);
347
+ // The global tier reads its file by absolute path; a candidate that
348
+ // IS that file is refused here rather than adopted as a project
349
+ // config, which would double-read and double-validate it
350
+ // (global-config-location PR, refs #2457).
351
+ if (isResolvedGlobalConfigPath(candidate))
352
+ continue;
347
353
  const stat = safeFileStat(candidate);
348
354
  if (stat?.isFile()) {
349
355
  info = {
@@ -358,6 +364,8 @@ function discoverPiLensProjectConfig(startDir) {
358
364
  }
359
365
  for (const location of LEGACY_PROJECT_LOCATIONS) {
360
366
  const file = path.join(dir, location.relativePath);
367
+ if (isResolvedGlobalConfigPath(file))
368
+ continue;
361
369
  const stat = safeFileStat(file);
362
370
  if (!stat?.isFile())
363
371
  continue;