@herbertgao/pi-extensions 2026.9.15 → 2026.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +5 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
  4. package/node_modules/@herbertgao/sol-pi/package.json +1 -1
  5. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +11 -53
  6. package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
  7. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  8. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  9. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
  10. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
  11. package/node_modules/pi-lens/CHANGELOG.md +498 -0
  12. package/node_modules/pi-lens/README.md +45 -2
  13. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
  14. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
  15. package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
  16. package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
  17. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  18. package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
  19. package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
  20. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
  21. package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
  22. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
  23. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
  24. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
  25. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
  26. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
  27. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
  28. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
  29. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
  30. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
  31. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
  32. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  33. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
  34. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  40. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
  41. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
  64. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
  65. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
  66. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
  67. package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
  68. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
  69. package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
  70. package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
  71. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
  72. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
  73. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
  74. package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
  75. package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
  76. package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
  77. package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
  78. package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
  79. package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
  80. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
  81. package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
  82. package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
  83. package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
  84. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
  85. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
  86. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
  87. package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
  88. package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
  89. package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
  90. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  91. package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
  92. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
  93. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
  94. package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
  95. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
  96. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
  97. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
  98. package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
  99. package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
  100. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
  101. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
  102. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
  103. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  104. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
  105. package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
  106. package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
  107. package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
  108. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
  109. package/node_modules/pi-lens/dist/index.js +5695 -3733
  110. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
  111. package/node_modules/pi-lens/dist/mcp/server.js +33 -21
  112. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  113. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
  114. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
  115. package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  116. package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  117. package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
  118. package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
  119. package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  120. package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  121. package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  122. package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
  123. package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
  124. package/node_modules/pi-lens/docs/adr/README.md +26 -0
  125. package/node_modules/pi-lens/docs/agent-tools.md +18 -1
  126. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
  127. package/node_modules/pi-lens/docs/configuration.md +55 -1
  128. package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
  129. package/node_modules/pi-lens/docs/environment-variables.md +61 -4
  130. package/node_modules/pi-lens/docs/features.md +2 -2
  131. package/node_modules/pi-lens/docs/globalconfig.md +6 -1
  132. package/node_modules/pi-lens/docs/language-coverage.md +17 -0
  133. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
  134. package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
  135. package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
  136. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
  137. package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
  138. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  139. package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
  140. package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
  141. package/node_modules/pi-lens/docs/settings.md +12 -3
  142. package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
  143. package/node_modules/pi-lens/package.json +5 -3
  144. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  145. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  146. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  147. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  148. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  149. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  150. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  151. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  152. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  153. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  154. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  155. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  156. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  157. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  158. package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
  159. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  160. package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
  161. package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
  162. package/node_modules/pi-mcp-adapter/README.md +12 -5
  163. package/node_modules/pi-mcp-adapter/config.ts +31 -8
  164. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
  165. package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
  166. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  167. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
  168. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
  169. package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
  170. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
  171. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
  172. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
  173. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  174. package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
  175. package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
  176. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
  177. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
  178. package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
  179. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  180. package/node_modules/pi-mcp-adapter/index.ts +37 -20
  181. package/node_modules/pi-mcp-adapter/init.ts +14 -15
  182. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
  183. package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
  184. package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
  185. package/node_modules/pi-mcp-adapter/package.json +3 -2
  186. package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
  187. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
  188. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
  189. package/node_modules/pi-mcp-adapter/types.ts +2 -2
  190. package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
  191. package/node_modules/pi-mcp-adapter/utils.ts +11 -3
  192. package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
  193. package/node_modules/pi-next-cue/LICENSE +21 -0
  194. package/node_modules/pi-next-cue/README.md +81 -0
  195. package/node_modules/pi-next-cue/index.ts +358 -0
  196. package/node_modules/pi-next-cue/package.json +28 -0
  197. package/package.json +9 -6
@@ -0,0 +1,203 @@
1
+ # ADR 0003: git-guard latch is a separate writer
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-09-23. Amended 2026-09-23 (#3248 remainder 2): the identity
6
+ rule below is decided and implemented.
7
+
8
+ ## Context
9
+
10
+ The turn-end disposition policy can change the survivor set while the
11
+ git-guard latch is read first by the commit gate. Existing edit-time code writes
12
+ the latch and synchronizes the `turn-end-findings` record; turn-end policy was a
13
+ separate actor that did neither.
14
+
15
+ ### Writers by axis, re-verified against the tree at `5f5d847f9`
16
+
17
+ | Actor | Axis / timing | Latch | Durable record | Site |
18
+ |---|---|---|---|---|
19
+ | `handleToolResult` | per edit, after each dispatch | `updateGitGuardStatus(result.hasBlockers, result.output)` | `syncGitGuardRecord(…, filePath)` | `clients/runtime-tool-result.ts:2546-2549` |
20
+ | `retireInlineBlockerAndResyncGuard` | confirmed-clean verdict from `lsp_diagnostics` | `updateGitGuardStatus(false, "")` | `syncGitGuardRecord(…, filePath)` | `clients/git-guard.ts:864` |
21
+ | `markGitGuardCacheUnknown` | cache/session mismatch, untrusted provenance, pipeline error | — | marks the record unreadable | `clients/git-guard.ts:749`, `clients/runtime-tool-result.ts:928` |
22
+ | turn-end composer, blocker sections | per turn, after policy | — | `writeGitGuardRecord` (dedupe path `:4231`, main path `:4287`) | `clients/runtime-turn.ts` |
23
+ | turn-end composer, clean session | per turn, when nothing survives | — | `clearCache("turn-end-findings")`, gated on the LATCH | `clients/runtime-turn.ts:829`, `:4379` |
24
+ | turn-end disposition policy (#3246) | per turn, per finding | **nothing — the gap** | nothing | `clients/runtime-turn.ts:1068` |
25
+ | `evaluateGitGuard` | read (commit gate) | reads the LATCH first and short-circuits | read second | `clients/git-guard.ts:1164` |
26
+
27
+ PR #3254's table named five actors; re-verification adds the composer's own two
28
+ record writers, which is what decides the rule below.
29
+
30
+ ### State table
31
+
32
+ Axes: blockers on the file pre-policy / post-policy · record demoted by the
33
+ freshness sweep · persisted record freshness · which reader the gate uses ·
34
+ session identity.
35
+
36
+ | # | State | Latch | Record | Gate |
37
+ |---|---|---|---|---|
38
+ | 1 | every blocker on the only file suppressed | clear | cleared by the composer's clean-session clear (now reachable) | allow |
39
+ | 2 | 2 of 4 suppressed | set | blocker sections of the survivors | block |
40
+ | 3 | file X fully suppressed, file Y still blocking | set (Y) | Y only; X's summary gone | block |
41
+ | 4 | record demoted (`stale`) by the freshness sweep | set | untouched | block |
42
+ | 5 | every blocker suppressed, test failures in the record | clear | `hasBlockers` stays true via `testFailures` | block |
43
+ | 6 | suppressed, then an unrelated clean edit next turn | stays clear (the verdict rides the record the per-edit writer re-derives from) | not rewritten with the suppressed file | allow |
44
+ | 7 | suppressed, then a NEW blocker dispatched on the same file | set again | lists the new blocker | block |
45
+ | 8 | policy ran, suppressed nothing, changed no verdict | untouched | untouched | unchanged |
46
+ | 9 | record for a file that no longer exists (snapshot drops it) | untouched | untouched | unchanged |
47
+
48
+ ## Decision
49
+
50
+ The latch is an aggregate over the inline-blocker MAP, and the map carries the
51
+ policy verdict. One identity rule, no second clause:
52
+
53
+ > A record is in the gate's blocking set unless the LAST turn-end policy pass
54
+ > found every blocker it carries suppressed.
55
+
56
+ - One writer for the verdict: the turn-end composer, at the point where the
57
+ post-policy survivor set per file is final, calls
58
+ `resyncGitGuardAfterInlinePolicy`, which stamps
59
+ `InlineBlockerRecord.policySuppressed` on every live record (true for a fully
60
+ suppressed file, false for every other, so a verdict cannot outlive its pass)
61
+ and re-derives the latch through `updateGitGuardStatus(false, "")` — the same
62
+ re-derivation the retire path uses.
63
+ - Two readers, both deriving from the map: the latch
64
+ (`RuntimeCoordinator.updateGitGuardStatus`) and the persisted record
65
+ (`syncGitGuardRecord`'s `blockerContent`). The per-edit writer therefore
66
+ honors the verdict too, without a second identity clause.
67
+ - The record keeps the suppressed entry. The policy is content-bound and
68
+ re-derived every turn end, so retiring the entry would be silencing rather
69
+ than filtering (AGENTS.md shape 10), and a fresh dispatch replaces the record
70
+ wholesale, so a new blocker is never born pre-suppressed (#1198 ordering).
71
+ - No third durable writer at turn end. The composer rewrites or clears the
72
+ record later in the SAME turn end from the same survivor set, and its clear is
73
+ gated on the latch this pass just recomputed.
74
+
75
+ ### Amendment, review round 2 (GG-3283-01): the verdict is content-bound
76
+
77
+ Round 1's rule said a verdict "cannot outlive the pass that made it" because
78
+ `applyInlineBlockerPolicyVerdicts` rewrites the whole axis each turn end. The
79
+ review probe showed that is only true while every byte change enters pi-lens
80
+ dispatch: `handleTurnEnd` returns before the policy loop when no file was
81
+ touched, and a commit can arrive with no turn end after the change at all.
82
+
83
+ Second state table, axes: did a turn end judge this record · how the bytes moved
84
+ since the verdict · is the durable record present.
85
+
86
+ | # | judged | bytes since verdict | record | gate | case |
87
+ |---|---|---|---|---|---|
88
+ | 1 | yes | unchanged | cleared | allow | `clears the latch and the record when every blocker on the file is marked` |
89
+ | 2 | yes | unchanged | present | block | `keeps the gate closed when only two of four blockers are marked` |
90
+ | 3 | yes, then an unrelated edit | unchanged | cleared | allow | `stays clear when the NEXT turn edits an unrelated clean file` |
91
+ | 4 | yes, then a dispatch of the same file | changed by dispatch | rewritten | block | `re-latches when a NEW blocker is dispatched on the same file after the mark` |
92
+ | 5 | no (no-file turn end) | unchanged | cleared | allow | `still allows the commit after an unjudged turn end that changed nothing` |
93
+ | 6 | no (no-file turn end) | changed outside dispatch | cleared | **unknown** | `keeps the gate closed when the suppressed file's bytes moved outside dispatch` |
94
+ | 7 | no turn end at all after the change | changed outside dispatch | cleared | **unknown** | same case, and `does not allow the commit after an external change to a marked file` (pi host) |
95
+ | 8 | yes, after an outside change | changed outside dispatch | re-rendered | block | the freshness sweep demotes the record before the policy loop, so it is never a survivor and the verdict is cleared |
96
+ | 9 | yes | unverifiable (no content baseline) | either | **unknown** | `keeps the gate closed for a suppressed record with no content baseline` |
97
+
98
+ One clause, added to the rule above rather than replacing it: **a suppression
99
+ verdict is a statement about BYTES, so it stands only while the file still
100
+ matches the content baseline the record carries** (`recordedHash`, the #2982
101
+ baseline the freshness sweep already compares against — and which the sweep
102
+ guarantees matched at stamping time, since a self-drifted record is demoted
103
+ before the policy loop can suppress it). The gate — the one reader that can let
104
+ a verdict OPEN a commit — confirms that before allowing, and an unverifiable
105
+ record (no baseline) fails closed. Rejected alternatives, from the table:
106
+
107
+ - a TURN generation on the stamp: reds cell 3, which is the defect #3248 is
108
+ about (an unrelated edit must not re-block).
109
+ - invalidating verdicts at every unjudged turn end (the review's suggestion):
110
+ leaves cells 7 and 9 open, because no turn end runs between the change and the
111
+ commit — and reds cell 5, re-blocking a commit after a read-only turn.
112
+ Mutation P1 in PR #3283 quotes both.
113
+ - clearing the verdict in the freshness sweep only: covers cell 8, which the
114
+ existing rewrite already covers, and none of 6, 7 or 9.
115
+
116
+ ### Amendment 3 (#3282): the record's attribution contract
117
+
118
+ Written before the first edit of #3282's fix. `blockerContent` is the rendered
119
+ blocker text the gate may quote back; it is NOT the gate's source of truth about
120
+ which files block — `blockingFiles` is (`clients/git-guard.ts:31`). The parse of
121
+ that text exists for one consumer, `syncGitGuardRecord`'s
122
+ `clearedLastKnownBlocker`, which drops `blockerContent` when the file that just
123
+ dispatched clean owns everything left in it.
124
+
125
+ Writers of `blockerContent`, re-verified at `f8fef5f72`:
126
+
127
+ | Actor | Axis / timing | Shape it writes | Site |
128
+ |---|---|---|---|
129
+ | `syncGitGuardRecord` | per edit, after each dispatch | `<path>: <formatDiagnostics(…,"blocking").trim()>` per live entry, joined `"\n"` | `clients/git-guard.ts:1013` |
130
+ | turn-end composer, main path | per turn, after the policy | `capTurnEndMessage(blockerParts.join("\n\n"))` | `clients/runtime-turn.ts:4291` |
131
+ | turn-end composer, dedupe path | per turn, duplicate signature | the same expression | `clients/runtime-turn.ts:4234` |
132
+ | `mergeGitGuardTestFailure` / `clearGitGuardTestFailure` | test-runner edges | pass `existing.blockerContent` through | `clients/git-guard.ts:1068`, `:1113` |
133
+ | `resyncGitGuardAfterInlinePolicy` | per turn, before the composer | nothing durable — the latch only | `clients/git-guard.ts:919` |
134
+
135
+ Neither of the two real writers emits one line per file, and `blockerParts` is
136
+ multi-lane: only `Unresolved from this turn — <path>:` sections
137
+ (`clients/runtime-turn.ts:1085`) name a file at all — knip (`:2147`), trivy
138
+ CRITICAL (`:2141`), the secrets lane (`:2099`) and cascade (`:1340`) do not.
139
+
140
+ > **Contract.** `blockerContent` is a sequence of per-file blocker SECTIONS. A
141
+ > section is opened by an attribution line at column 0 — `<path>: <text>` or
142
+ > `Unresolved from this turn — <path>[ (suppressed by disposition: N
143
+ > finding(s))]:` — and every following line up to the next attribution line or
144
+ > the next ZERO-LENGTH line is its rendered body, attributing nothing. The text
145
+ > is attributable iff at least one line opens a section, no line precedes the
146
+ > first section, and every section's file is named in `blockingFiles`. A clean
147
+ > per-file dispatch may clear the text iff every section in it is that file's.
148
+ >
149
+ > Zero sections is UNATTRIBUTED, never clean (#3287 round 2, HIGH-3287-1): text
150
+ > that is non-empty but all-blank opens nothing, and reading that as "nothing
151
+ > left to attribute" let a clean dispatch of an unrelated file delete a record
152
+ > whose `blockingFiles` named a different file. A record with no blocker text at
153
+ > all never reaches the parse — `syncGitGuardRecord` gates on
154
+ > `existing?.blockerContent` being truthy — so the genuine clean case is decided
155
+ > before this contract applies.
156
+
157
+ One-directional on purpose. A bijection between sections and `blockingFiles` was
158
+ #3282's second cause: the composer persists `blockingFiles: affectedFiles`
159
+ (`:4292`) — every file the turn touched plus every cascade neighbour with
160
+ diagnostics — so any turn that edited a clean file alongside a blocking one was
161
+ judged untrusted for the rest of the session. The extra direction guarded
162
+ nothing either: the clear now asks about the sections themselves, so a
163
+ `blockingFiles` entry with no section, a duplicate section and a duplicate
164
+ `blockingFiles` entry cannot make it drop a blocker another file owns. Requiring
165
+ `blockingFiles` to be NON-EMPTY is subsumed for the same reason once at least
166
+ one section is required, so that clause is gone too: an empty provenance list
167
+ cannot own a section.
168
+
169
+ Fail direction (AGENTS.md shape 48): an unattributable line keeps `unknown`. The
170
+ harm it prevents is a live CVE or leaked secret being cleared out of the gate by
171
+ a clean dispatch of the file beside it; the harm it causes is a refused commit
172
+ whose stated remedy ("re-run pi-lens checks or start a fresh session") does not
173
+ clear it, which is why the reason must be reachable only for text no writer
174
+ produces for a single file. `TurnEndFindingsCache` is unchanged by #3282 as
175
+ well: parser-side only, no field added, no writer altered.
176
+
177
+ ## Consequences
178
+
179
+ The latch cannot be treated as a read-only projection of the blocker map.
180
+ Old-record parsing, the latch-first consumer, and the paired resynchronization
181
+ remain strict consumers of the next fix. `TurnEndFindingsCache` is unchanged by
182
+ this slice — the verdict is in-memory only, so a 4.2.1 commit hook reads records
183
+ written after a policy pass with no new field.
184
+
185
+ The gate now reads the blocker map itself, not only the latch memo: one content
186
+ hash per suppressed record on a commit/push attempt, and nothing when no verdict
187
+ is live. `logDecision`'s existing `git-guard` `decision` record carries the new
188
+ `inline_policy_stale` reason category with the file and tier.
189
+
190
+ Clearing the latch exposed the gate's SECOND reader, which had its own defect:
191
+ `hasCompleteBlockingProvenance` parsed `blockerContent` line by line while both
192
+ writers of that field render multi-line blocker text, so the gate answered
193
+ `blocking_provenance_untrusted` once a session had recorded any blocker —
194
+ measured on `origin/master`, independent of dispositions, filed as #3282 and
195
+ fixed under amendment 3 above. The witness golden's turns 2 and 3 flip from that
196
+ reason to `block: false`, as this slice committed them to do.
197
+
198
+ ## Links
199
+
200
+ - Catalog shape: `AGENTS.md` shape 24.
201
+ - Issue: #3248.
202
+ - PR: #3254, “The git-guard latch” remainder and writers-by-axis table.
203
+ - Follow-up: #3282 (record provenance parse) — amendment 3.
@@ -0,0 +1,38 @@
1
+ # ADR 0004: fold dispositions onto the policy seam
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-09-23
6
+
7
+ ## Context
8
+
9
+ The #1892 brief found freshness and disposition to be different gates with
10
+ different failure directions. The #3247 and #3254 slices showed that missing
11
+ turn-end disposition stages are existing-surface wiring gaps, not proof that a
12
+ new findings store is required.
13
+
14
+ ## Decision
15
+
16
+ Fold disposition application onto `applyFindingPolicy` and its existing pushed
17
+ finding-policy seam. Do not introduce a new findings store until #1892 has a
18
+ measured structured-store slice with a deletion sweep.
19
+
20
+ ## Consequences
21
+
22
+ Existing producers keep their freshness, identity, and coverage contracts while
23
+ delivery surfaces converge on the shared policy. A future store migration must
24
+ measure the request and delete parallel identity/count logic in the same slice.
25
+ The measured host-adapter population is two: the pi extension adapter activates
26
+ through `activateExtension` and assembles `HostPorts` in `index.ts:725-767`, and
27
+ the MCP server imports the host-neutral facade and serves requests in
28
+ `mcp/server.ts:40-45,1881`. `LensEngine` (`clients/lens-engine.ts:2`) and
29
+ `HostPorts` (`clients/host-ports.ts:10`) are the shared internal engine/ports
30
+ seam, not additional adapters. Because the heuristic says two adapters are a
31
+ real seam, #1358 (the ports layer) is **re-assess**, not held on a one-adapter
32
+ claim.
33
+
34
+ ## Links
35
+
36
+ - Catalog shapes: `AGENTS.md` shapes 10 and 26.
37
+ - Issue/brief: #1892, 2026-09-22 brief.
38
+ - PRs: #3247 and #3254.
@@ -0,0 +1,29 @@
1
+ # ADR 0005: tool availability is one seam by enforcement
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-09-23
6
+
7
+ ## Context
8
+
9
+ The availability policy and `createAvailabilityLatch` already define the shared
10
+ taxonomy, but nine stores answer the same “can this tool run, at what path?”
11
+ question. Migrating all stores at once is not justified by the evidence.
12
+
13
+ ## Decision
14
+
15
+ Enforce one seam with a registry ratchet. A change touching a registered store
16
+ migrates that store onto the shared policy in the same change and removes it
17
+ from the registry; the registry never grows.
18
+
19
+ ## Consequences
20
+
21
+ The population can shrink without speculative migration. The runner/formatter
22
+ mid-session uninstall divergence remains the first red slice under #1894.
23
+
24
+ ## Links
25
+
26
+ - Catalog shape: `AGENTS.md` shape 52.
27
+ - Issue: #1894, rewritten 2026-09-22.
28
+ - Catalog PR: #3253.
29
+
@@ -0,0 +1,31 @@
1
+ # ADR 0006: benchmark before a derived-state cache
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-09-23
6
+
7
+ ## Context
8
+
9
+ The staleness arc fixed caches whose invalidation keys could not express the
10
+ truth. For signatures, line counts, hashes, and import graphs, a bounded
11
+ request-local recompute can remove that invalidation surface. Tool-run caches
12
+ are a separate class because the expensive operation is the tool run.
13
+
14
+ ## Decision
15
+
16
+ Do not add a cross-request derived-state cache until a fresh-process benchmark
17
+ shows that request-local recomputation is the cost. A justified cache carries
18
+ the generation from which it was derived.
19
+
20
+ ## Consequences
21
+
22
+ Cheap derived state has no stale cache to invalidate. Any future cache needs a
23
+ benchmark with a control row, explicit freshness axes, and a measured reason
24
+ that the request-local pass is insufficient.
25
+
26
+ ## Links
27
+
28
+ - Catalog shape: `AGENTS.md` shape 51.
29
+ - Issue: #1644 closure decision, 2026-09-22.
30
+ - PR: #3253.
31
+
@@ -0,0 +1,38 @@
1
+ # ADR 0007: end-to-end witness per seam slice
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-09-23
6
+
7
+ ## Context
8
+
9
+ Seam umbrellas #1892, #1816, #1193, #1894, and #1844 need evidence that
10
+ survives adapter wiring and records both what the model receives and what the
11
+ degradation ledger recorded. The #3247 round-2 adapter tests establish the
12
+ shape through the pi and MCP turn-end surfaces.
13
+
14
+ ## Decision
15
+
16
+ Every slice under those seam umbrellas adds one witness test through the real
17
+ host entry: pi `index.ts` `turn_end` to the runtime-context message, or the MCP
18
+ turn-end entry in `clients/mcp/session.ts`. The test commits the rendered
19
+ context message and degradation-ledger rows as a golden fixture under
20
+ `tests/fixtures/witness/<slice>/` and diffs it on every run. The witness is
21
+ owned by the #1605 lane umbrella. Process boundaries, including LSP servers
22
+ and tool binaries, use the real-wire fake server rather than a module mock.
23
+
24
+ ## Consequences
25
+
26
+ Witnesses are the verifiable, repeatable artifact for each seam slice and
27
+ expose adapter drift at the host boundary. A witness is not the sole mechanism
28
+ at a process boundary: a module mock cannot prove argv, wire framing, process
29
+ lifecycle, or tool output, so those boundaries require the real-wire fake
30
+ server. In-process stores, sinks, coordinators, and dispatchers remain real.
31
+
32
+ ## Links
33
+
34
+ - Umbrella: #1605.
35
+ - Seam slices: #1892, #1816, #1193, #1894, #1844.
36
+ - Shape: `tests/index-3246-turn-end-delivery.test.ts` and
37
+ `tests/clients/mcp/session-inline-blocker-dispositions.test.ts`.
38
+ - Adapter evidence: #3247 round 2.
@@ -0,0 +1,165 @@
1
+ # ADR 0008: one `TurnEndLane` interface, and what stays in the composer
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-09-23
6
+
7
+ ## Context
8
+
9
+ `clients/runtime-turn.ts` was 4,654 lines with fourteen delivery lanes written
10
+ inline in `handleTurnEnd`. Each lane read its own store, applied its own
11
+ freshness/disposition policy, and formatted its own section into
12
+ `blockerParts`/`advisoryParts`, so the composer stated every lane's rules and no
13
+ lane could be read, tested or changed on its own. #1892's third slice deepens
14
+ the composer into a thin orchestrator over lane modules that share ONE
15
+ interface, one lane per round, with no agent-facing change.
16
+
17
+ The constraint that shapes the interface is #3264's fold: the turn-end scanner
18
+ stores share ONE `gateFindingsByPathFreshness` call, so a path two stores cite
19
+ is `stat`'d once, spends one stat budget, and writes one bounded decision
20
+ record per delivery instead of up to six. A lane that gated itself would
21
+ re-split that pass the moment a second lane existed.
22
+
23
+ The second constraint is the delivery-surface registry
24
+ (`clients/finding-delivery-gate.ts`): its `@delivery-surface:` seam scan reads
25
+ exactly `clients/runtime-turn.ts` and `tools/lens-diagnostics.ts`. Code that
26
+ renders an agent-facing section from a file that scan does not read would be an
27
+ unregistered surface, invisible to the mechanism built to enumerate them.
28
+
29
+ ## Decision
30
+
31
+ **The interface** (`clients/turn-end/lane.ts`) is three stages, and nothing else — no lane id, because nothing would read one:
32
+
33
+ | Stage | Signature | Owns |
34
+ |---|---|---|
35
+ | collect | `collect(ctx) → Promise<S>` where `S` maps STORE name → `FindingFreshnessSource` | which caches to read, classification, the per-store `citedPath`/`scannedAt`/`onMissing` policy. Structured rows with source identity, never a rendered string. Calls no gate. |
36
+ | gate | `gate(gates: TurnEndLaneGates<S>, ctx) → Kept` | the policy the shared pass cannot apply: dispositions through `filterFindingsByDisposition` (the seam in `clients/dispatch/finding-policy.ts`, ADR 0004), plus any per-store existence/lifecycle contract. `Kept` is lane-private. |
37
+ | render | `render(kept, ctx) → TurnEndLaneParts` | the sections, the display cap, the tier each section belongs to, the per-store suppressed counts, and the location keys it delivered. Returns sections; never pushes. `TurnEndLaneParts` carries a field per tier the composer actually pushes and no others — an unread field is a side channel that drops a lane's output, so the advisory tier joins the type with the lane that renders one. |
38
+
39
+ `TurnEndLaneContext` is the whole window a lane gets onto the turn: `cwd`, the
40
+ hook's `signal`, `readScannerCache` (memoized per turn by the composer) and
41
+ `peekActionableWarnings`.
42
+
43
+ **Writers by axis** — what the composer keeps, and why it cannot be a lane's:
44
+
45
+ | Axis | Owner | Why |
46
+ |---|---|---|
47
+ | Path-freshness pass (stat memo, stat budget, `finding_dead_path_drop` / `finding_stale_line_demote` rows) | composer | A cross-lane resource. Per-lane gating is #3264 undone: N stat passes, N budgets, N records for one filesystem fact. A lane only DECLARES sources. |
48
+ | One read per scanner store per turn | composer (`readScannerCache` memo) | The trivy store is read by the secrets lane and by the CVE/license tiers. Two envelopes of one store in one delivery is the parallel-store shape, and the TTL boundary can fall between the reads. |
49
+ | Disposition application | lane | Per-store anchor identity; only the lane knows which `ProjectDiagnostic` adapter anchors its rows. |
50
+ | Tier order, `blockerParts`/`staleSecretParts`/`advisoryParts` accumulation, the `@delivery-surface:` tags | composer | The tags are what enumerates the surfaces; the scan reads the composer. |
51
+ | Suppressed-by-disposition notice | composer, fed per lane | One bounded notice per turn (#1616), with per-lane attribution. |
52
+ | Turn signature dedupe, `turn-end-findings` cache write, git-guard record, message cap, turn telemetry | composer | Delivery-event properties of the whole turn, not of one lane. |
53
+ | Delivered location keys | lane produces, composer consumes | One secret is reported once across tiers (#131 Mode 3). |
54
+
55
+ **Registry evidence.** A surface whose render moved into a lane keeps its
56
+ registry `file` as `clients/runtime-turn.ts`, because that is where its tagged
57
+ push seam and its gate call live. Its evidence literal is repinned from the
58
+ partition it used to read (`gitleaksGate.live,`) to the gated store object the
59
+ composer hands the lane (`gitleaksGate,`). That is a weaker pin — it no longer
60
+ states which partition feeds which tier — and the loss is recorded rather than
61
+ hidden: the live/stale split is a lane rule now, pinned behaviourally by
62
+ `tests/clients/runtime-turn-finding-freshness.test.ts` and the witness goldens.
63
+ What the pin still proves is what the registry exists for: the rows came out of
64
+ a real gate call, not a hand-built object.
65
+
66
+ **Lanes that do not fit, and are not being made to.** Test-runner findings
67
+ (generation/retirement state, a verdict rather than diagnostics, its own
68
+ provenance validation at read), the git-guard latch (a durable second
69
+ writer/consumer with a commit-gate audience) and the agent nudge (session
70
+ read/edit relevance, no findings and no freshness axis at all) stay outside
71
+ this interface. So do the package-pinned trivy CVE/license tiers: they have no
72
+ cited path, so their `collect` would declare no freshness source and their
73
+ honesty mechanism is the age label, not the gate.
74
+
75
+ **One lane is one delivery contract, not one store.** The secrets lane
76
+ (`clients/turn-end/lanes/secrets.ts`) owns BOTH secret stores, because #131
77
+ Mode 3 collapses gitleaks, trivy-secrets and ast-grep by LOCATION into one
78
+ blocker section with combined provenance, and the demoted tier merges the same
79
+ two stores. A "gitleaks-only" lane would leave every rendering rule in the
80
+ composer and move only a cache read.
81
+
82
+ ## Amendment — 2026-09-23: the advisory tier joins `TurnEndLaneParts`
83
+
84
+ `TurnEndLaneParts` gains ONE field, `advisoryParts?: readonly string[]`, with
85
+ the govulncheck lane (`clients/turn-end/lanes/govulncheck.ts`). This is the
86
+ amendment the Decision above anticipated ("the advisory tier joins the type
87
+ with the lane that renders one"), not a widening of the rule.
88
+
89
+ **Why it was withheld until now.** The rule is "a field per tier the composer
90
+ PUSHES, and no others", because a field the composer does not read is a side
91
+ channel that silently drops a lane's whole output (AGENTS.md shape 5). The
92
+ secrets lane renders no advisory, so an `advisoryParts` shipped with the
93
+ interface would have been exactly that: a slot a later lane could fill and lose.
94
+ The field, the composer's push and the surface's registry id therefore move
95
+ together, in one round, as one claim.
96
+
97
+ **What the composer pushes it into.** `handleTurnEnd`'s `advisoryParts` array
98
+ (`clients/runtime-turn.ts`), under the existing tag
99
+ `// @delivery-surface: runtime-turn:govulncheck-advisory`, at the same position
100
+ in the tier order the inline block occupied. The tag is what makes the tier a
101
+ registered delivery surface rather than a string, and
102
+ `tests/config/turn-end-lane-boundaries.test.ts` already listed `advisoryParts`
103
+ in the tier-push rule it reds on — so a lane that pushes its own advisory was
104
+ forbidden before the field existed, and still is.
105
+
106
+ **What did NOT change.** Still three stages; still no lane id; still no fourth
107
+ method. The `runtime-turn:govulncheck-advisory` registry entry keeps its `file`,
108
+ its `gated` mode and its evidence literal `scannerGates.govulncheck` unchanged
109
+ — the composer still binds that gate arm itself and hands it to the lane, so
110
+ unlike the secrets repin nothing was weakened here.
111
+
112
+ **The interface question this lane was asked and did not need to answer.** The
113
+ brief asked how a lane declares "no freshness source" honestly, since a
114
+ package-pinned row (trivy CVE/license) has no path to stat. The state table is:
115
+
116
+ | Lane's store | has a cited path | honesty mechanism | fits the interface today |
117
+ |---|---|---|---|
118
+ | govulncheck | yes (first trace frame with a filename) | the shared gate, `onMissing: "demote"` | yes — this round |
119
+ | gitleaks / trivy-secrets | yes (`finding.file`) | the shared gate, `onMissing: "drop"` | yes — #3269 |
120
+ | trivy CVE / license | no — pinned by package, not by file | `formatCacheAgeLabel`, the registry's `labeled` mode | NO |
121
+ | a finding whose `citedPath` returns `undefined` for SOME rows | mixed | the gate leaves the uncited row live and gates the rest | yes — already handled per row |
122
+
123
+ The last row is the one that matters for "no freshness source": the gate already
124
+ takes `citedPath` returning `undefined` as "leave live", per FINDING, so a lane
125
+ with a partially-uncited store needs nothing new (the govulncheck witness pins
126
+ it: `GO-2026-0103` has an empty trace and renders by module). A store where
127
+ EVERY row is uncited is a different animal — it is not a gated lane at all, it
128
+ is a `labeled` one, and giving it a `sources: {}` `collect` would let it pass
129
+ the composer's gate with an empty declaration and look gated in the registry
130
+ while nothing checked anything. That is the second exception the brief said not
131
+ to bend the lane around, so it is not added: the labeled lanes need a `label`
132
+ stage (an age string the composer renders into the header) before they can be
133
+ extracted, and that is the design decision of the round that extracts one.
134
+
135
+ ## Consequences
136
+
137
+ - The composer no longer states a govulncheck rule (2026-09-23): 4,438 →
138
+ 4,403 lines, and the six rules the block held (the `demote`/first-frame
139
+ freshness declaration, the both-arm disposition anchor, the stale-line
140
+ withholding and marker, the module/package fallback, the fix hint and the
141
+ display cap) are stated once, in the lane.
142
+ - The composer no longer states a secrets rule: 4,654 → 4,438 lines, and the
143
+ ten rules the block used to hold (cache key, classification budget and
144
+ fail-open, blocking filter, freshness declaration, two disposition anchors
145
+ over both arms, location dedupe, ast-grep enrichment, display cap, both
146
+ preambles, demoted-row identity) are stated once, in the lane.
147
+ - Extracting a lane moves code out from under two mechanisms that read only
148
+ `clients/runtime-turn.ts`, so `tests/config/turn-end-lane-boundaries.test.ts`
149
+ walks `clients/turn-end/` and reds if a lane pushes into a tier or calls the
150
+ freshness gate itself. Both rules name the recurrence they prevent.
151
+ - Registrations move with the code: the `bounded()` call-site registry entry,
152
+ the one-hop unbounded-await pin, and the delivery-surface marker exemption
153
+ all name the lane file now. A lane is a new file every directory-scanning
154
+ governance suite sees.
155
+ - The next lanes are extracted one per round against this interface. A lane
156
+ that needs a stage this interface does not have is a finding to report, not a
157
+ fourth method to add quietly.
158
+
159
+ ## Links
160
+
161
+ - Umbrella: #1892. Previous slices: #3264 (the shared freshness pass), #3269
162
+ (this interface and the secrets lane).
163
+ - Witness convention: docs/adr/0007-end-to-end-witness-per-seam-slice.md.
164
+ - Disposition seam: docs/adr/0004-disposition-policy-seam.md.
165
+ - Registry and evidence rules: `clients/finding-delivery-gate.ts`.
@@ -0,0 +1,114 @@
1
+ # ADR 0009: reported-path attribution is one seam, based on the runner cwd
2
+
3
+ ## Status
4
+
5
+ Accepted — 2026-09-23
6
+
7
+ ## Context
8
+
9
+ Every runner that parses a tool's textual or JSON output has to answer one
10
+ question per reported line: *is this diagnostic about the file the dispatcher
11
+ ran me for?* Twelve sites answered it with their own predicate — a bare `===` or
12
+ `!==` over two `path.resolve` results, a `path.resolve` with NO base argument, a
13
+ `path.posix.basename` compare, and two `endsWith` compares. #209 and #3277 are
14
+ the shipped defects of the first two shapes: a spelling that differs only in
15
+ case is the SAME file on Windows and on a case-folding POSIX mount, so the
16
+ compare dropped every finding for the edited file and the run reported clean.
17
+ #3278 measured the population at ~11 and #1193 recorded the fold verdict.
18
+
19
+ The state space, written before the first edit. Left = the path the tool
20
+ printed. Right = `path.resolve(ctx.filePath)`, which is
21
+ `normalizeMapKey`-canonical and absolute by the #2016 invariant.
22
+
23
+ | axis | value | consequence |
24
+ |---|---|---|
25
+ | host folds case | win32 always; POSIX only on a case-folding mount (APFS, `nocase` vfat/ntfs3/cifs) | a `process.platform === "win32"` test is wrong in both directions on APFS. Only the filesystem knows, so the predicate must ask it. |
26
+ | reported spelling | absolute (the tool echoes our argv), relative to the tool's own cwd, relative to a package/base directory it chose, or basename-only | only the tool's OWN cwd is a base the caller can supply; `process.cwd()` is the extension's and is a different directory whenever `resolveRunnerCwd` returns a nested language root. |
27
+ | separators after `path.resolve` | host-native on both sides; a foreign separator survives only embedded in the reported string | separator folding can only MERGE, never split. |
28
+ | symlinked project dir | `pathsEqual` does NOT resolve symlinks on POSIX (`clients/path-utils.ts:181`, deliberate) | a project entered through a directory symlink can still mis-match; that is a property of the shared seam, unchanged by this decision (#3276's recorded output). |
29
+ | purpose | identity test of ONE file, per reported line — not a stored key, not set membership | asks for an equality predicate, not a key derivation. |
30
+
31
+ Candidate seams: `normalizeEphemeralMapKey` is rejected by its own contract
32
+ (`clients/path-utils.ts:488-490` — "never … compared against externally-supplied
33
+ paths where symlink / real-casing resolution actually matters"; the left side is
34
+ literally an external process's output, and it folds case on
35
+ `process.platform === "win32"` only). `normalizeFilePath`/`normalizeMapKey` are
36
+ the right rule in the wrong shape: the caller would still hold the comparison
37
+ and re-derive both keys. A NEW `reportedPathMatchesTarget(reported, { cwd,
38
+ target })` helper fails the deletion test — removing it would put back the same
39
+ single expression at each call site rather than concentrating anything — and it
40
+ would hide the comparison from the #1193 sweep's call-site walk while raising
41
+ the helper count.
42
+
43
+ ## Decision
44
+
45
+ The predicate is `pathsEqual(path.resolve(<the cwd the tool ran in>, reported),
46
+ absTarget)`, written INLINE at each call site. It is not a new helper: it is the
47
+ call shape `clients/dispatch/runners/terragrunt.ts:90`,
48
+ `elixir-check.ts:63` (#3276) and `go-vet.ts:107` (#3277) already use, so this
49
+ decision migrates the remaining members onto an existing seam and adds nothing.
50
+ A parse function that does not receive the runner cwd takes it as a parameter;
51
+ no parse function resolves a reported path against `process.cwd()`.
52
+
53
+ Absence is enforced by `tests/config/reported-path-attribution-sweep.test.ts`, a
54
+ shrink-only census of runner sites that still hold their own predicate.
55
+ `tests/config/path-key-fold-sweep.test.ts` counts folds that EXIST and therefore
56
+ structurally cannot see a member with no fold at all — measured on #3277, where
57
+ mutating `go-vet.ts` to a hand-rolled case fold left that sweep green.
58
+
59
+ A member's fold is admitted only with its premise established: what spelling
60
+ does this tool really emit under OUR invocation? Where the tool echoes the
61
+ absolute path we hand it as argv, the fold is behaviour-preserving and its tests
62
+ say so, rather than feeding a spelling the tool never emits.
63
+
64
+ ## Consequences
65
+
66
+ The path-case rule exists once, in `clients/path-utils.ts`; no runner holds a
67
+ `process.platform` test or a `toLowerCase` for this question. `pathsEqual` costs
68
+ a `realpathSync.native`/`existsSync` per reported line, which is bounded by the
69
+ tool's own output size on a parse path that already allocates per line.
70
+
71
+ Two directions are now pinned per member: the tool's real spelling attaches, and
72
+ a different file's finding does not. On a case-folding host the case-variant
73
+ spelling attaches too, and on a case-sensitive host it must not — asserted from
74
+ the filesystem's own answer, never from `process.platform`.
75
+
76
+ The predicate still cannot see through a directory symlink on POSIX, and a
77
+ runner that needs that must say so at its own call site rather than re-deriving
78
+ a local rule. It also cannot SPLIT a separator: `pathsEqual` folds `\` to `/` on
79
+ every platform (`clients/path-utils.ts:231`), so a POSIX file literally named
80
+ `a\b.gleam` stays merged with `a/b.gleam` after a member's own hand-rolled fold
81
+ is deleted. Deleting the local fold removes a second, divergent copy of the rule;
82
+ it does not change that answer.
83
+
84
+ Two amendments from #3285 / #3286, the two remainders:
85
+
86
+ 1. The CAPTURE is part of this decision, not a detail before it. A tool renders
87
+ its location line however it likes, and `gleam` renders through
88
+ `codespan_reporting`, whose locus line wraps the path in a `┌─` gutter. A
89
+ suffix compare tolerates that decoration and an equality predicate cannot, so
90
+ a member whose reported path arrives decorated moves the decoration out of the
91
+ captured group in the same change — and its premise is a vector generated by
92
+ the upstream RENDERER (`tests/fixtures/gleam-codespan/`), not a transcription
93
+ of it. The same rule covers colour: a tool that may emit SGR codes goes
94
+ through `stripAnsi` before an anchored capture.
95
+ 2. The population is runners AND tool clients (`clients/*-client.ts`). The autofix
96
+ half of a tool lives in its client, `ruff-client.ts` held the same bare `!==`,
97
+ and a detector scoped to `clients/dispatch/runners/**` structurally could not
98
+ see it. Widening cost exactly one non-member registration
99
+ (`clients/test-runner-client.ts`, two self-derived directories).
100
+
101
+ ## Links
102
+
103
+ - Umbrella: #1193 (P3 fold), #3278 (the population).
104
+ - Precedents: #3276 (`elixir-check`), #3277 / PR #3281 (`go-vet`).
105
+ - Shipped defects: #209, #3277.
106
+ - Detector: `tests/config/reported-path-attribution-sweep.test.ts`.
107
+ - Cells: `tests/clients/dispatch/runners/reported-path-attribution.test.ts`.
108
+ - Witness (ADR 0007): `tests/fixtures/witness/runner-outcome-eslint-golangci/golangci-lint-relative-path.txt`.
109
+ - Remainders, both now closed: `gleam-check.ts` (#3285 — its `endsWith` was
110
+ load-bearing for `codespan_reporting`'s gutter) and `clients/ruff-client.ts`
111
+ (#3286 — outside the runner population).
112
+ - Upstream vectors: `tests/fixtures/gleam-codespan/` (gleam v1.18.1 +
113
+ codespan-reporting 0.13.1), `tests/fixtures/ruff-json/` (ruff 0.16.8).
114
+ - Cells for the client half: `tests/clients/ruff-client-reported-path.test.ts`.
@@ -0,0 +1,26 @@
1
+ # Architecture decision records
2
+
3
+ Each record is `NNNN-title.md` and uses the same short shape: number/title,
4
+ status, date, Context, Decision, Consequences, and Links. Records capture a
5
+ settled design decision; observations without a decision remain in the defect
6
+ catalog or issue history.
7
+
8
+ The catalog is the index for defect shapes. A shape that records a decision
9
+ links its ADR in `AGENTS.md`; ADR links point to the catalog shape, the issue
10
+ that supplied the question, and the PR or brief that settled it. Use `refs`
11
+ for work that remains open and `closes` only when its acceptance criteria are
12
+ complete.
13
+
14
+ ## Seed catalog
15
+
16
+ | ADR | Decision | Catalog shape | Status |
17
+ |---|---|---:|---|
18
+ | [0001](0001-stale-advisory-live-arm.md) | Stale advisory arm skips disposition policy; live arm applies it. | 10 | Accepted |
19
+ | [0002](0002-workspace-hash-rendezvous.md) | `workspaceHash` is pure cross-process rendezvous derivation. | 1, 2 | Accepted |
20
+ | [0003](0003-git-guard-latch-writer.md) | Git-guard latch is a separate writer on the durable record. | 24 | Accepted |
21
+ | [0004](0004-disposition-policy-seam.md) | Dispositions fold onto `applyFindingPolicy`; no new store before measured #1892 work. | 10, 26 | Accepted |
22
+ | [0005](0005-tool-availability-enforcement-seam.md) | Tool availability is one seam by enforcement, with migrate-on-touch. | 52 | Accepted |
23
+ | [0006](0006-derived-state-benchmark-first.md) | No cross-request derived-state cache without a fresh-process benchmark. | 51 | Accepted |
24
+ | [0007](0007-end-to-end-witness-per-seam-slice.md) | Every seam slice commits an end-to-end host witness and degradation ledger artifact. | #1605 umbrella; #1892, #1816, #1193, #1894, #1844 | Accepted |
25
+ | [0009](0009-reported-path-attribution.md) | Reported-path attribution is `pathsEqual` against the runner cwd, inline at each call site. | 2, 34, 38; #1193, #3278 | Accepted |
26
+ | [0008](0008-turn-end-lane-interface.md) | One `TurnEndLane` interface; `handleTurnEnd` stays a thin orchestrator over lane modules. | #1892 umbrella; #3264, #3269 | Accepted |