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