@opengsd/gsd-core 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ja-JP.md +3 -3
  4. package/README.ko-KR.md +3 -3
  5. package/README.pt-BR.md +3 -3
  6. package/README.zh-CN.md +3 -3
  7. package/agents/gsd-code-fixer.compact.md +7 -6
  8. package/agents/gsd-code-fixer.md +9 -8
  9. package/agents/gsd-debug-session-manager.compact.md +17 -2
  10. package/agents/gsd-debug-session-manager.md +17 -2
  11. package/agents/gsd-debugger.md +2 -2
  12. package/agents/gsd-eval-auditor.compact.md +1 -1
  13. package/agents/gsd-eval-auditor.md +1 -1
  14. package/agents/gsd-executor.md +13 -8
  15. package/agents/gsd-intel-updater.compact.md +1 -1
  16. package/agents/gsd-intel-updater.md +1 -1
  17. package/agents/gsd-phase-researcher.md +19 -11
  18. package/agents/gsd-plan-checker.md +8 -7
  19. package/agents/gsd-planner.md +12 -8
  20. package/agents/gsd-project-researcher.compact.md +1 -1
  21. package/agents/gsd-project-researcher.md +1 -1
  22. package/agents/gsd-research-synthesizer.compact.md +1 -1
  23. package/agents/gsd-research-synthesizer.md +1 -1
  24. package/agents/gsd-ui-auditor.md +155 -17
  25. package/agents/gsd-ui-researcher.compact.md +1 -1
  26. package/agents/gsd-ui-researcher.md +1 -1
  27. package/agents/gsd-verifier.md +10 -9
  28. package/bin/install.js +642 -95
  29. package/commands/gsd/autonomous.md +2 -2
  30. package/commands/gsd/capture.md +1 -1
  31. package/commands/gsd/mempalace-capture.md +7 -3
  32. package/commands/gsd/plan-review-convergence.md +6 -6
  33. package/commands/gsd/progress.md +1 -1
  34. package/commands/gsd/quick-batch.md +1 -1
  35. package/commands/gsd/review.md +2 -3
  36. package/gsd-core/bin/gsd-tools.cjs +335 -22
  37. package/gsd-core/bin/lib/adr-parser.cjs +3 -1
  38. package/gsd-core/bin/lib/audit.cjs +81 -13
  39. package/gsd-core/bin/lib/capability-registry.cjs +82 -187
  40. package/gsd-core/bin/lib/capability-validator.cjs +0 -1
  41. package/gsd-core/bin/lib/check-command-router.cjs +101 -14
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +21 -25
  43. package/gsd-core/bin/lib/commands.cjs +175 -42
  44. package/gsd-core/bin/lib/config-loader.cjs +65 -4
  45. package/gsd-core/bin/lib/config.cjs +33 -7
  46. package/gsd-core/bin/lib/decisions.cjs +30 -14
  47. package/gsd-core/bin/lib/frontmatter.cjs +13 -0
  48. package/gsd-core/bin/lib/graphify.cjs +10 -2
  49. package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
  50. package/gsd-core/bin/lib/init.cjs +207 -41
  51. package/gsd-core/bin/lib/install-engine.cjs +13 -0
  52. package/gsd-core/bin/lib/installer-migrations.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone.cjs +18 -5
  54. package/gsd-core/bin/lib/model-resolver.cjs +159 -50
  55. package/gsd-core/bin/lib/phase-command-router.cjs +9 -1
  56. package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
  57. package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
  58. package/gsd-core/bin/lib/phase-id.cjs +109 -7
  59. package/gsd-core/bin/lib/phase-locator.cjs +29 -10
  60. package/gsd-core/bin/lib/phase.cjs +227 -26
  61. package/gsd-core/bin/lib/plan-document.cjs +49 -1
  62. package/gsd-core/bin/lib/planning-document.cjs +459 -0
  63. package/gsd-core/bin/lib/planning-inspect.cjs +18 -1
  64. package/gsd-core/bin/lib/planning-workspace.cjs +8 -3
  65. package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
  66. package/gsd-core/bin/lib/probe-core.cjs +7 -1
  67. package/gsd-core/bin/lib/project-root.cjs +41 -2
  68. package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
  69. package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
  70. package/gsd-core/bin/lib/roadmap-command-router.cjs +12 -4
  71. package/gsd-core/bin/lib/roadmap-parser.cjs +163 -3
  72. package/gsd-core/bin/lib/roadmap-upgrade.cjs +1539 -13
  73. package/gsd-core/bin/lib/roadmap.cjs +251 -31
  74. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +283 -31
  75. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -1
  76. package/gsd-core/bin/lib/runtime-homes.cjs +4 -0
  77. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -33
  78. package/gsd-core/bin/lib/runtime-name-policy.cjs +111 -1
  79. package/gsd-core/bin/lib/shell-command-projection.cjs +10 -6
  80. package/gsd-core/bin/lib/state-transition.cjs +39 -2
  81. package/gsd-core/bin/lib/state.cjs +42 -0
  82. package/gsd-core/bin/lib/surface.cjs +17 -1
  83. package/gsd-core/bin/lib/tdd-red-evidence.cjs +78 -5
  84. package/gsd-core/bin/lib/uat-predicate.cjs +47 -4
  85. package/gsd-core/bin/lib/uat.cjs +8 -0
  86. package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
  87. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +100 -9
  88. package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
  89. package/gsd-core/bin/lib/verification.cjs +268 -15
  90. package/gsd-core/bin/lib/verify-command-grounding.cjs +46 -2
  91. package/gsd-core/bin/lib/verify.cjs +132 -25
  92. package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
  93. package/gsd-core/bin/lib/worktree-safety.cjs +784 -51
  94. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  95. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  96. package/gsd-core/references/checkpoints.md +5 -3
  97. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
  98. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
  99. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
  100. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
  101. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
  102. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
  103. package/gsd-core/references/edge-probe.md +195 -21
  104. package/gsd-core/references/execute-phase-between-wave-reset.md +7 -6
  105. package/gsd-core/references/execute-phase-wave-guard.md +22 -11
  106. package/gsd-core/references/gsd-run-resolver.md +1 -1
  107. package/gsd-core/references/model-profiles.md +1 -1
  108. package/gsd-core/references/phase-argument-parsing.md +9 -7
  109. package/gsd-core/references/phase-id-convention.md +28 -0
  110. package/gsd-core/references/planner-gap-closure.md +2 -0
  111. package/gsd-core/references/planner-load-graph-context.md +24 -13
  112. package/gsd-core/references/planner-verify-command-grounding.md +14 -0
  113. package/gsd-core/references/planning-config.md +11 -2
  114. package/gsd-core/references/tdd.md +27 -4
  115. package/gsd-core/references/ui-consideration-probe.md +10 -5
  116. package/gsd-core/references/verify-command-path-resolvability.md +10 -2
  117. package/gsd-core/references/worktree-path-safety.md +321 -0
  118. package/gsd-core/templates/verification-report.md +1 -1
  119. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  120. package/gsd-core/workflows/add-backlog.md +1 -1
  121. package/gsd-core/workflows/add-phase.md +1 -1
  122. package/gsd-core/workflows/add-tests.md +2 -2
  123. package/gsd-core/workflows/add-todo.md +3 -3
  124. package/gsd-core/workflows/ai-integration-phase.md +11 -3
  125. package/gsd-core/workflows/audit-fix.md +1 -1
  126. package/gsd-core/workflows/audit-milestone.md +1 -1
  127. package/gsd-core/workflows/audit-uat.md +1 -1
  128. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
  129. package/gsd-core/workflows/autonomous.md +16 -6
  130. package/gsd-core/workflows/check-todos.md +2 -2
  131. package/gsd-core/workflows/cleanup.md +2 -2
  132. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
  133. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  134. package/gsd-core/workflows/code-review-fix.md +108 -22
  135. package/gsd-core/workflows/code-review.md +63 -46
  136. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +1 -1
  137. package/gsd-core/workflows/complete-milestone.md +2 -2
  138. package/gsd-core/workflows/debug.md +3 -3
  139. package/gsd-core/workflows/diagnose-issues.md +1 -1
  140. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  141. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  142. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  143. package/gsd-core/workflows/discuss-phase.md +1 -1
  144. package/gsd-core/workflows/do.md +2 -2
  145. package/gsd-core/workflows/docs-update.md +3 -3
  146. package/gsd-core/workflows/edit-phase.md +1 -1
  147. package/gsd-core/workflows/eval-review.md +10 -3
  148. package/gsd-core/workflows/execute-phase/detail/elaboration.md +2 -2
  149. package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1017 -0
  150. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  151. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +3 -3
  152. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +37 -3
  153. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  154. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  155. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  156. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
  157. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
  158. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
  159. package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
  160. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
  161. package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +24 -0
  162. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
  163. package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
  164. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +1 -1
  165. package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
  166. package/gsd-core/workflows/execute-phase.md +36 -26
  167. package/gsd-core/workflows/execute-plan.md +5 -4
  168. package/gsd-core/workflows/explore.md +3 -3
  169. package/gsd-core/workflows/extract-learnings.md +2 -1
  170. package/gsd-core/workflows/fast.md +1 -1
  171. package/gsd-core/workflows/forensics.md +1 -1
  172. package/gsd-core/workflows/graduation.md +1 -1
  173. package/gsd-core/workflows/health.md +2 -2
  174. package/gsd-core/workflows/help/modes/full.compact.md +3 -3
  175. package/gsd-core/workflows/help/modes/full.md +5 -5
  176. package/gsd-core/workflows/help/modes/topic.md +15 -5
  177. package/gsd-core/workflows/import.md +2 -2
  178. package/gsd-core/workflows/inbox.md +2 -2
  179. package/gsd-core/workflows/ingest-docs.md +3 -3
  180. package/gsd-core/workflows/insert-phase.md +1 -1
  181. package/gsd-core/workflows/list-seeds.md +1 -1
  182. package/gsd-core/workflows/list-workspaces.md +1 -1
  183. package/gsd-core/workflows/manager.md +2 -2
  184. package/gsd-core/workflows/map-codebase.md +2 -2
  185. package/gsd-core/workflows/milestone-summary.md +1 -1
  186. package/gsd-core/workflows/mvp-phase.md +1 -1
  187. package/gsd-core/workflows/new-milestone.md +2 -2
  188. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
  189. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
  190. package/gsd-core/workflows/new-project.md +7 -7
  191. package/gsd-core/workflows/new-workspace.md +2 -2
  192. package/gsd-core/workflows/next.md +1 -1
  193. package/gsd-core/workflows/note.md +1 -1
  194. package/gsd-core/workflows/onboard.md +1 -1
  195. package/gsd-core/workflows/pause-work.md +1 -1
  196. package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
  197. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
  198. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
  199. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
  200. package/gsd-core/workflows/plan-phase.md +24 -7
  201. package/gsd-core/workflows/plan-review-convergence.md +21 -5
  202. package/gsd-core/workflows/plant-seed.md +62 -20
  203. package/gsd-core/workflows/pr-branch.md +113 -13
  204. package/gsd-core/workflows/profile-user.md +2 -2
  205. package/gsd-core/workflows/progress.md +1 -1
  206. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +25 -0
  207. package/gsd-core/workflows/quick/steps/quick-verification.md +1 -1
  208. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +1 -1
  209. package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
  210. package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
  211. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
  212. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
  213. package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
  214. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
  215. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +1 -1
  216. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
  217. package/gsd-core/workflows/quick-batch.md +1 -1
  218. package/gsd-core/workflows/quick.md +21 -9
  219. package/gsd-core/workflows/reapply-patches.md +9 -3
  220. package/gsd-core/workflows/remove-phase.md +1 -1
  221. package/gsd-core/workflows/remove-workspace.md +2 -2
  222. package/gsd-core/workflows/resume-project.md +1 -1
  223. package/gsd-core/workflows/review.md +31 -16
  224. package/gsd-core/workflows/scan.md +1 -1
  225. package/gsd-core/workflows/secure-phase.md +3 -2
  226. package/gsd-core/workflows/settings-advanced.md +30 -10
  227. package/gsd-core/workflows/settings-integrations.md +2 -3
  228. package/gsd-core/workflows/settings.md +4 -4
  229. package/gsd-core/workflows/ship.md +3 -2
  230. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  231. package/gsd-core/workflows/sketch.md +1 -1
  232. package/gsd-core/workflows/smart-entry.md +2 -2
  233. package/gsd-core/workflows/spec-phase.md +15 -5
  234. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  235. package/gsd-core/workflows/spike.md +1 -1
  236. package/gsd-core/workflows/stats.md +1 -1
  237. package/gsd-core/workflows/sync-skills.md +5 -5
  238. package/gsd-core/workflows/thread.md +1 -1
  239. package/gsd-core/workflows/transition.md +1 -1
  240. package/gsd-core/workflows/ui-phase.md +44 -8
  241. package/gsd-core/workflows/ui-review.md +18 -4
  242. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  243. package/gsd-core/workflows/undo.md +339 -20
  244. package/gsd-core/workflows/update.md +7 -7
  245. package/gsd-core/workflows/validate-phase.md +3 -2
  246. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
  247. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  248. package/gsd-core/workflows/verify-work.md +81 -16
  249. package/hooks/dist/gsd-agent-isolation-guard.js +24 -0
  250. package/hooks/dist/gsd-secret-read-guard.js +27 -1
  251. package/hooks/dist/gsd-statusline.js +70 -13
  252. package/hooks/dist/gsd-validate-commit.sh +63 -4
  253. package/hooks/gsd-agent-isolation-guard.js +24 -0
  254. package/hooks/gsd-secret-read-guard.js +27 -1
  255. package/hooks/gsd-statusline.js +70 -13
  256. package/hooks/gsd-validate-commit.sh +63 -4
  257. package/package.json +3 -2
  258. package/scripts/build-hooks.js +15 -6
  259. package/scripts/check-contract-drift.cjs +127 -11
  260. package/scripts/command-contract-helpers.cjs +3 -0
  261. package/scripts/docs-guard-registry.cjs +28 -0
  262. package/scripts/gen-loop-host-contract.cjs +69 -0
  263. package/scripts/lib/macos-conformance-tier.generated.cjs +14 -0
  264. package/scripts/lib/ndjson-reporter.cjs +3 -2
  265. package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
  266. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +28 -1
  267. package/scripts/lint-phase-arg-assignment.cjs +257 -0
  268. package/scripts/lint-phase-id-drift.cjs +290 -5
  269. package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
  270. package/scripts/lint-retired-runtime-name.cjs +619 -0
  271. package/scripts/lint-state-write-path-drift.cjs +93 -0
  272. package/scripts/lint-test-file-count.allowlist.json +28 -9
  273. package/scripts/lint-workflow-shellcheck-baseline.json +15 -0
  274. package/scripts/prompt-injection-scan.sh +4 -0
  275. package/scripts/release-tarball-smoke.cjs +194 -1
  276. package/skills/gsd-autonomous/SKILL.md +2 -2
  277. package/skills/gsd-capture/SKILL.md +1 -1
  278. package/skills/gsd-mempalace-capture/SKILL.md +7 -3
  279. package/skills/gsd-plan-review-convergence/SKILL.md +5 -5
  280. package/skills/gsd-progress/SKILL.md +1 -1
  281. package/skills/gsd-quick-batch/SKILL.md +1 -1
  282. package/skills/gsd-review/SKILL.md +2 -3
  283. package/vscode/package.json +1 -1
@@ -0,0 +1,1017 @@
1
+ # `code_review_gate` — report the review and record a per-finding disposition
2
+
3
+ Read and executed by `execute-phase.md`'s `code_review_gate` step, immediately after code review
4
+ returns. It consumes `PHASE_DIR` and `PHASE_NUMBER` and derives everything else.
5
+
6
+ It lives here rather than inline in the parent because `execute-phase.md` sits against two size
7
+ ceilings — the XL hard cap in `tests/workflow-size-budget.test.cjs` and the frozen ADR-857
8
+ pre-phase-6 ceiling in `tests/claude-orchestration.test.cjs` — and both are red lines to be kept
9
+ under, not budgets to spend.
10
+
11
+ **What it is for.** The counts the gate prints say how many findings there were, not what happened
12
+ to any of them. Without a record, the phase directory carries no answer to *what happened to CR-01*
13
+ and a phase can reach `phase.complete` with a Critical standing and no trace it was ever seen.
14
+
15
+ **Why a sibling artifact rather than a section inside REVIEW.md.** `--auto`'s re-review loop
16
+ rewrites REVIEW.md on every iteration, so a ledger kept inside it would not survive the next pass;
17
+ and REVIEW.md has a single writer, `gsd-code-reviewer`, which this step is not.
18
+
19
+ **Advisory — it never blocks.** Every failure path reports and steps over.
20
+
21
+ **Check results using deterministic path (not glob):**
22
+ ```bash
23
+ # PADDED must survive a DOTTED phase number, of ANY segment count. This step is dispatched from
24
+ # exactly TWO places: `execute-phase.md` (`code_review_gate`) and `code-review-fix.md`
25
+ # (`record_disposition`). Only the second validates anything -- `code-review-fix.md`'s PADDED_PHASE validator anchors
26
+ # `^[0-9]+[A-Z]?(\.[0-9]+)*$`, an unbounded `*` widened by #4568 and a letter axis widened by #4744, so it accepts `03.1`, `23.1.2` AND `12A`.
27
+ # `execute-phase.md` applies NO shape gate at all, so this fence is not mirroring an upstream
28
+ # guarantee; it IS the guarantee. (`code-review.md`'s own PADDED_PHASE validator is identical but never
29
+ # dispatches this step. It was cited here as a caller for several rounds and is not one.) And
30
+ # `printf "%02d"` cannot format one: bash prints `invalid number` and exits 1, which under
31
+ # `set -euo pipefail` aborts this step on its FIRST line -- the loudest possible failure from
32
+ # the gate that promises never to block, and it takes the whole phase's review reporting with
33
+ # it. Pad the integer part only and carry the sub-number verbatim, so 3.1 -> 03.1, 23.1.2 ->
34
+ # 23.1.2 and 3 -> 03. The segment count is deliberately NOT bounded here: the canonical
35
+ # grammar in src/phase-id.cts (`PHASE_NUMBER_TOKEN_SOURCE`, #2128) is unbounded in segments,
36
+ # and #4568 widened the one dispatcher that validates to match it on that axis, so a guard
37
+ # narrower than that dispatcher means no ledger for a phase id the dispatcher already accepted.
38
+ # On failure NO path is built and the fence refuses by name: advisory means advisory, and it
39
+ # also means never probing a path assembled out of a value we just rejected.
40
+ # VALIDATE, THEN FORMAT -- never format and fall back on failure. `printf "%02d" abc` writes
41
+ # `00` to stdout BEFORE it fails, so a `$(printf ... || printf %s ...)` fallback CONCATENATES
42
+ # the two and yields `00abc`; `08` fails the same way as invalid octal, giving `0008.1` for a
43
+ # legitimate `08.1`. Both were driven. `${PHASE_NUMBER:-}` because an UNSET input must not trip
44
+ # `set -u` in a step that promises not to abort. Those printf failures are why this block VALIDATES
45
+ # instead of formatting; the pad itself performs NO arithmetic since round 14 (see the
46
+ # `case "${#_dig}"` line below), so it has no octal hazard to guard and needs no `10#`. `10#`
47
+ # survives in this step only where it still belongs -- on the severity COUNTS, which really are
48
+ # numbers being added.
49
+ # VALIDATE THE WHOLE VALUE, then format -- and on failure build NO path at all.
50
+ # Carrying an unusable value verbatim was the first draft and it was worse than the bug it
51
+ # replaced: PHASE_NUMBER is interpolated into a file path, so `../../etc/passwd` produced
52
+ # `${PHASE_DIR}/../../etc/passwd-REVIEW.md`, where the old `printf "%02d"` had at least
53
+ # mangled it to `00`. `code-review-fix.md`'s PADDED_PHASE validator already checks `^[0-9]+[A-Z]?(\.[0-9]+)*$` against its
54
+ # own PADDED_PHASE -- the padded form, not the raw PHASE_NUMBER this step is handed -- while
55
+ # `execute-phase.md` validates nothing at all; this step has two call sites and validates for
56
+ # itself rather than trusting either. Anything else yields an EMPTY PADDED and the blocks
57
+ # below refuse to build a path from it.
58
+ # PHASE_DIR is checked for NON-EMPTINESS ONLY. Both inputs come from the caller's init query, so
59
+ # neither is raw user input; only PHASE_NUMBER has a SHAPE (`^[0-9]+[A-Z]?(\.[0-9]+)*$`) to check
60
+ # against. A filesystem path admits `..` and symlinked parents alike, so a shape
61
+ # check here rejects working setups and proves nothing. Residual: PHASE_DIR may itself be a symlink
62
+ # and the ledger is written through it -- left alone, and not a security boundary.
63
+ _pd="${PHASE_DIR:-}"
64
+ _pn="${PHASE_NUMBER:-}"
65
+ _ok=1
66
+ [ -n "$_pd" ] || _ok=0
67
+ case "$_pn" in
68
+ ''|*[!0-9.A-Z]*) _ok=0 ;; # empty, or any character outside [0-9.A-Z] -- this is the traversal fence
69
+ .*|*.) _ok=0 ;; # leading or trailing dot
70
+ *..*) _ok=0 ;; # EMPTY SEGMENT. The three arms plus the letter-axis block below
71
+ # accept exactly digits[LETTER](.digits)* -- byte-congruent with the
72
+ # callers' ^[0-9]+[A-Z]?(\.[0-9]+)*$ -- rather than merely wider than
73
+ # the retired `*.*.*` arity bound, which masked `1..2` by accident.
74
+ esac
75
+ # THE LETTER AXIS. The canonical grammar (src/phase-id.cts) is digits, an OPTIONAL single uppercase
76
+ # letter, then dotted digit segments -- `12A`, `3A`, `23A.1.2`. #4744 (#4660) widened the six
77
+ # shell/markdown mirrors to it after this branch was cut, and its lint ratchet then flagged this
78
+ # step as the one letterless mirror left. The character class above admits the letter; these
79
+ # arms pin WHERE it may sit -- only as the last character of the integer part, at most once --
80
+ # so `23a`, `A23`, `2A3`, `23AB` and `23.1A` are all refused.
81
+ if [ "$_ok" = "1" ]; then
82
+ _int="${_pn%%.*}"
83
+ case "$_pn" in *.*) _sub=".${_pn#*.}" ;; *) _sub="" ;; esac
84
+ _let="${_int##*[0-9]}" # what trails the last digit: '' or the letter
85
+ _dig="${_int%"$_let"}"
86
+ case "$_dig" in ''|*[!0-9]*) _ok=0 ;; esac # the integer part must be digits first
87
+ case "$_let" in ''|[A-Z]) ;; *) _ok=0 ;; esac # at most ONE letter, uppercase
88
+ case "$_sub" in *[!0-9.]*) _ok=0 ;; esac # no letter in any later segment
89
+ fi
90
+ # LENGTH-BOUND EACH COMPONENT SEPARATELY -- and the REASON changed at round 14, so read this rather
91
+ # than inherit it. It used to be integer overflow: the pad ran `$((10#$_int))`, bash integers wrap at
92
+ # 2^64, and a 54-digit value yielded -7908320945662590977 SILENTLY as the padded phase. The pad is a
93
+ # string pad now and converts nothing, so that overflow is unreachable and its rationale is dead.
94
+ # WHAT THE BOUND STILL DOES, stated narrowly because the obvious wider claim is FALSE: it bounds each
95
+ # SEGMENT, and NOTHING here bounds the COMPOSITE. Every segment is joined into ONE filename
96
+ # component and depth is unbounded, so a per-segment bound does not enforce a filename limit --
97
+ # driven at round 14: thirty 8-digit segments yield a 278-character PADDED and a 294-character name
98
+ # against a NAME_MAX of 255. That is a real residual of this validator, it predates the pad change,
99
+ # and it is named here rather than papered over with a filesystem rationale the bound does not
100
+ # deliver. The bound belongs on the INTEGER PART: applied to the whole value it rejected
101
+ # `12345678.1`, whose integer part is a legal 8 digits, while accepting `1.123456` -- an accidental
102
+ # bound on the composite that was both too strict and too loose. Every later segment is bounded too,
103
+ # on the same narrow reading.
104
+ # THE LOOP IS THE POINT, and it is what makes the heading above TRUE. The earlier form bounded
105
+ # `${_pn#*.}` -- the WHOLE tail after the first dot -- which is one component only while the id
106
+ # has at most two. Once N-segment ids are accepted (see the shape arms), that form rejects
107
+ # `1.1234567.1`, whose every component is a legal 7-or-fewer digits, purely because the tail
108
+ # measures 9 characters. That is the composite bound this comment already called "too strict",
109
+ # surviving one level up. Walk the segments instead, so the rule is per-component in fact and
110
+ # not only in the heading. The loop terminates on any string the shape arms admit: each pass
111
+ # strips a leading `<seg>.`, and the no-dot pass clears $_rest.
112
+ if [ "$_ok" = "1" ]; then
113
+ _rest="$_pn"
114
+ while [ -n "$_rest" ]; do
115
+ case "$_rest" in
116
+ *.*) _seg="${_rest%%.*}"; _rest="${_rest#*.}" ;;
117
+ *) _seg="$_rest"; _rest="" ;;
118
+ esac
119
+ # The bound is on the DIGITS: a letter suffix is one character the digit bound has no
120
+ # stake in, so `12345678A` is within it exactly as `12345678` is.
121
+ case "${_seg%[A-Z]}" in ?????????*) _ok=0 ;; esac
122
+ done
123
+ fi
124
+ if [ "$_ok" = "1" ]; then
125
+ # padStart(2,'0'), EXACTLY, and as a STRING -- the canonical normalizer left-pads the digit run to a
126
+ # MINIMUM of two and otherwise preserves it, so arithmetic is the wrong tool. `printf "%02d"
127
+ # "$((10#$_dig))"` agreed on `8`/`08`/`09` and silently DISAGREED on every longer leading-zero run:
128
+ # `008` -> `08`, `0008A` -> `08A`, resolving a REVIEW.md path init never writes. Driven at round 14
129
+ # by the property that asserts agreement with `normalizePhaseName` over generated ids; the 13-shape
130
+ # matrix that preceded it sampled no run longer than two and could not see it. Dropping the
131
+ # arithmetic also RETIRES the octal hazard `10#` existed to work around, rather than guarding it.
132
+ # The letter and any dot segments ride along verbatim, as the canonical padder does: `3A` -> `03A`.
133
+ case "${#_dig}" in 1) PADDED="0${_dig}${_let}${_sub}" ;; *) PADDED="${_dig}${_let}${_sub}" ;; esac
134
+ else
135
+ PADDED=""
136
+ fi
137
+ # REFUSE BEFORE BUILDING ANY PATH. An unusable input yields an empty PADDED above, and the
138
+ # earlier placement -- after the assignments -- meant a rejected value still had
139
+ # `${_pd}/-REVIEW.md` assembled and stat'ed before the refusal fired. Nothing is constructed
140
+ # from a value we have already rejected.
141
+ if [ -z "$PADDED" ]; then
142
+ echo "Code review reporting skipped (unusable phase number or directory: '${PHASE_NUMBER:-}')"
143
+ return 0 2>/dev/null || exit 0
144
+ fi
145
+ REVIEW_FILE="${_pd}/${PADDED}-REVIEW.md"
146
+ DISPOSITION_FILE="${_pd}/${PADDED}-REVIEW-DISPOSITION.md"
147
+ # Extract ONLY the leading frontmatter block: `sed -n '/^---$/,/^---$/p'` re-opens its range
148
+ # on a body `---` and runs to EOF, which leaks body lines into the scan. That leak is benign
149
+ # for a key the frontmatter always carries (the first match still wins) but NOT for an
150
+ # optional one — a review with no `findings:` block and a body `total:` line would otherwise
151
+ # report the body's number as the count. Stop at the closing delimiter instead, and strip CR
152
+ # first so a CRLF-authored review neither breaks the delimiter match nor injects a carriage
153
+ # return into the message below (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP).
154
+ # Buffered, and emitted only if the CLOSING delimiter was actually seen: an unterminated
155
+ # frontmatter block would otherwise run to EOF and hand the whole review body to the reads below,
156
+ # defeating the scoping entirely.
157
+ # Guarded and `|| true`: this step is advisory, so a REVIEW.md that is missing, a directory, or
158
+ # otherwise unreadable must leave the counts empty and let execution continue — never abort the
159
+ # step under `set -e`/`pipefail`.
160
+ # REVIEW_READ records that the file was actually OPENED, separately from what it yielded. An
161
+ # absent or unreadable review and a present-but-unparseable one both leave every value below
162
+ # empty, and the reporting arm used to treat the two identically -- silence -- so a REVIEW.md
163
+ # with three criticals and an unterminated frontmatter read exactly like a clean review. A
164
+ # malformed report must not read as a clean one; the arm below tells them apart on this flag.
165
+ REVIEW_FM=""
166
+ REVIEW_READ=0
167
+ if [ -f "$REVIEW_FILE" ] && [ -r "$REVIEW_FILE" ]; then
168
+ REVIEW_READ=1
169
+ REVIEW_FM=$(tr -d '\r' < "$REVIEW_FILE" 2>/dev/null | LC_ALL=C awk 'NR==1{if($0!="---") exit; next} /^---$/{closed=1; exit} {buf = buf $0 "\n"} END{if (closed) printf "%s", buf}' || true)
170
+ fi
171
+ # `|| true` on every read: under `pipefail` a non-matching `grep` exits 1, and an assignment
172
+ # whose command substitution fails aborts the step under `set -e`. An advisory gate must survive
173
+ # a REVIEW.md with no frontmatter at all.
174
+ # STATUS TAKES THE SAME PARSER AS THE COUNTS, and it is the read where truncation costs most. Under
175
+ # `cut -d: -f2` the valid YAML scalar `status: clean:junk` arrived as the bare `clean` -- so an
176
+ # unusable status SILENTLY took the clean arm, suppressing both the report and the ledger. `-f2-`
177
+ # keeps the whole scalar, `clean:junk` matches no arm, and the step reports. Found by the round's
178
+ # fourth adversarial pass as a sibling of the count-parser class, in the same file.
179
+ REVIEW_STATUS=$(echo "$REVIEW_FM" | LC_ALL=C grep -m1 "^status:" | cut -d: -f2- | LC_ALL=C sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' || true)
180
+ # The counts belong to the `findings:` MAPPING, not merely to the frontmatter, and the scoping now
181
+ # goes all the way there. `^[[:space:]]*total:` matches any indented key anywhere in the block, so
182
+ # a top-level key later named `total:`, `info:` or `critical:` was picked up ahead of the nested
183
+ # one — the extensive comment above is about scoping the frontmatter, and the scoping stopped one
184
+ # level short of the mapping the values actually live in. `status:` was never exposed: it is
185
+ # anchored to column 0 because it IS top-level.
186
+ # The awk selects the `findings:` block and stops at the next column-0 key, so the reads below can
187
+ # only see keys nested under it. Block 2 derives REVIEW_TOTAL through the same filter.
188
+ # `blocker:` is the documented tier-equivalent of `critical:` (gsd-code-reviewer.md § "Label
189
+ # equivalence") — accept either, exactly as code-review.md's present_results already does.
190
+ REVIEW_FINDINGS_FM=$(echo "$REVIEW_FM" | LC_ALL=C awk '/^findings:[[:space:]]*$/{f=1; next} f&&/^[^[:space:]]/{exit} f' || true)
191
+ REVIEW_CRITICAL=$(echo "$REVIEW_FINDINGS_FM" | LC_ALL=C grep -E -m1 "^[[:space:]]*(critical|blocker):" | cut -d: -f2- | LC_ALL=C sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' || true)
192
+ REVIEW_WARNING=$(echo "$REVIEW_FINDINGS_FM" | LC_ALL=C grep -E -m1 "^[[:space:]]*warning:" | cut -d: -f2- | LC_ALL=C sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' || true)
193
+ REVIEW_INFO=$(echo "$REVIEW_FINDINGS_FM" | LC_ALL=C grep -E -m1 "^[[:space:]]*info:" | cut -d: -f2- | LC_ALL=C sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' || true)
194
+ REVIEW_TOTAL=$(echo "$REVIEW_FINDINGS_FM" | LC_ALL=C grep -E -m1 "^[[:space:]]*total:" | cut -d: -f2- | LC_ALL=C sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' || true)
195
+ # ONE PARSER FOR THE WHOLE STEP. These reads used `cut -d: -f2 | tr -d ' '`, which repairs a
196
+ # malformed scalar into a number twice over: `tr -d` deletes INTERNAL spaces (`1 0` -> `10`) and
197
+ # `-f2` keeps only the SECOND FIELD (`1: junk` -> `1`). Block 2 was tightened first, which left the
198
+ # two fences disagreeing about the same bytes -- driven: `critical: 1 0` made this block print
199
+ # `10 findings -- 10 critical` while the ledger recorded three rows and no shortfall. A console line
200
+ # and a ledger contradicting each other is the exact confusion this PR exists to remove, so the fix
201
+ # is one parser rather than a disclosed divergence. `-f2-` keeps the whole scalar; only the ends are
202
+ # trimmed. A repaired number is not a number.
203
+ # LC_ALL=C ON EVERY `grep`/`sed` IN THESE READS, and it is load-bearing rather than cosmetic: the
204
+ # POSIX classes are LOCALE-DEFINED, and glibc's C.UTF-8 puts U+2003 (and U+1680, U+2000-U+200A,
205
+ # U+205F, U+3000) in BOTH [[:space:]] and [[:blank:]], where C and en_US.UTF-8 put them in neither.
206
+ # Unpinned, `status: clean<U+2003>` trimmed to `clean` under one locale and stayed unusable under
207
+ # another -- the same silent suppression as the `clean:junk` truncation, reachable only on some
208
+ # machines. Pinned to C the class is exactly {space, tab, NL, VT, FF, CR}, which is what the mirror
209
+ # in tests/code-review-pipeline-regression.test.cjs spells out literally, so the two agree by
210
+ # construction rather than by coincidence of locale.
211
+ # The breakdown is reportable only when ALL FOUR counts are numbers. Deciding on REVIEW_TOTAL
212
+ # alone would still emit `6 findings — critical` for a review carrying a total and nothing else.
213
+ REVIEW_COUNTS_OK=1
214
+ for _c in "$REVIEW_TOTAL" "$REVIEW_CRITICAL" "$REVIEW_WARNING" "$REVIEW_INFO"; do
215
+ # Length-bounded as well as digit-only: bash integers wrap at 2^64, so a 20-digit count
216
+ # arrives at the sum below as 0 and an inconsistent breakdown passes. No real review
217
+ # reports nine digits of findings.
218
+ case "$_c" in ''|*[!0-9]*) REVIEW_COUNTS_OK=0 ;; ?????????*) REVIEW_COUNTS_OK=0 ;; esac
219
+ done
220
+ # Numeric is necessary and not sufficient. `total: 0` beside `critical: 1` is four valid numbers
221
+ # that render the self-contradicting line `0 findings — 1 critical, 0 warning, 0 info`. An
222
+ # inconsistent breakdown is unavailable for the same reason a partial one is: half-true is worse
223
+ # than withheld, and the countless form is already the documented fallback.
224
+ # `10#` on every operand: bash infers the base from a leading zero, so a review reporting
225
+ # `critical: 08` makes $(( )) fail with "value too great for base". The CONSEQUENCE stated here
226
+ # used to be "takes the whole advisory step down under `set -e`", and that is WRONG: the
227
+ # arithmetic sits inside an `if` condition, a TESTED context, where `set -e` is inert. What
228
+ # actually happens is that the consistency check is SKIPPED -- which the regression suite
229
+ # already records. Skipping it is still the wrong outcome for a check whose whole job is
230
+ # refusing a half-true breakdown, so `10#` stays; only the account of what it prevents is
231
+ # corrected. The values are already digit-only by the loop above.
232
+ if [ "$REVIEW_COUNTS_OK" = "1" ] \
233
+ && [ "$((10#$REVIEW_CRITICAL + 10#$REVIEW_WARNING + 10#$REVIEW_INFO))" -ne "$((10#$REVIEW_TOTAL))" ]; then
234
+ REVIEW_COUNTS_OK=0
235
+ fi
236
+ # EMIT — inside the fence, on every reporting arm. Until this block existed, the fence computed six
237
+ # values and printed none of them, and the prose below then asked the agent to display four of them.
238
+ # The shell exits at the closing fence and the agent sees only stdout, so those values were
239
+ # unobtainable: the message could not be rendered, and the whole block was decorative. That is the
240
+ # rule block 2 states about itself — a prose-only gate on a value no later block can see is not a
241
+ # gate — applied to the block that is this step's primary deliverable rather than only to its
242
+ # sibling. The status arm is re-derived here, not left to the reader, for the same reason.
243
+ case "$REVIEW_STATUS" in
244
+ '')
245
+ # NO STATUS is not NO REVIEW. When the file was read and yielded no status -- unterminated
246
+ # frontmatter, no frontmatter, no `status:` key, a zero-byte file -- the review is
247
+ # UNPARSEABLE, and saying nothing would make it indistinguishable from a clean one. State it,
248
+ # without the breakdown (there is none to trust) and without the --fix suggestion (nothing
249
+ # here proves there are findings to fix). An absent or unreadable review stays silent: there
250
+ # is nothing to describe, and guessing is the failure the guard above exists to prevent.
251
+ if [ "$REVIEW_READ" = "1" ]; then
252
+ echo "Code review status unparsed: REVIEW.md is present but its frontmatter has no parseable status; severity counts unavailable."
253
+ fi
254
+ ;;
255
+ clean|skipped) ;; # nothing to report; block 2 still reconciles an existing ledger
256
+ *)
257
+ if [ "$REVIEW_COUNTS_OK" = "1" ]; then
258
+ echo "Code review: ${REVIEW_TOTAL} findings — ${REVIEW_CRITICAL} critical, ${REVIEW_WARNING} warning, ${REVIEW_INFO} info."
259
+ else
260
+ # A REVIEW.md written without a `findings:` block has no counts to report, and any count that
261
+ # is empty, non-numeric, over-long or inconsistent makes the whole breakdown unavailable
262
+ # rather than half-filled. Half-true is worse than withheld.
263
+ echo "Code review found issues."
264
+ fi
265
+ echo "Consider running: /gsd:code-review ${PHASE_NUMBER:-} --fix"
266
+ ;;
267
+ esac
268
+ ```
269
+
270
+ **Display that block's stdout verbatim.** It prints the severity breakdown when all four counts are
271
+ numeric and mutually consistent, and the countless form otherwise; on a clean, skipped or absent
272
+ review it prints nothing and there is nothing to display. Do not re-derive any of it — a number the
273
+ shell computed and did not print is gone once the fence closes, which is precisely the defect this
274
+ arm exists to close.
275
+
276
+ **Record a per-finding disposition.** The counts say how many findings there were, not what
277
+ happened to any of them. On the same condition as the message above — REVIEW_STATUS not "clean",
278
+ not "skipped" and not empty — write `${DISPOSITION_FILE}`: one row per finding ID, defaulting to
279
+ `open`, reconciling `fixed`/`skipped` from REVIEW-FIX.md and preserving any disposition already
280
+ recorded, its stated reason included. It is a sibling artifact because `--auto` rewrites
281
+ REVIEW.md every iteration and `gsd-code-reviewer` is its single writer. Advisory like the rest of
282
+ the step — never blocks:
283
+
284
+ ## Design notes for the embedded record-builder
285
+
286
+ These notes document the `node -e` script in the fence below. They live here rather than
287
+ as comments inside that script because the script is passed to `node -e` as a single
288
+ command-line argument, and Windows caps a command line at 32,767 characters
289
+ (`CreateProcess`); with the commentary inline the argument reached 33,353 characters and
290
+ the step failed to launch on Windows with `ENAMETOOLONG`. Each note names the line it
291
+ precedes, so the pairing survives the move.
292
+
293
+ **Before `(function main() {`**
294
+
295
+ EVERYTHING BELOW RUNS INSIDE main() AND LEAVES BY return, NEVER an explicit exit call. The script
296
+ prints its one-line verdict and then ends; with an explicit exit directly after console.log,
297
+ the exit can pre-empt the write when stdout is a pipe or socket (Node documents those writes
298
+ as asynchronous on POSIX), and the caller then sees an exit 0 with NO verdict line. A
299
+ hardening against that documented hazard, not a reproduced defect: the 'unchanged' branch was
300
+ the only one that exited explicitly, and the empty stdout that first pointed at it turned out to
301
+ be a reviewing sandbox's own. A function that returns lets the event loop drain stdout before
302
+ the process ends. Same exit status either way.
303
+
304
+ **Before `if (fs.existsSync(process.env.DISPOSITION_FILE) && !fs.lstatSync(process.env.DISPOSITION_FILE).isFile()) {`**
305
+
306
+ AN EXISTING LEDGER THAT IS NOT A REGULAR FILE IS NOT A LEDGER. Checked FIRST, before any read
307
+ or write of that path, and the ordering is the fix rather than a tidy-up:
308
+ * writeFileSync FOLLOWS a symlink, so a planted link replaced the contents of whatever it
309
+ pointed at -- outside the phase directory, link left intact so nothing looked wrong;
310
+ * a FIFO at that path made readFileSync BLOCK FOREVER, which is the one behaviour a gate
311
+ documented as advisory and non-blocking must never have;
312
+ * and the unchanged-run fast path read the file before the check, so a symlink whose
313
+ target already matched slipped through reporting 'unchanged'.
314
+ All three driven. lstatSync does not follow the link, which is why it is the right call.
315
+ NAMED RESIDUAL, not silently accepted: this is a check-then-write, so a symlink planted
316
+ between the lstat and the write still wins. Node exposes no portable O_NOFOLLOW write, and
317
+ an attacker who can write into the phase directory mid-run already has what the check would
318
+ protect. It narrows a real accident; it is not a security boundary, and the docs do not
319
+ claim one. A hard link likewise passes isFile() by construction.
320
+
321
+ **Before `let fence = null;`**
322
+
323
+ The OPEN fence's marker is remembered, not just the fact of being fenced. A bare toggle
324
+ treats every fence marker as interchangeable, so a ~~~ line inside a ` ` ` block CLOSES it
325
+ and the block's real close REOPENS one — which silently swaps a fenced example for the
326
+ real findings around it. Driven: a review quoting ~~~ inside a fenced example recorded
327
+ the EXAMPLE's id and dropped the real finding entirely. Per CommonMark, a fence closes
328
+ only on the same character, at least as long as the one that opened it.
329
+
330
+ **Before `const SECTION_SEV = [[/^##\s+Critical Issues\s*$/, 'critical'], [/^##\s+Warnings\s*$/, 'warning'], [/^##\s+Info\s*$/, 'info']];`**
331
+
332
+ SEVERITY COMES FROM THE SECTION FIRST, the recorded ledger value second, the id prefix
333
+ third (the full precedence is at sev(), below the identity check). The section heading is the
334
+ reviewer's OWN statement of a finding's severity -- gsd-code-reviewer.md emits findings under
335
+ '## Critical Issues' / '## Warnings' / '## Info' -- and this walker already visits every line,
336
+ so the signal was in hand and discarded. Deriving from the prefix alone means a reviewer who
337
+ mis-numbers a Critical as WR-04 while filing it under '## Critical Issues' gets a ledger row
338
+ reading 'warning', which then disagrees with the review it summarizes AND with the frontmatter
339
+ count line block 1 prints from findings.critical. The Severity column is the whole basis for
340
+ triaging the ledger, so it has to agree with the document it describes.
341
+ Matched WHOLE, exactly as the fix-report sections are: a prefix match would let a heading like
342
+ '## Critical Issues Verification' re-tier everything under it.
343
+
344
+ **Before `const declaredTotal = /^[0-9]+$/.test(process.env.REVIEW_TOTAL || '') ? Number(process.env.REVIEW_TOTAL) : null;`**
345
+
346
+ A review that reports nothing still has to reconcile an EXISTING ledger: its decided rows
347
+ and its untriaged rows are BOTH carried, marked. Exiting here would freeze a stale ledger
348
+ showing findings as open that the review no longer reports.
349
+ A fix report with no ledger is also something to record: a converged '--auto' run has neither,
350
+ and exiting here recorded nothing for a fully fixed phase.
351
+ A review that reports findings NONE of which this parser understood is also something to
352
+ record, and it is the case with the least evidence anywhere else. The shortfall is derived
353
+ HERE, above the guard, rather than at its old site beside the render: order is final from
354
+ the heading walk above and never grows again, so the value is the same either way -- but at
355
+ the old site it was computed AFTER this return had already fired, so it could not reach the
356
+ one exit that discards it. Partial shortfalls (some findings parsed, some not) always
357
+ reported, which is exactly why the total one read as covered.
358
+
359
+ **Before `const prior = new Map();`**
360
+
361
+ Prior rows: keep the disposition AND its source cell — the source is where a human writes
362
+ the reason a finding was deferred, and rewriting it would discard the very thing the
363
+ 'set deferred by hand, with the reason' instruction asks for. The Source cell is the LAST
364
+ column, so it is captured through to the end of the line, less an optional trailing pipe:
365
+ a bare | inside it is prose, not a column break. The previous capture admitted a pipe only
366
+ when escaped, and the whole-line match then FAILED on a bare one -- a human who wrote
367
+ 'waiting on team A | team B' as a deferral reason had the row not match at all, the finding
368
+ reset to open, and the reason destroyed: a triaged Critical rendered indistinguishable from
369
+ one never seen, off an ordinary typo in the one field this ledger asks a human to hand-edit.
370
+ The render below escapes a bare pipe on the next write, so the file converges to the escaped
371
+ form either way. The trailing pipe is optional so a hand-mangled row loses no decision.
372
+
373
+ **Before `const SEV_VOCAB = ['critical', 'warning', 'info'];`**
374
+
375
+ SEVERITY, READ BACK. The ledger has always WRITTEN a severity for every row -- in the table's
376
+ Severity cell and in the frontmatter's 'severity:' key -- and until this map existed nothing
377
+ read either back: the row regex discarded the cell as [^|]*, the frontmatter walk collected
378
+ only titles, and a CARRIED row was rebuilt through sev() from the id prefix, because
379
+ sectionSev holds only findings the CURRENT review reports. So a WR-04 the reviewer filed under
380
+ '## Critical Issues' was recorded 'critical', a human deferred it, and the next run -- the
381
+ review no longer reporting it -- silently re-recorded it 'warning'. The one artifact whose
382
+ purpose is remembering a finding's severity lost it on the second run, in the unsafe
383
+ direction. Driven by executing the shipped script twice (round 11).
384
+ The table cell is read first (it is the human-facing surface, and the one the disposition
385
+ already comes from); the frontmatter key is the fallback for a hand-mangled cell. Both are
386
+ ENUM-validated -- a value outside critical|warning|info is not a severity and is ignored, so
387
+ the row falls through to inference rather than carrying garbage (ADR-227, the same rule the
388
+ disposition column takes).
389
+
390
+ **Before `const m = l.match(/^\|\s*((?:CR|BL|WR|IN)-\d+)\s*\|\s*([^|]*?)\s*\|\s*(open|fixed|skipped|deferred)\s*\|\s*(.*?)\s*\|?\s*$/);`**
391
+
392
+ The disposition column is an ENUM, not 'any lowercase token'. ADR-227 requires a trust
393
+ boundary to validate semantic SHAPE, not merely type, and to coerce a failure to the
394
+ contract's safe default -- and this ledger is a trust boundary by construction, because
395
+ the rendered instruction tells a human to hand-edit it. Under the old ([a-z]+) capture a
396
+ single transposed character ('opne') was stored as a decision: it is not 'open', so it
397
+ beat the default, was excluded from the open: headline count, and was carried forward
398
+ forever. One typo and the ledger reported a phase fully triaged.
399
+ Note the asymmetry that made this a correctness bug rather than a style point: a typo
400
+ OUTSIDE [a-z] ('Deferred') already failed to match, lost the decision and reset the row
401
+ to open -- safe. A typo INSIDE [a-z] was unsafe. The parser failed open in the one
402
+ direction that matters. A row that does not match now yields no prior entry, so the row
403
+ falls back to 'open' -- the safe default, by the same path the capital-D case took.
404
+ The Severity cell is CAPTURED, not skipped: it is the value the carry-forward below has to
405
+ preserve, and skipping it (the previous [^|]*) is how a carried row lost its tier.
406
+
407
+ **Before `if (m) {`**
408
+
409
+ Strip the carried marker before storing: it is rendered from the carried flag, so
410
+ leaving it on the stored value would re-append it every run — the cell grows without
411
+ bound AND the file changes on every run, defeating the unchanged-run check below.
412
+ Strip AT MOST ONE trailing marker, unconditionally. Storing the cell verbatim looked
413
+ like the way to stop the strip eating human text, and it introduced a worse defect:
414
+ once the generated marker is stored it can never leave, so a carried finding that
415
+ REAPPEARS in a later review still renders 'not in the current review' -- a ledger that
416
+ is now factually wrong about its own contents. The residual ambiguity is irreducible
417
+ (a reason ending in exactly that phrase is indistinguishable from the marker) and it
418
+ costs nothing real: on a carried row the render puts the phrase straight back, and on a
419
+ current row the phrase was self-contradictory to begin with. The unbounded quantifier is
420
+ what had to go, not the strip itself.
421
+
422
+ **Before `const sameTitle = (a, b) => String(a === undefined ? '' : a).replace(/\s+/g, ' ').trim()`**
423
+
424
+ TITLE COMPARISON, and its FALSE-POSITIVE mode, which was previously unacknowledged.
425
+ The strict instinct is right -- ids are reused across re-reviews, so a stale REVIEW-FIX.md
426
+ must not mark a brand-new CR-01 as already fixed -- but gsd-code-fixer.md writes
427
+ '### {finding_id}: {title}' under no contract that the title is copied byte-for-byte from
428
+ REVIEW.md. A fixer that REFLOWS a long title produced a spurious stale note, left a
429
+ genuinely-fixed row 'open', and told the reader the fix report named a different finding.
430
+ Runs of whitespace are collapsed because re-spacing carries no information. The BOUND, stated
431
+ because it is easy to over-read this: a title WRAPPED across lines is NOT reconciled. A '###'
432
+ heading is one line by definition, so the continuation is a separate paragraph the heading
433
+ parser correctly never captures, and collapsing whitespace cannot reach across that boundary.
434
+ Not widened -- absorbing whatever follows a heading into the title would swallow arbitrary
435
+ prose and make this very check meaningless. Case changes and truncation stay strict too --
436
+ they are the shapes a genuinely different finding actually takes, and widening to them would
437
+ trade this false positive for the silent false NEGATIVE the strict match exists to prevent.
438
+ Residual, stated: a fixer that re-cases or truncates still produces a spurious note. That is
439
+ the safe direction (a visible note, not a silent wrong 'fixed'), and the note's wording below
440
+ no longer asserts which of the two it is.
441
+
442
+ **Before `if (h.id && sect && !applied.has(h.id)) {`**
443
+
444
+ First occurrence wins, so an id listed under BOTH sections is not decided by row order.
445
+ And the fix report must name the SAME finding: ids are reused across re-reviews, so a
446
+ stale REVIEW-FIX.md would otherwise mark a brand-new CR-01 as already fixed.
447
+ A title mismatch is the STALE-report case and must not pass silently: the id is
448
+ reused, the finding is not, and a reader who sees the row stay 'open' has no way to
449
+ tell that from 'the fix report never mentioned it'. Record it and say so below.
450
+
451
+ **Before `const sev = (id) => sectionSev.get(id) || (priorSev.has(id) && sameFinding(id) ? priorSev.get(id) : prefixSev(id));`**
452
+
453
+ SEVERITY PRECEDENCE: the current review's SECTION (the reviewer's own statement, this run),
454
+ then the severity this ledger RECORDED (an earlier reviewer's statement, persisted), then the
455
+ id PREFIX (an inference). A recorded value is inherited only while the id still names the
456
+ SAME finding -- the identity rule the disposition already obeys -- so a reused id starts from
457
+ its own review's section or its prefix, never from the finding it replaced. A carried row is
458
+ absent from the current review, so sameFinding() is true for it by construction and its
459
+ recorded severity is what it keeps. Defined here, below the identity check, because it
460
+ depends on it.
461
+
462
+ **Before `const carriedIds = [];`**
463
+
464
+ A prior finding the current review no longer reports is CARRIED, never dropped -- and that
465
+ now holds for UNTRIAGED rows too, which is the correction. Carrying only decided rows meant
466
+ an untriaged row for a dropped or renumbered finding disappeared without trace, and combined
467
+ with the reconciliation gap that left EVERY row untriaged, a re-review silently deleted the
468
+ whole ledger. The --auto loop rewrites REVIEW.md on every iteration, so it does not retain it
469
+ either: run 1 records CR-01 open, the re-review renumbers it to CR-02, and run 2's ledger
470
+ contains neither. That is #3829's complaint verbatim -- 'no trace of what happened to them' --
471
+ reproduced by the artifact built to prevent it, and 'nothing was decided about it' is exactly
472
+ the state #3829 says must leave a trace.
473
+ The carried marker is what keeps this honest rather than merely additive: the row does not
474
+ claim the finding is live, it records that it was seen and never triaged. Stated cost, since
475
+ it is real: a RENUMBERED finding appears twice until someone triages the old row, and a
476
+ carried untriaged row persists across runs until decided. Both are bounded by the phase's own
477
+ findings, both are legible from the marker, and both are strictly better than a silent delete.
478
+ Prior rows UNION ids a fix report decided that the review no longer reports: a decision the
479
+ ledger cannot render is a decision lost. Precedence matches row() -- applied beats recorded.
480
+
481
+ **Before `const reusedNote = reused.length ? ' (' + reused.length + ' recorded decision(s) DROPPED -- the id now names a different finding, so the decision no longer has a row: ' + reused.join(', ') + ')' : '';`**
482
+
483
+ Surfaced, not thrown: the gate is advisory. But a fix report naming a finding whose title
484
+ no longer matches is the one case where 'open' understates what is known, so it is stated.
485
+ The wording no longer ASSERTS a stale report. Both causes reach here -- a genuinely different
486
+ finding under a reused id, and a fixer that re-titled the same one -- and the step cannot tell
487
+ them apart, so it reports the observation rather than a conclusion it has not earned.
488
+ On the console too, for a reader who never opens the ledger.
489
+
490
+ **Before `if (rows.length === 0 && !unparsed && !fs.existsSync(process.env.DISPOSITION_FILE)) return;`**
491
+
492
+ RECONCILE THE TWO PARSERS. The counts come from REVIEW.md's frontmatter; the rows come from
493
+ heading matches against a CLOSED CR|BL|WR|IN alternation. A finding the heading parser cannot
494
+ match -- a fifth prefix, a missing ': ' separator, a '#### ' heading -- contributed no row, no
495
+ note and no diagnostic, and the ledger then declared 'open: 3 of 3' over a set strictly
496
+ smaller than the console line reported one paragraph earlier. Two findings recorded nowhere,
497
+ and neither artifact said so.
498
+ The earlier argument for the closed alternation -- that an unlisted prefix produces no row
499
+ rather than a MIS-CLASSIFIED one -- is the wrong trade under this repo's own fail-safe rule:
500
+ a dropped finding is demoted below every finding that parsed, and an unparseable finding is
501
+ precisely the one a human most needs to see. Surfaced, not thrown, exactly as the stale
502
+ fix-report case above is: the gate stays advisory and states the shortfall.
503
+ The !unparsed conjunct here is the SECOND of the two exits that discarded the shortfall, and
504
+ it is not redundant with the one above: that guard keys on order and stands down when a fix
505
+ report exists, so a run with a fix report and no parseable finding reaches THIS line with
506
+ rows.length 0. Both exits now decline to fire while a shortfall is outstanding, and the
507
+ result is a zero-row ledger carrying an unparsed key -- an honest record that the review
508
+ declared findings and none of them were understood, which is strictly better than the file
509
+ not existing. A genuinely clean review is untouched either way: a declared total of 0 is not
510
+ greater than order.length, so unparsed is 0 and both returns still fire.
511
+
512
+ **Before `const escapePipes = (t) => t.replace(/\\.|\|/g, (m) => (m === '|' ? '\\|' : m));`**
513
+
514
+ A bare | in a Source cell is escaped on render so the table stays a table. Scanned as PAIRS,
515
+ not by the preceding character: an escaped pair (backslash + anything) is kept verbatim and only
516
+ a pipe outside one is escaped. The previous form, /(^|[^\\])\|/g, CONSUMED the character before
517
+ the pipe, so adjacent bare pipes were escaped one per run (A||B -> A\||B -> A\|\|B, a third run
518
+ to converge) and an escaped backslash before a pipe (A\\|B) hid the pipe behind the wrong
519
+ parity and left it bare. Found by the round-3 adversarial pass, not by the property -- whose
520
+ generator then emitted at most one bare pipe, the one case the old form got right; it now
521
+ reaches adjacent pipes and both backslash parities, against an independent parity oracle.
522
+
523
+ **Before `fs.writeFileSync(process.env.DISPOSITION_FILE, render(new Date().toISOString()));`**
524
+
525
+ READ-MODIFY-WRITE, NO LOCK. The ledger is rendered whole from a read taken above, and nothing
526
+ serializes two writers: this step has two dispatchers (execute-phase's gate and
527
+ code-review-fix's record_disposition) plus a human the legend invites to hand-edit, so a
528
+ lost update is a real window, not a theoretical one. Same shape as #3780 (WINDOWS.md
529
+ append under parallel executors), which #4681 closed with a cross-process lock in
530
+ src/broken-windows.cts. NOT taken here: this is a shell-embedded script with no build
531
+ dependency on the compiled tree, and adopting the lock module is its own change. Residual,
532
+ stated in docs/features/code-review-pipeline.md; not reproduced as a lost update.
533
+
534
+ ```bash
535
+ # Each fenced block runs in a FRESH shell, so block 1's PADDED/REVIEW_FILE/DISPOSITION_FILE are NOT
536
+ # live here — re-derive them from the two inputs this step consumes (`PHASE_DIR`, `PHASE_NUMBER`).
537
+ # Inheriting them is not merely stale, it is EMPTY, and the failure is silent rather than loud:
538
+ # the embedded script throws on reading the empty review path, the trailing `|| echo` swallows it
539
+ # as a non-blocking skip, and no ledger is written at all. The shim preamble below is re-emitted
540
+ # for the same reason, and these three belong beside it.
541
+ # PADDED must survive a DOTTED phase number, of ANY segment count. This step is dispatched from
542
+ # exactly TWO places: `execute-phase.md` (`code_review_gate`) and `code-review-fix.md`
543
+ # (`record_disposition`). Only the second validates anything -- `code-review-fix.md`'s PADDED_PHASE validator anchors
544
+ # `^[0-9]+[A-Z]?(\.[0-9]+)*$`, an unbounded `*` widened by #4568 and a letter axis widened by #4744, so it accepts `03.1`, `23.1.2` AND `12A`.
545
+ # `execute-phase.md` applies NO shape gate at all, so this fence is not mirroring an upstream
546
+ # guarantee; it IS the guarantee. (`code-review.md`'s own PADDED_PHASE validator is identical but never
547
+ # dispatches this step. It was cited here as a caller for several rounds and is not one.) And
548
+ # `printf "%02d"` cannot format one: bash prints `invalid number` and exits 1, which under
549
+ # `set -euo pipefail` aborts this step on its FIRST line -- the loudest possible failure from
550
+ # the gate that promises never to block, and it takes the whole phase's review reporting with
551
+ # it. Pad the integer part only and carry the sub-number verbatim, so 3.1 -> 03.1, 23.1.2 ->
552
+ # 23.1.2 and 3 -> 03. The segment count is deliberately NOT bounded here: the canonical
553
+ # grammar in src/phase-id.cts (`PHASE_NUMBER_TOKEN_SOURCE`, #2128) is unbounded in segments,
554
+ # and #4568 widened the one dispatcher that validates to match it on that axis, so a guard
555
+ # narrower than that dispatcher means no ledger for a phase id the dispatcher already accepted.
556
+ # On failure NO path is built and the fence refuses by name: advisory means advisory, and it
557
+ # also means never probing a path assembled out of a value we just rejected.
558
+ # VALIDATE, THEN FORMAT -- never format and fall back on failure. `printf "%02d" abc` writes
559
+ # `00` to stdout BEFORE it fails, so a `$(printf ... || printf %s ...)` fallback CONCATENATES
560
+ # the two and yields `00abc`; `08` fails the same way as invalid octal, giving `0008.1` for a
561
+ # legitimate `08.1`. Both were driven. `${PHASE_NUMBER:-}` because an UNSET input must not trip
562
+ # `set -u` in a step that promises not to abort. Those printf failures are why this block VALIDATES
563
+ # instead of formatting; the pad itself performs NO arithmetic since round 14 (see the
564
+ # `case "${#_dig}"` line below), so it has no octal hazard to guard and needs no `10#`. `10#`
565
+ # survives in this step only where it still belongs -- on the severity COUNTS, which really are
566
+ # numbers being added.
567
+ # VALIDATE THE WHOLE VALUE, then format -- and on failure build NO path at all.
568
+ # Carrying an unusable value verbatim was the first draft and it was worse than the bug it
569
+ # replaced: PHASE_NUMBER is interpolated into a file path, so `../../etc/passwd` produced
570
+ # `${PHASE_DIR}/../../etc/passwd-REVIEW.md`, where the old `printf "%02d"` had at least
571
+ # mangled it to `00`. `code-review-fix.md`'s PADDED_PHASE validator already checks `^[0-9]+[A-Z]?(\.[0-9]+)*$` against its
572
+ # own PADDED_PHASE -- the padded form, not the raw PHASE_NUMBER this step is handed -- while
573
+ # `execute-phase.md` validates nothing at all; this step has two call sites and validates for
574
+ # itself rather than trusting either. Anything else yields an EMPTY PADDED and the blocks
575
+ # below refuse to build a path from it.
576
+ # PHASE_DIR is checked for NON-EMPTINESS ONLY. Both inputs come from the caller's init query, so
577
+ # neither is raw user input; only PHASE_NUMBER has a SHAPE (`^[0-9]+[A-Z]?(\.[0-9]+)*$`) to check
578
+ # against. A filesystem path admits `..` and symlinked parents alike, so a shape
579
+ # check here rejects working setups and proves nothing. Residual: PHASE_DIR may itself be a symlink
580
+ # and the ledger is written through it -- left alone, and not a security boundary.
581
+ _pd="${PHASE_DIR:-}"
582
+ _pn="${PHASE_NUMBER:-}"
583
+ _ok=1
584
+ [ -n "$_pd" ] || _ok=0
585
+ case "$_pn" in
586
+ ''|*[!0-9.A-Z]*) _ok=0 ;; # empty, or any character outside [0-9.A-Z] -- this is the traversal fence
587
+ .*|*.) _ok=0 ;; # leading or trailing dot
588
+ *..*) _ok=0 ;; # EMPTY SEGMENT. The three arms plus the letter-axis block below
589
+ # accept exactly digits[LETTER](.digits)* -- byte-congruent with the
590
+ # callers' ^[0-9]+[A-Z]?(\.[0-9]+)*$ -- rather than merely wider than
591
+ # the retired `*.*.*` arity bound, which masked `1..2` by accident.
592
+ esac
593
+ # THE LETTER AXIS. The canonical grammar (src/phase-id.cts) is digits, an OPTIONAL single uppercase
594
+ # letter, then dotted digit segments -- `12A`, `3A`, `23A.1.2`. #4744 (#4660) widened the six
595
+ # shell/markdown mirrors to it after this branch was cut, and its lint ratchet then flagged this
596
+ # step as the one letterless mirror left. The character class above admits the letter; these
597
+ # arms pin WHERE it may sit -- only as the last character of the integer part, at most once --
598
+ # so `23a`, `A23`, `2A3`, `23AB` and `23.1A` are all refused.
599
+ if [ "$_ok" = "1" ]; then
600
+ _int="${_pn%%.*}"
601
+ case "$_pn" in *.*) _sub=".${_pn#*.}" ;; *) _sub="" ;; esac
602
+ _let="${_int##*[0-9]}" # what trails the last digit: '' or the letter
603
+ _dig="${_int%"$_let"}"
604
+ case "$_dig" in ''|*[!0-9]*) _ok=0 ;; esac # the integer part must be digits first
605
+ case "$_let" in ''|[A-Z]) ;; *) _ok=0 ;; esac # at most ONE letter, uppercase
606
+ case "$_sub" in *[!0-9.]*) _ok=0 ;; esac # no letter in any later segment
607
+ fi
608
+ # LENGTH-BOUND EACH COMPONENT SEPARATELY -- and the REASON changed at round 14, so read this rather
609
+ # than inherit it. It used to be integer overflow: the pad ran `$((10#$_int))`, bash integers wrap at
610
+ # 2^64, and a 54-digit value yielded -7908320945662590977 SILENTLY as the padded phase. The pad is a
611
+ # string pad now and converts nothing, so that overflow is unreachable and its rationale is dead.
612
+ # WHAT THE BOUND STILL DOES, stated narrowly because the obvious wider claim is FALSE: it bounds each
613
+ # SEGMENT, and NOTHING here bounds the COMPOSITE. Every segment is joined into ONE filename
614
+ # component and depth is unbounded, so a per-segment bound does not enforce a filename limit --
615
+ # driven at round 14: thirty 8-digit segments yield a 278-character PADDED and a 294-character name
616
+ # against a NAME_MAX of 255. That is a real residual of this validator, it predates the pad change,
617
+ # and it is named here rather than papered over with a filesystem rationale the bound does not
618
+ # deliver. The bound belongs on the INTEGER PART: applied to the whole value it rejected
619
+ # `12345678.1`, whose integer part is a legal 8 digits, while accepting `1.123456` -- an accidental
620
+ # bound on the composite that was both too strict and too loose. Every later segment is bounded too,
621
+ # on the same narrow reading.
622
+ # THE LOOP IS THE POINT, and it is what makes the heading above TRUE. The earlier form bounded
623
+ # `${_pn#*.}` -- the WHOLE tail after the first dot -- which is one component only while the id
624
+ # has at most two. Once N-segment ids are accepted (see the shape arms), that form rejects
625
+ # `1.1234567.1`, whose every component is a legal 7-or-fewer digits, purely because the tail
626
+ # measures 9 characters. That is the composite bound this comment already called "too strict",
627
+ # surviving one level up. Walk the segments instead, so the rule is per-component in fact and
628
+ # not only in the heading. The loop terminates on any string the shape arms admit: each pass
629
+ # strips a leading `<seg>.`, and the no-dot pass clears $_rest.
630
+ if [ "$_ok" = "1" ]; then
631
+ _rest="$_pn"
632
+ while [ -n "$_rest" ]; do
633
+ case "$_rest" in
634
+ *.*) _seg="${_rest%%.*}"; _rest="${_rest#*.}" ;;
635
+ *) _seg="$_rest"; _rest="" ;;
636
+ esac
637
+ # The bound is on the DIGITS: a letter suffix is one character the digit bound has no
638
+ # stake in, so `12345678A` is within it exactly as `12345678` is.
639
+ case "${_seg%[A-Z]}" in ?????????*) _ok=0 ;; esac
640
+ done
641
+ fi
642
+ if [ "$_ok" = "1" ]; then
643
+ # padStart(2,'0'), EXACTLY, and as a STRING -- the canonical normalizer left-pads the digit run to a
644
+ # MINIMUM of two and otherwise preserves it, so arithmetic is the wrong tool. `printf "%02d"
645
+ # "$((10#$_dig))"` agreed on `8`/`08`/`09` and silently DISAGREED on every longer leading-zero run:
646
+ # `008` -> `08`, `0008A` -> `08A`, resolving a REVIEW.md path init never writes. Driven at round 14
647
+ # by the property that asserts agreement with `normalizePhaseName` over generated ids; the 13-shape
648
+ # matrix that preceded it sampled no run longer than two and could not see it. Dropping the
649
+ # arithmetic also RETIRES the octal hazard `10#` existed to work around, rather than guarding it.
650
+ # The letter and any dot segments ride along verbatim, as the canonical padder does: `3A` -> `03A`.
651
+ case "${#_dig}" in 1) PADDED="0${_dig}${_let}${_sub}" ;; *) PADDED="${_dig}${_let}${_sub}" ;; esac
652
+ else
653
+ PADDED=""
654
+ fi
655
+ # REFUSE BEFORE BUILDING ANY PATH. An unusable input yields an empty PADDED above, and the
656
+ # earlier placement -- after the assignments -- meant a rejected value still had
657
+ # `${_pd}/-REVIEW.md` assembled and stat'ed before the refusal fired. Nothing is constructed
658
+ # from a value we have already rejected.
659
+ if [ -z "$PADDED" ]; then
660
+ echo "Code review disposition skipped (unusable phase number or directory: '${PHASE_NUMBER:-}')"
661
+ return 0 2>/dev/null || exit 0
662
+ fi
663
+ REVIEW_FILE="${_pd}/${PADDED}-REVIEW.md"
664
+ DISPOSITION_FILE="${_pd}/${PADDED}-REVIEW-DISPOSITION.md"
665
+ # The condition stated above this block is re-derived HERE rather than left to the reader. Block 1
666
+ # computes REVIEW_STATUS and emits nothing, and its shell is gone, so nothing downstream can act on
667
+ # it: a prose-only gate on a value no later block can see is not a gate. Without this, a clean
668
+ # re-review rewrites an existing ledger it was never meant to touch.
669
+ REVIEW_STATUS=""
670
+ REVIEW_TOTAL=""
671
+ REVIEW_READ=0 # block 1's distinction, re-derived here: read-but-unparseable is not absent
672
+ if [ -f "$REVIEW_FILE" ] && [ -r "$REVIEW_FILE" ]; then
673
+ REVIEW_READ=1
674
+ _FM=$(tr -d '\r' < "$REVIEW_FILE" 2>/dev/null | LC_ALL=C awk 'NR==1{if($0!="---") exit; next} /^---$/{closed=1; exit} {buf = buf $0 "\n"} END{if (closed) printf "%s", buf}' || true)
675
+ REVIEW_STATUS=$(echo "$_FM" | LC_ALL=C grep -m1 "^status:" | cut -d: -f2- | LC_ALL=C sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' || true)
676
+ # The frontmatter total is carried into the script so the two parsers in this step can be
677
+ # RECONCILED. The counts come from the frontmatter; the rows come from `### <ID>:` heading
678
+ # matches against a closed CR|BL|WR|IN alternation. They are two independent numbers produced
679
+ # one paragraph apart, and nothing compared them: a finding the heading parser cannot match
680
+ # contributed no row, no note and no diagnostic, and the ledger then asserted `open: 3 of 3`
681
+ # over a set strictly smaller than the console line had just reported. Anchored inside the
682
+ # `findings:` mapping — see the anchoring note in block 1 — and digit-only, because a
683
+ # non-numeric total is not a number to reconcile against.
684
+ _FINDINGS_FM=$(echo "$_FM" | LC_ALL=C awk '/^findings:[[:space:]]*$/{f=1; next} f&&/^[^[:space:]]/{exit} f' || true)
685
+ # ONE PARSER FOR EVERY COUNT THIS BLOCK READS, and both halves of it are load-bearing.
686
+ # `-f2-` keeps everything AFTER the first colon: `-f2` alone takes only the SECOND FIELD, so the
687
+ # malformed `critical: 1: junk` arrives as the perfectly numeric `1`. And the ends are trimmed
688
+ # rather than `tr -d ' '`-ed, which would delete INTERNAL spaces and turn `1 0` into `10`.
689
+ # Both quirks are long-standing in the sibling reads and both were INERT here until this block
690
+ # began reconciling; each one repairs a malformed scalar into a number that then decides whether a
691
+ # shortfall is reported. An adversarial pass drove both: `critical: 1: junk` wrongly suppressed a
692
+ # real `unparsed: 2`, and a LENIENT total beside a STRICT severity was worse still -- `critical: 5 0`
693
+ # with `total: 1 0` repaired only the total, rejected the severity, skipped the contradiction check
694
+ # and INVENTED `unparsed: 7`. A field is either trustworthy or it is not; parsing one leniently and
695
+ # its sibling strictly is the shape that fabricates.
696
+ REVIEW_TOTAL=$(echo "$_FINDINGS_FM" | LC_ALL=C grep -E -m1 "^[[:space:]]*total:" | cut -d: -f2- | LC_ALL=C sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' || true)
697
+ case "$REVIEW_TOTAL" in ''|*[!0-9]*) REVIEW_TOTAL="" ;; ?????????*) REVIEW_TOTAL="" ;; esac
698
+ # ONE FIELD, ONE TRUST MODEL, ACROSS BOTH FENCES. Block 1 withholds the whole breakdown unless the
699
+ # four counts are numeric AND `critical + warning + info == total`; this fence used to bound `total`
700
+ # for digits and length only and then hand it to the `unparsed:` reconciliation, so a REVIEW.md whose
701
+ # `findings:` block is internally inconsistent (`total: 10` beside `critical: 1, warning: 1, info: 1`)
702
+ # made block 1 print the countless form -- breakdown suppressed as untrustworthy -- while this block
703
+ # still computed an `unparsed:` shortfall from that same untrusted number. It fails in the SAFE
704
+ # direction (over-reports a possible gap rather than hiding one), which is why it is not a blocker;
705
+ # it is still two trust models for one field, one fence apart, and the weaker one is downstream.
706
+ # `blocker:` is the documented tier-equivalent of `critical:` (gsd-code-reviewer.md 'Label
707
+ # equivalence') -- the same alternation block 1 reads, because a mirror that drops it would diverge
708
+ # on exactly the reviews that use it.
709
+ # TRIM THE ENDS, NEVER `tr -d ' '`, AND THE DIFFERENCE DECIDES A SUPPRESSION. `tr -d` deletes
710
+ # INTERNAL spaces too, so a malformed `critical: 1 0` would arrive as the perfectly numeric `10`.
711
+ # Every read in this step now takes the end-trim instead -- the sibling reads were moved off `tr -d`
712
+ # in the same round, so this is no longer a divergence between blocks -- and the reason it matters
713
+ # HERE is that a value which LOOKS numeric can satisfy the sum test and SUPPRESS a real
714
+ # `unparsed:` shortfall. Suppression is the new
715
+ # behaviour, so the admission test for it is strict -- an internal space survives the trim, fails the
716
+ # digit `case` below, and the shortfall is reported. Fail-safe in the only direction that matters:
717
+ # when the frontmatter is malformed we decline to suppress, rather than trusting a repaired number.
718
+ _c_crit=$(echo "$_FINDINGS_FM" | LC_ALL=C grep -E -m1 "^[[:space:]]*(critical|blocker):" | cut -d: -f2- | LC_ALL=C sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' || true)
719
+ _c_warn=$(echo "$_FINDINGS_FM" | LC_ALL=C grep -E -m1 "^[[:space:]]*warning:" | cut -d: -f2- | LC_ALL=C sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' || true)
720
+ _c_info=$(echo "$_FINDINGS_FM" | LC_ALL=C grep -E -m1 "^[[:space:]]*info:" | cut -d: -f2- | LC_ALL=C sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' || true)
721
+ # THE CHECK IS NARROWER THAN BLOCK 1'S, DELIBERATELY, AND THE DIFFERENCE IS NOT AN OVERSIGHT.
722
+ # Block 1 withholds on `REVIEW_COUNTS_OK`, which demands ALL FOUR counts be numeric -- because it
723
+ # DISPLAYS all four, and `6 findings -- critical` is the half-filled line that rule exists to
724
+ # prevent. This fence displays none of them: it uses `total` alone, to reconcile against the number
725
+ # of headings the row parser matched. So the all-four rule does not port. Applied verbatim here it
726
+ # would blank `total` on a REVIEW.md carrying `total: 5` and no severity keys -- a review whose total
727
+ # is perfectly usable -- and SILENTLY DROP an `unparsed:` shortfall this step reports correctly today.
728
+ # That trades a safe-direction over-report for a silent under-report, which is the wrong way round
729
+ # and is the exact failure class the `unparsed:` key was added to close.
730
+ # What DOES port is the CONTRADICTION: when the three severities are all present and numeric and do
731
+ # not sum to `total`, the frontmatter disagrees with itself and `total` is not a number to reconcile
732
+ # against. Block 1 already suppresses its breakdown on that input; this fence now declines to compute
733
+ # a shortfall from it. Absent counts are not a contradiction -- there is nothing to disagree.
734
+ # AN ABSENT SEVERITY STILL BOUNDS THE SUM FROM BELOW, and that is enough to prove a contradiction
735
+ # in one direction. Counts are non-negative, so a missing one can only ADD: if the severities that
736
+ # ARE present and numeric already sum to MORE than `total`, the block disagrees with itself whatever
737
+ # the missing value is. Requiring all three before comparing missed that -- driven by an adversarial
738
+ # pass: `critical: 4`, `warning: 4`, no `info:`, `total: 5` reconciled against a total the present
739
+ # counts had already refuted. So the comparison is two-armed: EQUALITY when all three are known,
740
+ # and a LOWER BOUND when they are not. `_p_sum` accumulates only the present-and-numeric ones.
741
+ _sum_ok=1; _p_sum=0
742
+ for _c in "$_c_crit" "$_c_warn" "$_c_info"; do
743
+ # Present AND numeric AND within the same length bound the total carries -- `10#` below needs
744
+ # digits, and bash integers wrap at 2^64.
745
+ case "$_c" in
746
+ ''|*[!0-9]*) _sum_ok=0 ;;
747
+ ?????????*) _sum_ok=0 ;;
748
+ *) _p_sum=$(( _p_sum + 10#$_c )) ;;
749
+ esac
750
+ done
751
+ # `10#` on every operand, for block 1's reason: bash infers the base from a leading zero, so
752
+ # `critical: 08` makes $(( )) fail with "value too great for base" and, under `set -e`, takes the
753
+ # whole advisory step down -- strictly worse than the stale count this check exists to prevent.
754
+ if [ -n "$REVIEW_TOTAL" ]; then
755
+ _t=$(( 10#$REVIEW_TOTAL ))
756
+ if [ "$_sum_ok" = "1" ]; then
757
+ # All three known: the sum must match exactly.
758
+ if [ "$_p_sum" -ne "$_t" ]; then REVIEW_TOTAL=""; fi
759
+ elif [ "$_p_sum" -gt "$_t" ]; then
760
+ # Not all known: only an OVERSHOOT is provable. An undershoot is the absent count's job.
761
+ REVIEW_TOTAL=""
762
+ fi
763
+ fi
764
+ fi
765
+ # Skip a clean/skipped/absent review ONLY when there is nothing to reconcile AT ALL. An EXISTING
766
+ # ledger is still brought up to date -- freezing it would leave findings showing open that the
767
+ # review no longer reports, and an unconditional skip would make the reconciliation path
768
+ # unreachable on exactly the run that needs it.
769
+ # A FIX REPORT IS THE SECOND REASON TO PROCEED: a direct `/gsd:code-review N --auto` writes no gate
770
+ # ledger and a converged loop leaves `status: clean`, so a fully fixed phase recorded nothing.
771
+ _fix_any=0
772
+ [ -f "${_pd}/${PADDED}-REVIEW-FIX.md" ] && _fix_any=1
773
+ # Backups count too -- a converged loop's earlier iterations live only there. An unmatched glob
774
+ # expands to the literal pattern, which `-f` rejects.
775
+ # $(printf '%s' "$PADDED") per lint-workflow-shellcheck's #4109 remedy: a bare $VAR in a `for x in`
776
+ # splits differently under bash and zsh.
777
+ for _f in "${_pd}/$(printf '%s' "$PADDED")-REVIEW-FIX.iter"*.md; do [ -f "$_f" ] && _fix_any=1; done
778
+ # The word for an empty status names WHICH empty it is, for the same reason block 1 does: a
779
+ # review that was read and could not be parsed is 'unparsed', an absent one is 'none'.
780
+ _st="${REVIEW_STATUS:-none}"; [ -z "$REVIEW_STATUS" ] && [ "$REVIEW_READ" = "1" ] && _st="unparsed"
781
+ case "$REVIEW_STATUS" in
782
+ ''|clean|skipped)
783
+ if [ ! -f "$DISPOSITION_FILE" ] && [ "$_fix_any" = "0" ]; then
784
+ echo "Code review disposition skipped (status: ${_st})"
785
+ return 0 2>/dev/null || exit 0
786
+ fi
787
+ echo "Code review status ${_st}; reconciling the fix report and any existing disposition ledger."
788
+ ;;
789
+ esac
790
+ _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; _gsd_at() { for _p; do if [ -f "$_p" ]; then GSD_TOOLS="$_p"; return 0; fi; done; return 1; }; _gsd_id_ok() { case "$("$1" runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') return 0;; *) return 1;; esac; }; _gsd_homes() { _gsd_at "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; }; if _gsd_at "${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif _gsd_homes; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; [ -n "$_G" ] && _gsd_id_ok "$_G"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and no identity-proving gsd_run is on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; GSD_IDENTITY_STATUS=unverified; _gsd_id_ok gsd_run && GSD_IDENTITY_STATUS=ok; export GSD_IDENTITY_STATUS; [ "$GSD_IDENTITY_STATUS" = ok ] || echo "WARNING: \"$GSD_TOOLS\" did not prove it is @opengsd/gsd-core - it is either a different package or an @opengsd/gsd-core older than the runtime-identity verb. See docs/how-to/diagnose-a-foreign-gsd-tools.md" >&2; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
791
+ # Built before the command for READABILITY, not as a fix. ShellCheck's SC2097/SC2098 here is a FALSE
792
+ # POSITIVE: prefix assignments take effect left to right (driven, bash and dash).
793
+ FIX_REPORT_FILE="${_pd}/${PADDED}-REVIEW-FIX.md"
794
+ REVIEW_FILE="${REVIEW_FILE}" DISPOSITION_FILE="${DISPOSITION_FILE}" PADDED="${PADDED}" \
795
+ REVIEW_TOTAL="${REVIEW_TOTAL}" \
796
+ FIX_REPORT_FILE="${FIX_REPORT_FILE}" node -e "
797
+ (function main() {
798
+ const fs = require('fs'), path = require('path');
799
+ const norm = (s) => s.replace(/\r\n/g, '\n');
800
+ if (fs.existsSync(process.env.DISPOSITION_FILE) && !fs.lstatSync(process.env.DISPOSITION_FILE).isFile()) {
801
+ console.log('Code review disposition skipped: ' + process.env.DISPOSITION_FILE + ' exists and is not a regular file; refusing to read or write through it.');
802
+ return;
803
+ }
804
+ // Captures the id AND the title: the title is what tells a stale fix report apart from a
805
+ // current one, because finding ids are reused across re-reviews.
806
+ const ID_RE = /^###\s+((?:CR|BL|WR|IN)-\d+)\s*:\s*(.*)\$/;
807
+ // BL- is Critical-tier-equivalent to CR- (gsd-code-reviewer.md 'Label equivalence').
808
+ const sectionSev = new Map();
809
+ // PREFIX severity -- the LAST resort, an inference from the id alone. Used only when neither
810
+ // the current review's section nor a severity this ledger already RECORDED is available; the
811
+ // precedence and the reason for it are stated at sev(), defined below once identity is known.
812
+ const prefixSev = (id) => ({ CR: 'critical', BL: 'critical', WR: 'warning' }[id.split('-')[0]] || 'info');
813
+ const headings = (text) => {
814
+ // Fenced blocks are skipped: review and fix bodies quote example findings, and a heading
815
+ // inside a fence is an illustration, not a finding.
816
+ const out = [];
817
+ let fence = null;
818
+ for (const l of norm(text).split('\n')) {
819
+ const f = l.match(/^ {0,3}(\`{3,}|~{3,})/); // >3 spaces is an indented block, not a fence
820
+ if (f) {
821
+ const ch = f[1][0], len = f[1].length;
822
+ if (!fence) { fence = { ch: ch, len: len }; out.push({ fence: true }); continue; }
823
+ // A CLOSER carries nothing but whitespace after the marker; an info string makes it
824
+ // an opener's shape, never a close.
825
+ if (ch === fence.ch && len >= fence.len && /^\s*\$/.test(l.slice(l.indexOf(f[1]) + f[1].length))) {
826
+ fence = null; out.push({ fence: true }); continue;
827
+ }
828
+ out.push({ skip: true, line: l }); continue; // a foreign marker inside a fence is content
829
+ }
830
+ if (fence) { out.push({ skip: true, line: l }); continue; }
831
+ const m = l.match(ID_RE);
832
+ out.push(m ? { id: m[1], title: m[2].trim(), line: l } : { line: l });
833
+ }
834
+ return out;
835
+ };
836
+ const order = [], title = new Map();
837
+ // An ABSENT review still has a ledger to reconcile: the step's own guard proceeds when
838
+ // one exists, and throwing here would send that run to the trailing non-blocking fallback
839
+ // with the
840
+ // ledger untouched -- the freeze the reconciliation path exists to prevent.
841
+ const reviewText = fs.existsSync(process.env.REVIEW_FILE) ? fs.readFileSync(process.env.REVIEW_FILE, 'utf-8') : '';
842
+ const SECTION_SEV = [[/^##\s+Critical Issues\s*\$/, 'critical'], [/^##\s+Warnings\s*\$/, 'warning'], [/^##\s+Info\s*\$/, 'info']];
843
+ let curSection = null;
844
+ for (const h of headings(reviewText)) {
845
+ if (h.fence || h.skip) continue;
846
+ if (h.line !== undefined && /^##\s+/.test(h.line)) {
847
+ const hit = SECTION_SEV.find(([re]) => re.test(h.line));
848
+ curSection = hit ? hit[1] : null; // an unrecognized ## section falls back to the prefix
849
+ }
850
+ if (h.id && order.indexOf(h.id) === -1) {
851
+ order.push(h.id); title.set(h.id, h.title);
852
+ if (curSection) sectionSev.set(h.id, curSection);
853
+ }
854
+ }
855
+ // THE FIX REPORTS THIS RUN MAY RECONCILE AGAINST. --auto overwrites REVIEW-FIX.md each iteration
856
+ // and the re-review drops what was fixed, so an iteration-1 fix is in NEITHER final artifact.
857
+ // Read the backups too, newest first.
858
+ const FIX_FINAL = process.env.FIX_REPORT_FILE;
859
+ const fixStem = path.basename(FIX_FINAL).slice(0, -3); // '<NN>-REVIEW-FIX'
860
+ const iterMarker = fixStem + '.iter';
861
+ // String ops, not a built RegExp: every backslash is one more thing bash rewrites first.
862
+ // ONE expression, no 'return': ShellCheck lints this fence as shell and would mark the rest
863
+ // unreachable (SC2317) against a ratchet baseline.
864
+ const iterDigits = (n) => (n.indexOf(iterMarker) === 0 && n.slice(-3) === '.md') ? n.slice(iterMarker.length, -3) : '';
865
+ const iterOf = (n) => /^[0-9]+\$/.test(iterDigits(n)) ? Number(iterDigits(n)) : null;
866
+ const fixReports = [];
867
+ if (fs.existsSync(FIX_FINAL)) fixReports.push(FIX_FINAL);
868
+ let iterFiles = [];
869
+ // Guarded: the phase directory is not guaranteed readable, and this step never aborts.
870
+ try { iterFiles = fs.readdirSync(path.dirname(FIX_FINAL)).map((n) => [iterOf(n), n]).filter((e) => e[0] !== null); } catch (e) { iterFiles = []; }
871
+ iterFiles.sort((a, b) => b[0] - a[0]);
872
+ for (const e of iterFiles) fixReports.push(path.join(path.dirname(FIX_FINAL), e[1]));
873
+ const declaredTotal = /^[0-9]+\$/.test(process.env.REVIEW_TOTAL || '') ? Number(process.env.REVIEW_TOTAL) : null;
874
+ // Against order.length -- the CURRENT review's findings -- never rows.length, which also counts
875
+ // rows carried from earlier reviews and would understate the shortfall or invent one.
876
+ const unparsed = declaredTotal !== null && declaredTotal > order.length ? declaredTotal - order.length : 0;
877
+ const unparsedNote = unparsed ? ' (' + unparsed + ' finding(s) recorded NOWHERE: the review reports ' + declaredTotal + ', but only ' + order.length + ' matched the expected heading shape \`### <CR|BL|WR|IN>-NN: <title>\`)' : '';
878
+ if (order.length === 0 && !unparsed && !fs.existsSync(process.env.DISPOSITION_FILE) && fixReports.length === 0) return;
879
+ const prior = new Map();
880
+ // TITLES, IN THE FRONTMATTER. Ids are reused across re-reviews (--auto renumbers), so an id alone
881
+ // does not identify a finding: driven, a prior 'CR-01 fixed' rendered a brand-new CR-01 'fixed'.
882
+ // Not a fifth table column -- the Source cell is already the hand-edited, pipe-escaping one.
883
+ const priorTitle = new Map();
884
+ const SEV_VOCAB = ['critical', 'warning', 'info'];
885
+ const priorSev = new Map();
886
+ // Ids whose decision could not be carried: the id now names a DIFFERENT finding. REPORTED, not
887
+ // re-homed -- rows key on the id, and two under one id is an ambiguity. The note does NOT claim the
888
+ // old row is in git: committing is gated on commit_docs. See docs/features/code-review-pipeline.md.
889
+ const reused = [];
890
+ var _fmId = null, _fmSec = null;
891
+ // Set when the ledger declares JSON scalars; without it they are bare. Load-bearing: a legacy title
892
+ // that merely LOOKED like JSON was parsed, lost its quotes, and flipped to open.
893
+ var _fmJson = false;
894
+ if (fs.existsSync(process.env.DISPOSITION_FILE)) {
895
+ for (const l of norm(fs.readFileSync(process.env.DISPOSITION_FILE, 'utf-8')).split('\n')) {
896
+ const m = l.match(/^\|\s*((?:CR|BL|WR|IN)-\d+)\s*\|\s*([^|]*?)\s*\|\s*(open|fixed|skipped|deferred)\s*\|\s*(.*?)\s*\|?\s*\$/);
897
+ if (m) {
898
+ prior.set(m[1], { d: m[3], src: m[4].replace(/\s*\(not in the current review\)\s*\$/, '') });
899
+ // The table wins over the frontmatter (set unconditionally here, only-if-absent below),
900
+ // whichever order the two appear in the file.
901
+ if (SEV_VOCAB.indexOf(m[2]) !== -1) priorSev.set(m[1], m[2]);
902
+ }
903
+ // Frontmatter is walked in the same pass, as a SECTIONED list rather than by one line shape.
904
+ if (/^titles: json\s*\$/.test(l)) { _fmJson = true; continue; }
905
+ var msec = l.match(/^(findings):\s*\$/);
906
+ if (msec) { _fmSec = msec[1]; _fmId = null; continue; }
907
+ var mi = l.match(/^ - id: ((?:CR|BL|WR|IN)-\d+)\s*\$/);
908
+ if (mi && _fmSec) { _fmId = mi[1]; continue; }
909
+ // The frontmatter's own copy of the severity -- the fallback when the table cell is unusable.
910
+ var msv = l.match(/^ severity: (critical|warning|info)\s*\$/);
911
+ if (msv && _fmId && _fmSec === 'findings') { if (!priorSev.has(_fmId)) priorSev.set(_fmId, msv[1]); continue; }
912
+ var mkv = l.match(/^ title: (.*)\$/);
913
+ if (mkv && _fmId && _fmSec === 'findings') {
914
+ var _v = mkv[1];
915
+ if (_fmJson) { try { _v = JSON.parse(_v); } catch (e) { /* keep the raw scalar */ } }
916
+ priorTitle.set(_fmId, _v);
917
+ continue;
918
+ }
919
+ }
920
+ }
921
+ const sameTitle = (a, b) => String(a === undefined ? '' : a).replace(/\s+/g, ' ').trim()
922
+ === String(b === undefined ? '' : b).replace(/\s+/g, ' ').trim();
923
+ // Section headings are matched WHOLE: a prefix match would let '## Fixed Issues Verification'
924
+ // classify every finding under it as fixed.
925
+ const applied = new Map(), staleFix = [];
926
+ for (const fixPath of fixReports) {
927
+ let sect = null;
928
+ for (const h of headings(fs.readFileSync(fixPath, 'utf-8'))) {
929
+ if (h.fence || h.skip) continue;
930
+ if (/^##\s+Fixed Issues\s*\$/.test(h.line)) { sect = 'fixed'; continue; }
931
+ if (/^##\s+Skipped Issues\s*\$/.test(h.line)) { sect = 'skipped'; continue; }
932
+ if (/^##\s+/.test(h.line)) { sect = null; continue; }
933
+ if (h.id && sect && !applied.has(h.id)) {
934
+ // THREE ARMS. An id the review does not report has no title to disagree with -- not the
935
+ // stale-report case, but what a finding looks like once acted on; the old form dropped it
936
+ // silently. Reuse stays closed below. The record carries the originating report and title.
937
+ var _acted = { d: sect, src: path.basename(fixPath), t: h.title };
938
+ if (!title.has(h.id)) applied.set(h.id, _acted);
939
+ else if (sameTitle(title.get(h.id), h.title)) applied.set(h.id, _acted);
940
+ else if (staleFix.indexOf(h.id) === -1) staleFix.push(h.id);
941
+ }
942
+ }
943
+ }
944
+ // Precedence: an applied outcome is evidence of an action on code and wins; a recorded
945
+ // non-'open' decision wins over the default. 'open' never overwrites a decision.
946
+ // Inherited only while the id names the SAME finding. An ABSENT prior title inherits: a
947
+ // pre-titles ledger has none, and refusing would reset every decision in it.
948
+ const sameFinding = (id) => !priorTitle.has(id) || !title.has(id) || sameTitle(priorTitle.get(id), title.get(id));
949
+ const sev = (id) => sectionSev.get(id) || (priorSev.has(id) && sameFinding(id) ? priorSev.get(id) : prefixSev(id));
950
+ const row = (id) => {
951
+ if (applied.has(id)) { const a = applied.get(id); return { id, sev: sev(id), d: a.d, src: a.src, t: title.has(id) ? title.get(id) : a.t }; }
952
+ const was = prior.get(id);
953
+ if (was && was.d !== 'open' && sameFinding(id)) return { id, sev: sev(id), d: was.d, src: was.src || 'recorded', t: title.get(id) };
954
+ // Reused id: the NEW finding is untriaged and renders 'open'; the prior decision loses its row,
955
+ // and that is REPORTED.
956
+ if (was && was.d !== 'open' && reused.indexOf(id + '=' + was.d) === -1) reused.push(id + '=' + was.d);
957
+ return { id, sev: sev(id), d: 'open', src: '-', t: title.get(id) };
958
+ };
959
+ const rows = order.map(row);
960
+ const carriedIds = [];
961
+ for (const id of prior.keys()) if (order.indexOf(id) === -1 && carriedIds.indexOf(id) === -1) carriedIds.push(id);
962
+ for (const id of applied.keys()) if (order.indexOf(id) === -1 && carriedIds.indexOf(id) === -1) carriedIds.push(id);
963
+ for (const id of carriedIds) {
964
+ const act = applied.get(id), was = prior.get(id);
965
+ const d = act ? act.d : (was ? was.d : 'open');
966
+ const src = act ? act.src : (was && was.src) || (d === 'open' ? '-' : 'recorded');
967
+ // Title precedence: the report that DECIDED it, then the prior ledger. A carried row is absent
968
+ // from the review, so one of those two is the only record of it.
969
+ // typeof, not ||: an empty title is FALSY, and the truthy fallback discarded it -- reading back
970
+ // as a pre-format ledger and reopening the leak.
971
+ const kt = act && typeof act.t === 'string' ? act.t : priorTitle.get(id);
972
+ rows.push({ id, sev: sev(id), d: d, src: src, t: kt, carried: true });
973
+ }
974
+ const open = rows.filter((r) => r.d === 'open').length;
975
+ const reusedNote = reused.length ? ' (' + reused.length + ' recorded decision(s) DROPPED -- the id now names a different finding, so the decision no longer has a row: ' + reused.join(', ') + ')' : '';
976
+ const staleNote = staleFix.length ? ' (' + staleFix.length + ' fix-report entr' + (staleFix.length === 1 ? 'y titles its' : 'ies title their') + ' finding differently from the review, so ' + (staleFix.length === 1 ? 'it was' : 'they were') + ' not reconciled -- a stale report, or a re-titled one: ' + staleFix.join(', ') + ')' : '';
977
+ if (rows.length === 0 && !unparsed && !fs.existsSync(process.env.DISPOSITION_FILE)) return;
978
+ const escapePipes = (t) => t.replace(/\\\\.|\|/g, (m) => (m === '|' ? '\\\\|' : m));
979
+ const body = ['# Phase ' + process.env.PADDED + ': Code Review Disposition', '', '| Finding | Severity | Disposition | Source |', '|---------|----------|-------------|--------|']
980
+ .concat(rows.map((r) => { const src = escapePipes(r.src || '-'); const mark = r.carried && !/\(not in the current review\)\s*\$/.test(src) ? ' (not in the current review)' : ''; return '| ' + r.id + ' | ' + r.sev + ' | ' + r.d + ' | ' + src + mark + ' |'; }))
981
+ .concat(['', 'Dispositions: \`open\` (recorded, not yet triaged), \`fixed\`, \`skipped\`, \`deferred\`.', 'Set \`deferred\` by hand and put the reason in the Source cell; both are preserved. A \`|\` in the reason is kept as prose and escaped on the next run.', 'Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but \`open\`) is named on the console when that happens; a row still at \`open\` is replaced silently, because \`open\` records no decision to lose.', '']).join('\n');
982
+ // One line: the value feeds a line-oriented record a regex re-reads.
983
+ const oneLine = (t) => String(t === undefined || t === null ? '' : t).replace(/[\r\n]+/g, ' ').trim();
984
+ // JSON.stringify: YAML 1.2 is a JSON superset, so a colon, quote or leading '#' survives. The
985
+ // bare form emitted 'title: Parser: loses data', which a real YAML reader rejects (driven).
986
+ const yv = (t) => JSON.stringify(oneLine(t));
987
+ const head = ['---', 'phase: ' + process.env.PADDED, 'review: ' + path.basename(process.env.REVIEW_FILE), 'titles: json', 'findings:']
988
+ .concat(rows.map((r) => ' - id: ' + r.id + '\n severity: ' + r.sev + '\n disposition: ' + r.d
989
+ // Emitted whenever KNOWN, empty included ('### CR-01:'). Known-empty vs NOT
990
+ // KNOWN is the distinction; conflating them was a leak. Unknown stays absent.
991
+ + (typeof r.t === 'string' ? '\n title: ' + yv(r.t) : '')))
992
+ .concat(['open: ' + open, 'total: ' + rows.length])
993
+ // Emitted only when there IS a shortfall, so an ordinary ledger gains no noise key and the
994
+ // unchanged-run check below is unaffected on every review that parses cleanly.
995
+ .concat(unparsed ? ['unparsed: ' + unparsed] : []).join('\n');
996
+ // Rewrite only on a real change. The timestamp is the one field that always differs, so
997
+ // stamping unconditionally would dirty the tree and produce a docs commit on every phase
998
+ // re-run with nothing to report.
999
+ const render = (stamp) => head + '\nrecorded: ' + stamp + '\n---\n\n' + body;
1000
+ const stripTs = (t) => t.replace(/^recorded:.*\$/m, 'recorded:');
1001
+ const prev = fs.existsSync(process.env.DISPOSITION_FILE) ? norm(fs.readFileSync(process.env.DISPOSITION_FILE, 'utf-8')) : '';
1002
+ if (prev && stripTs(prev) === stripTs(render(''))) {
1003
+ console.log('Code review disposition unchanged: ' + open + ' of ' + rows.length + ' finding(s) open' + staleNote + unparsedNote + reusedNote);
1004
+ return;
1005
+ }
1006
+ fs.writeFileSync(process.env.DISPOSITION_FILE, render(new Date().toISOString()));
1007
+ console.log('Code review disposition recorded: ' + open + ' of ' + rows.length + ' finding(s) open' + staleNote + unparsedNote + reusedNote + ' — ' + process.env.DISPOSITION_FILE);
1008
+ })();
1009
+ " || echo "Code review disposition record skipped (non-blocking)."
1010
+
1011
+ COMMIT_DOCS=$(gsd_run query config-get commit_docs --raw 2>/dev/null || echo "true")
1012
+ # `-f` FOLLOWS a symlink, so this could hand the commit helper a link the script above just
1013
+ # refused to write through -- the guard and its consumer disagreeing about the same path.
1014
+ if [ "$COMMIT_DOCS" = "true" ] && [ -f "${DISPOSITION_FILE}" ] && [ ! -L "${DISPOSITION_FILE}" ]; then
1015
+ gsd_run query commit "docs(${PADDED}): record code review disposition" --files "${DISPOSITION_FILE}" || true
1016
+ fi
1017
+ ```