@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
@@ -49,77 +49,263 @@ const planningScopeMod = require("./planning-scope.cjs");
49
49
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
50
50
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
51
51
  const security_cjs_1 = require("./security.cjs");
52
+ const pattern_cjs_1 = require("./pattern.cjs");
53
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
54
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
55
+ const command_arg_projection_cjs_1 = require("./command-arg-projection.cjs");
52
56
  const { output, error } = io;
53
57
  const { extractPhaseToken, scopeToPhase } = phaseId;
54
- const { extractFrontmatter } = frontmatterMod;
58
+ const { extractFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatterMod;
55
59
  const { normalizeLineEndings } = coreUtilsMod;
56
60
  const { SCOPE } = planningScopeMod;
57
- // ─── Constants ────────────────────────────────────────────────────────────────
58
- /** The set of status values that the gsd-verifier agent emits. */
59
- const VERIFIER_STATUSES = ['passed', 'gaps_found', 'human_needed'];
61
+ // ─── The closed VerificationStatus enum (#5118, ADR-5057 §1 / Phase 4) ────────
60
62
  /**
61
- * Canonical routing table for verification statuses.
63
+ * The closed verification-status vocabulary. This module is its ONE owner:
64
+ * every other `src/` site imports `VERIFICATION_STATUS` / `VerificationStatus`
65
+ * (the ESLint rule `local/no-verification-status-literal` keeps a spelled
66
+ * literal out of `src/`), every workflow reads the owner's query fields, and
67
+ * the prose writer contract (`agents/gsd-verifier.md`,
68
+ * `templates/verification-report.md`) is parity-locked to `VERIFIER_STATUSES`.
62
69
  *
63
- * This is the single source of truth — ship.md and execute-phase.md will
64
- * later import from here instead of embedding their own message strings.
70
+ * `unknown` is not a member (#5118): it existed only to pass an out-of-set
71
+ * report value along (#4817's `status: verified` → `/gsd-execute-phase`). An
72
+ * out-of-set report value is now a `VerificationStatusError`, thrown where it
73
+ * is read.
65
74
  *
66
- * INTERNAL SENTINELS: 'missing' and 'unknown' are operational states constructed
67
- * internally — the verifier (gsd-verifier.md) never emits them. The verifier only
68
- * emits values in VERIFIER_STATUSES (passed|gaps_found|human_needed). The guard in
69
- * readVerificationStatus excludes 'missing' and 'unknown' from raw-status table
70
- * lookup so they can only be reached via internal construction paths.
75
+ * Reader-only members — never valid in a report's frontmatter:
76
+ * - `stale` the covered inputs moved after the verifier ran
77
+ * - `missing` the phase directory exists and holds no report, or
78
+ * the report has no `status` (the verify step never ran)
79
+ * - `unparseable` the report exists but its frontmatter is not YAML
80
+ * (#4806) — this one meaning only
81
+ * - `phase_dir_not_found` there was no phase directory to look in (ADR-5057
82
+ * amendment 2, #4987) — a usage error, never
83
+ * `execute-phase`
71
84
  *
72
- * For 'gaps_found', next_command is built at call time in readVerificationStatus
73
- * by substituting the phase number — it is NOT stored as a function in the table.
85
+ * An out-of-set report status is NOT a member either: the reader throws, and
86
+ * `isPhaseComplete`'s no-throw projection of it is `status: null` with scope
87
+ * UNREADABLE and `statusError` set (never `unparseable`).
88
+ */
89
+ const VERIFICATION_STATUS = Object.freeze({
90
+ PASSED: 'passed',
91
+ GAPS_FOUND: 'gaps_found',
92
+ HUMAN_NEEDED: 'human_needed',
93
+ STALE: 'stale',
94
+ MISSING: 'missing',
95
+ UNPARSEABLE: 'unparseable',
96
+ PHASE_DIR_NOT_FOUND: 'phase_dir_not_found',
97
+ });
98
+ /** The set of status values the gsd-verifier agent writes — a frozen subset of the enum. */
99
+ const VERIFIER_STATUSES = Object.freeze(new Set([
100
+ VERIFICATION_STATUS.PASSED,
101
+ VERIFICATION_STATUS.GAPS_FOUND,
102
+ VERIFICATION_STATUS.HUMAN_NEEDED,
103
+ ]));
104
+ const VERIFICATION_STATUS_VALUES = new Set(Object.values(VERIFICATION_STATUS));
105
+ /** True exactly when `v` is a member of the closed enum (exact match, no case folding). */
106
+ function isVerificationStatus(v) {
107
+ return typeof v === 'string' && VERIFICATION_STATUS_VALUES.has(v);
108
+ }
109
+ /**
110
+ * Fail where the value is produced (the same rule as phase-status.cts's
111
+ * `assertPhaseStatus`): a non-member is a `TypeError` naming the call site.
112
+ */
113
+ function assertVerificationStatus(v, where) {
114
+ if (!isVerificationStatus(v)) {
115
+ throw new TypeError(`${where}: ${describeRawStatus(v)} is not a VerificationStatus (expected one of ${[...VERIFICATION_STATUS_VALUES].join(', ')})`);
116
+ }
117
+ }
118
+ /** The rendered raw-status token is cut to this many characters (#5118 security review). */
119
+ const RAW_STATUS_TOKEN_LIMIT = 120;
120
+ /**
121
+ * Render an untrusted status value as one quoted, control-free token
122
+ * (io.formatDiagnosticToken escapes C0/C1 controls, line/paragraph
123
+ * separators, zero-width and bidi-override characters and the BOM as
124
+ * `\uXXXX`), truncated to RAW_STATUS_TOKEN_LIMIT characters plus
125
+ * `…(N more)` — a report is agent-written text, and its echo reaches every
126
+ * workflow's LLM context through the error message.
127
+ */
128
+ function describeRawStatus(raw) {
129
+ let text;
130
+ if (typeof raw === 'string') {
131
+ text = raw;
132
+ }
133
+ else {
134
+ let json;
135
+ try {
136
+ json = JSON.stringify(raw);
137
+ }
138
+ catch {
139
+ json = undefined;
140
+ }
141
+ text = json ?? String(raw);
142
+ }
143
+ const rendered = io.formatDiagnosticToken(text);
144
+ if (rendered.length <= RAW_STATUS_TOKEN_LIMIT)
145
+ return rendered;
146
+ // The cut must land on a boundary: never inside a `\uXXXX` escape the
147
+ // formatter emitted (a fragment such as `\u00` would read as a different
148
+ // character) and never between the halves of a surrogate pair (a lone
149
+ // surrogate is not valid text). Back off to the start of either.
150
+ let cut = RAW_STATUS_TOKEN_LIMIT;
151
+ const partialEscape = /\\u[0-9a-fA-F]{0,3}$/.exec(rendered.slice(0, cut));
152
+ if (partialEscape) {
153
+ cut -= partialEscape[0].length;
154
+ }
155
+ else {
156
+ const last = rendered.charCodeAt(cut - 1);
157
+ if (last >= 0xd800 && last <= 0xdbff)
158
+ cut -= 1;
159
+ }
160
+ // JSON also emits two-character escapes (`\n`, `\t`, `\"`, `\\`): an odd
161
+ // trailing run of backslashes is the first half of one, so drop it too.
162
+ const trailingBackslashes = /\\+$/.exec(rendered.slice(0, cut));
163
+ if (trailingBackslashes && trailingBackslashes[0].length % 2 === 1)
164
+ cut -= 1;
165
+ return `${rendered.slice(0, cut)}…(${rendered.length - cut} more)`;
166
+ }
167
+ /** `VerificationStatusError.code` — import this constant wherever the code is matched. */
168
+ const VERIFICATION_STATUS_ERROR_CODE = 'ERR_VERIFICATION_STATUS_OUT_OF_SET';
169
+ /**
170
+ * A report whose frontmatter `status` is outside the writer set
171
+ * (`VERIFIER_STATUSES`) — a string that is not a member (`verified`, `Passed`,
172
+ * a reader-only member such as `stale`), or a non-string value (`5`, `true`,
173
+ * a list). Thrown by the reader, never folded into another status (#4817).
174
+ *
175
+ * It carries its own ERROR_REASON (`reason`, `verification_status_invalid`) —
176
+ * the one owner of that mapping: a CLI surface fails with `error(err.message,
177
+ * err.reason)`, and the command-routing hub returns it as a pure Result whose
178
+ * `kind` is that reason. `isPhaseComplete` maps it to its UNREADABLE scope
179
+ * (`status: null`, `statusError`) so its no-throw contract holds.
180
+ */
181
+ class VerificationStatusError extends Error {
182
+ code = VERIFICATION_STATUS_ERROR_CODE;
183
+ reason = io.ERROR_REASON.VERIFICATION_STATUS_INVALID;
184
+ rawStatus;
185
+ file;
186
+ accepted;
187
+ constructor(rawStatus, file) {
188
+ const accepted = [...VERIFIER_STATUSES];
189
+ super(`Verification report ${io.formatDiagnosticToken(file)} has status ${describeRawStatus(rawStatus)}, ` +
190
+ `which is outside the closed set — accepted values: ${accepted.join(' | ')}. ` +
191
+ `Recovery: set the report's frontmatter \`status:\` to one of ${accepted.join(' | ')}, ` +
192
+ "or delete the report and re-run the phase's verification.");
193
+ this.name = 'VerificationStatusError';
194
+ this.rawStatus = rawStatus;
195
+ this.file = file;
196
+ this.accepted = accepted;
197
+ }
198
+ }
199
+ /**
200
+ * The CLI projection of a VerificationStatusError — its own message and its
201
+ * own `.reason` (the one owner of the reason mapping). Every CLI surface that
202
+ * refuses a report (a thrown error at the entry seam, or an aggregate
203
+ * carrying one in its result) fails through here; nothing restates the reason.
204
+ */
205
+ function failOnVerificationStatusError(err) {
206
+ return error(err.message, err.reason);
207
+ }
208
+ /**
209
+ * The carry rule every aggregate shares: the FIRST refused report is the one
210
+ * an aggregate fails with. Keeps `carried` once set; otherwise takes
211
+ * `candidate` (a later report never displaces an earlier one).
212
+ */
213
+ function firstStatusError(carried, candidate) {
214
+ return carried ?? candidate ?? undefined;
215
+ }
216
+ /**
217
+ * The owner's frontmatter-only judgement of a report's `status` — the one
218
+ * place a VERIFICATION report's `status` scalar is read (#5118: phase.cts,
219
+ * audit.cts and uat-predicate.cts used to read it themselves). Returns the
220
+ * writer-set member, or `null` when the report carries no status (absent key,
221
+ * `null`, an empty string, or an unparseable block — the caller routes
222
+ * those). Throws `VerificationStatusError` for anything else, including a
223
+ * non-string value.
74
224
  *
75
- * #2617: `next_command` here holds a BARE command name (`execute-phase`), never a
76
- * prefixed one. Every return path projects it through `formatGsdSlash` with the
77
- * caller's runtime, so Codex sees `$gsd-execute-phase` and slash-hyphen runtimes
78
- * see `/gsd-execute-phase`. Storing a prefixed literal is what leaked the
79
- * hard-coded (and deprecated) `/gsd:` colon form to every runtime.
225
+ * `fm` is `extractFrontmatter`'s result for the report at `filePath`.
80
226
  */
81
- const VERIFICATION_ROUTING_TABLE = {
82
- passed: {
83
- status: 'passed',
227
+ function reportStatusOf(fm, filePath) {
228
+ if (fm[FRONTMATTER_UNPARSEABLE] === true)
229
+ return null;
230
+ const raw = fm['status'];
231
+ if (raw === undefined || raw === null)
232
+ return null;
233
+ if (typeof raw === 'string') {
234
+ const trimmed = raw.trim();
235
+ if (trimmed.length === 0)
236
+ return null;
237
+ if (VERIFIER_STATUSES.has(trimmed))
238
+ return trimmed;
239
+ throw new VerificationStatusError(trimmed, filePath);
240
+ }
241
+ throw new VerificationStatusError(raw, filePath);
242
+ }
243
+ /**
244
+ * VERIFICATION_ROUTES — the one routing table (#5118). Keyed by exactly the
245
+ * enum (`Record<VerificationStatus, …>`: a missing or extra key is a compile
246
+ * error); the key IS the status, so entries carry no `status` field. Every
247
+ * result `readVerificationStatus` returns is projected from this table by
248
+ * `routeResult` — no return site hard-codes a command.
249
+ *
250
+ * #2617: `command` holds a BARE command name (`execute-phase`), never a
251
+ * prefixed one; `routeResult` projects it through `formatGsdSlash` with the
252
+ * caller's runtime, so Codex sees `$gsd-execute-phase` and slash-hyphen
253
+ * runtimes see `/gsd-execute-phase`.
254
+ *
255
+ * `stale` has ONE route: `execute-phase`. Its regenerating action is the
256
+ * shared step `gsd-core/workflows/execute-phase/steps/verify-phase-goal.md`
257
+ * (execute-phase's verify_phase_goal, which verify-work's stale arm includes
258
+ * too): it re-runs the verifier, which regenerates VERIFICATION.md and its
259
+ * digest (#4682, #4887).
260
+ */
261
+ const VERIFICATION_ROUTES = Object.freeze({
262
+ passed: Object.freeze({
84
263
  next_action: 'Verification passed — continue.',
85
- next_command: '',
86
- },
87
- gaps_found: {
88
- status: 'gaps_found',
264
+ command: '',
265
+ tail: '',
266
+ }),
267
+ gaps_found: Object.freeze({
89
268
  next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.',
90
- // next_command is computed at call time; this entry is never returned directly.
91
- next_command: '',
92
- },
93
- human_needed: {
94
- status: 'human_needed',
269
+ command: 'plan-phase',
270
+ tail: ' --gaps',
271
+ }),
272
+ human_needed: Object.freeze({
95
273
  next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
96
- // #2617: was '' — next_action told the user to "re-run the verify step" but
97
- // named no command, while init.cts's parallel projector emitted
98
- // `verify-work <N>` for this same state. The two surfaces disagreed on
99
- // whether a next command existed at all; init's answer was the useful one,
100
- // and init now delegates here rather than re-deriving it.
101
- next_command: 'verify-work',
102
- },
103
- stale: {
104
- status: 'stale',
105
- next_action: 'Verification is stale. Re-run verify-work before transition.',
106
- next_command: '',
107
- },
108
- // INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
109
- // the file has no parseable frontmatter status. Never emitted by the verifier.
110
- missing: {
111
- status: 'missing',
274
+ // #2617: init's projector and this table used to disagree on whether a
275
+ // next command existed at all; `verify-work <N>` is the useful answer.
276
+ command: 'verify-work',
277
+ tail: '',
278
+ }),
279
+ stale: Object.freeze({
280
+ // #4682 / #5118: the only remedy for a stale report is re-running the
281
+ // verifier; /gsd-verify-work on its own never rewrites VERIFICATION.md.
282
+ next_action: 'Verification is stale — covered source files changed after the verifier last ran. Re-run execute-phase for this phase: it resumes at the verification gates and re-runs the verifier, regenerating VERIFICATION.md and its digest. verify-work alone cannot refresh a stale report.',
283
+ command: 'execute-phase',
284
+ tail: '',
285
+ }),
286
+ // The phase directory exists and holds no report, or the report has no
287
+ // `status`: the verify step never completed (#2868).
288
+ missing: Object.freeze({
112
289
  next_action: 'No verification report found — the verify step never completed. Running execute-phase is safe here: it resumes at the verification gates and does not re-run plans that already have a SUMMARY.md (see #2868).',
113
- next_command: 'execute-phase',
114
- },
115
- // INTERNAL SENTINEL: constructed when the file has a status value not in
116
- // VERIFIER_STATUSES. Never emitted by the verifier.
117
- unknown: {
118
- status: 'unknown',
119
- next_action: '', // filled in dynamically with the raw value
120
- next_command: 'execute-phase',
121
- },
122
- };
290
+ command: 'execute-phase',
291
+ tail: '',
292
+ }),
293
+ // #4806: the report EXISTS but its frontmatter is not parseable YAML —
294
+ // re-running execute-phase cannot fix a YAML typo in an existing report.
295
+ unparseable: Object.freeze({
296
+ next_action: "The *-VERIFICATION.md frontmatter is not parseable YAML — fix the syntax error in the report itself. Re-running execute-phase cannot fix a YAML typo in an existing report.",
297
+ command: '',
298
+ tail: '',
299
+ }),
300
+ // ADR-5057 amendment 2 / #4987: there was nothing to look in — a usage
301
+ // error, never execute-phase (which would re-run a phase that may already be
302
+ // archived under .planning/milestones/).
303
+ phase_dir_not_found: Object.freeze({
304
+ next_action: 'Usage error: the phase directory does not exist — pass an existing phase directory (resolve it with find-phase; phases archived by complete-milestone live under .planning/milestones/v<X.Y>-phases/).',
305
+ command: '',
306
+ tail: '',
307
+ }),
308
+ });
123
309
  /**
124
310
  * Project a BARE command name (plus optional argument tail) into the surface the
125
311
  * given runtime actually installs (#2617).
@@ -163,118 +349,495 @@ function toPosix(p) {
163
349
  function normalizeRel(p) {
164
350
  return node_path_1.default.posix.normalize(toPosix(p));
165
351
  }
166
- /** Canonicalize a covered-files list: normalize, de-duplicate, sort — the SAME
167
- * transform computeCoveredDigest and cmdVerificationFingerprint both need
168
- * (the digest's own key order; the CLI's own `covered_files` JSON output). */
169
- function canonicalizeCoveredFiles(files) {
170
- return Array.from(new Set(files.map(normalizeRel))).sort();
352
+ /**
353
+ * Canonicalize a covered-files list: normalize, de-duplicate, sort — the SAME
354
+ * transform computeCoveredDigest and cmdVerificationFingerprint both need
355
+ * (the digest's own key order; the CLI's own `covered_files` JSON output).
356
+ *
357
+ * #5095 (ADR-5057 Phase 2): `opts.version >= 3` additionally drops any
358
+ * report-shaped path (`isVerificationReportPath`) — a report is never an
359
+ * input to its own digest (#4857). Versionless callers (the default) keep
360
+ * the pre-#5095 behaviour: normalize/dedupe/sort only, no filtering.
361
+ */
362
+ function canonicalizeCoveredFiles(files, opts = {}) {
363
+ const normalized = files.map(normalizeRel);
364
+ const filtered = opts.version !== undefined && opts.version >= 3
365
+ ? normalized.filter((f) => !isVerificationReportPath(f))
366
+ : normalized;
367
+ return Array.from(new Set(filtered)).sort();
368
+ }
369
+ /**
370
+ * #5095 (ADR-5057 Phase 2, #4857): a covered-input path names a verification
371
+ * REPORT — basename `VERIFICATION.md` or ending `-VERIFICATION.md`
372
+ * (case-sensitive, as `resolveVerificationFile` is) — as opposed to any other
373
+ * evidence. `computeCoveredDigest(projectRoot, coveredFiles)` never receives
374
+ * the report's OWN path as a distinguished value — the report writes its own
375
+ * digest into its own frontmatter, so a comparison inside that function is
376
+ * structurally impossible (it would need to know, while computing a value,
377
+ * what that value is about to become). The filter therefore lives here, one
378
+ * function up from the digest, as a PATTERN match on shape, not a path
379
+ * identity check. Matches `docs/VERIFICATION.md` (same basename) but not
380
+ * `VERIFICATION-NOTES.md` or `07-VERIFICATION.md.bak` (different basename).
381
+ */
382
+ function isVerificationReportPath(rel) {
383
+ const basename = node_path_1.default.posix.basename(toPosix(rel));
384
+ return basename === 'VERIFICATION.md' || basename.endsWith('-VERIFICATION.md');
385
+ }
386
+ /**
387
+ * #5095 (R7): enumerate every anchored planning scope the Planning Workspace
388
+ * Module's layout admits, by walking the REAL directory tree rooted at
389
+ * `<projectRoot>/.planning` — never the caller's `phaseDir` (the pre-R7 bug:
390
+ * admitting a scope root on `basename(phaseDir's parent) === 'phases'` alone
391
+ * made any directory named `phases` anywhere an admissible root). Only
392
+ * directories are admitted, and a project/workstream segment failing
393
+ * `planningDir`'s own `BAD_SEGMENT` rule (a path separator or `..`) is
394
+ * skipped — the identical validation `planningDir` itself enforces, so every
395
+ * scope this function admits is one `planningDir` could also resolve to.
396
+ * `workstreams` is reserved as a layout segment (never itself a project
397
+ * name), so the walk cannot derive the same scope twice under two labels.
398
+ * Each planning BASE contributes two scopes — the base itself, and its own
399
+ * `phases/` subdirectory — since `phases/` may be independently symlinked to
400
+ * a per-project/per-workstream store while the base directory stays real
401
+ * (the #4894 layout, generalized to every base shape); the longest-`lexRel`
402
+ * -wins rule in `mapRealPathToRootRelative` / `bestPlanningScopeForRel` then
403
+ * prefers the `phases` scope for anything living under it.
404
+ *
405
+ * Security: a scope whose realpath is the filesystem root, or an ANCESTOR of
406
+ * `realRoot` itself, is refused outright (`real: null`) — an
407
+ * attacker-controlled `.planning -> /` (or `-> ..`) symlink must never become
408
+ * an admissible containment root.
409
+ */
410
+ function enumeratePlanningScopes(projectRoot, realRoot) {
411
+ const BAD_SEGMENT = /[/\\]|\.\./;
412
+ const listDirs = (dirAbs) => {
413
+ try {
414
+ return node_fs_1.default
415
+ .readdirSync(dirAbs, { withFileTypes: true })
416
+ .filter((e) => e.isDirectory() && !BAD_SEGMENT.test(e.name))
417
+ .map((e) => e.name);
418
+ }
419
+ catch {
420
+ return [];
421
+ }
422
+ };
423
+ const realOf = (p) => {
424
+ let real;
425
+ try {
426
+ real = node_fs_1.default.realpathSync(p);
427
+ }
428
+ catch {
429
+ return null;
430
+ }
431
+ if (node_path_1.default.dirname(real) === real)
432
+ return null; // filesystem root
433
+ if (realRoot !== null && real !== realRoot && (0, security_cjs_1.isContainedIn)(realRoot, real))
434
+ return null; // ancestor of realRoot
435
+ return real;
436
+ };
437
+ // Every planning BASE (a directory `planningDir` itself would resolve to)
438
+ // contributes TWO scopes: the base itself (for a direct child like
439
+ // `ROADMAP.md`/`config.json`) and its OWN `phases/` subdirectory (for phase
440
+ // artifacts) — kept separate because `phases/` may be independently
441
+ // symlinked to a per-project/per-workstream store while the base directory
442
+ // stays real (the #4894 layout, generalized to every base shape). The
443
+ // longest-`lexRel`-wins rule elsewhere then prefers the `phases` scope for
444
+ // anything under it.
445
+ const addBase = (lexRel, baseAbs) => {
446
+ scopes.push({ lexRel, real: realOf(baseAbs) });
447
+ scopes.push({ lexRel: `${lexRel}/phases`, real: realOf(node_path_1.default.join(baseAbs, 'phases')) });
448
+ };
449
+ const scopes = [];
450
+ const planningAbs = node_path_1.default.join(projectRoot, '.planning');
451
+ const planningBaseReal = realOf(planningAbs);
452
+ scopes.push({ lexRel: '.planning', real: planningBaseReal });
453
+ scopes.push({ lexRel: '.planning/phases', real: realOf(node_path_1.default.join(planningAbs, 'phases')) });
454
+ // #5095 (R7(c) follow-up, security): when `.planning` itself was refused as
455
+ // a dangerous root (filesystem root, or an ancestor of `realRoot` — see
456
+ // `realOf` above), NEVER walk its `workstreams`/project subdirectories to
457
+ // mint further scopes. `listDirs`/`path.join` operate on the LEXICAL,
458
+ // still-symlinked `planningAbs`, so with `.planning -> /` every entry of
459
+ // the real filesystem root (`/etc`, `/usr`, ...) would otherwise surface as
460
+ // an admissible `.planning/<project>` scope — legitimizing an attacker- or
461
+ // container-controlled root-filesystem directory as a containment root the
462
+ // instant it happens to share a name with something reachable from `/`.
463
+ // Each such child is realpath-resolved on its OWN merits (not an ancestor
464
+ // of `realRoot`, not the filesystem root itself), so it silently passes the
465
+ // per-scope refusal check even though its only claim to legitimacy is
466
+ // having been discovered by listing a root the outer check already
467
+ // rejected. Refusing to enumerate here is what makes that refusal actually
468
+ // stick.
469
+ if (planningBaseReal === null)
470
+ return scopes;
471
+ for (const ws of listDirs(node_path_1.default.join(planningAbs, 'workstreams'))) {
472
+ addBase(`.planning/workstreams/${ws}`, node_path_1.default.join(planningAbs, 'workstreams', ws));
473
+ }
474
+ for (const project of listDirs(planningAbs)) {
475
+ if (project === 'workstreams')
476
+ continue;
477
+ const projectAbs = node_path_1.default.join(planningAbs, project);
478
+ addBase(`.planning/${project}`, projectAbs);
479
+ for (const ws of listDirs(node_path_1.default.join(projectAbs, 'workstreams'))) {
480
+ addBase(`.planning/${project}/workstreams/${ws}`, node_path_1.default.join(projectAbs, 'workstreams', ws));
481
+ }
482
+ }
483
+ return scopes;
484
+ }
485
+ function resolveContainmentRoots(projectRoot) {
486
+ let realRoot;
487
+ try {
488
+ realRoot = node_fs_1.default.realpathSync(projectRoot);
489
+ }
490
+ catch {
491
+ realRoot = null;
492
+ }
493
+ return { realRoot, planningScopes: enumeratePlanningScopes(projectRoot, realRoot) };
494
+ }
495
+ function bestPlanningScopeForRel(rel, planningScopes) {
496
+ let best = null;
497
+ for (const scope of planningScopes) {
498
+ if ((rel === scope.lexRel || rel.startsWith(`${scope.lexRel}/`)) && // allow-handrolled-containment: lexical PREFIX selection among candidate scope spellings (posix segment-boundary), not a resolved-path containment decision — the realpath check happens separately via isContainedIn on scope.real
499
+ (best === null || scope.lexRel.length > best.lexRel.length)) {
500
+ best = scope;
501
+ }
502
+ }
503
+ return best;
504
+ }
505
+ /**
506
+ * #5095 (R7): map an already-`realpathSync`-resolved target back to a
507
+ * root-relative, posix-normalized spelling. The ANCHORED scope with the
508
+ * LONGEST `lexRel` whose `real` contains the target wins — so a nested scope
509
+ * (e.g. `.planning/workstreams/ws1`) is preferred over the top-level
510
+ * `.planning` scope for a target reachable through both, and a
511
+ * same-named-but-different-store phase under the nested scope is never
512
+ * mapped onto the root scope's spelling. Falls back to a bare
513
+ * project-root-relative path when the target lives inside `realRoot` but no
514
+ * scope's real claims it; else `null` (fail closed — the caller must not
515
+ * guess a spelling for a path it cannot place).
516
+ */
517
+ function mapRealPathToRootRelative(real, realRoot, planningScopes) {
518
+ let best = null;
519
+ for (const scope of planningScopes) {
520
+ if (scope.real !== null &&
521
+ (0, security_cjs_1.isContainedIn)(real, scope.real) &&
522
+ (best === null || scope.lexRel.length > best.lexRel.length)) {
523
+ best = scope;
524
+ }
525
+ }
526
+ if (best !== null) {
527
+ const rel = normalizeRel(node_path_1.default.relative(best.real, real));
528
+ return rel === '' || rel === '.' ? best.lexRel : `${best.lexRel}/${rel}`;
529
+ }
530
+ if ((0, security_cjs_1.isContainedIn)(real, realRoot)) {
531
+ return normalizeRel(node_path_1.default.relative(realRoot, real));
532
+ }
533
+ return null;
534
+ }
535
+ function phaseArtifactPaths(phaseDir, projectRoot) {
536
+ const roots = resolveContainmentRoots(projectRoot);
537
+ if (roots.realRoot === null) {
538
+ return { ok: false, reason: `project root is unreadable: ${projectRoot}` };
539
+ }
540
+ const scan = scanPhasePlans(phaseDir);
541
+ // Fail closed on a non-COMPLETE scan (unreadable phase dir, or an
542
+ // unreadable nested plans/ dir) — a partial scan's invisible contents can
543
+ // never be proven covered (mirrors allCurrentArtifactsCovered's own
544
+ // fail-closed contract).
545
+ if (scan.scope !== SCOPE.COMPLETE) {
546
+ return { ok: false, reason: `phase directory scan is incomplete (scope: ${scan.scope}): ${phaseDir}` };
547
+ }
548
+ const candidates = [...scan.allPlanFiles, ...scan.summaryFiles].filter((f) => !isVerificationReportPath(f));
549
+ const paths = [];
550
+ for (const f of candidates) {
551
+ let real;
552
+ try {
553
+ real = node_fs_1.default.realpathSync(node_path_1.default.join(phaseDir, f));
554
+ }
555
+ catch {
556
+ return { ok: false, reason: `phase artifact is unreadable: ${f}` };
557
+ }
558
+ const mapped = mapRealPathToRootRelative(real, roots.realRoot, roots.planningScopes);
559
+ if (mapped === null) {
560
+ return { ok: false, reason: `phase artifact escapes the project root: ${f}` };
561
+ }
562
+ paths.push(mapped);
563
+ }
564
+ // #5095 (R2): the phase dir's own mapped spelling, fed to `sharedRootsFor`
565
+ // so a workstream/project ROADMAP under an out-of-repo store is still
566
+ // recognized as a shared planning document even when `phaseDir` itself was
567
+ // addressed by its real (non-`.planning`-spelled) path.
568
+ let mappedPhaseDir = null;
569
+ try {
570
+ const realPhaseDir = node_fs_1.default.realpathSync(phaseDir);
571
+ mappedPhaseDir = mapRealPathToRootRelative(realPhaseDir, roots.realRoot, roots.planningScopes);
572
+ }
573
+ catch {
574
+ mappedPhaseDir = null;
575
+ }
576
+ return { ok: true, paths, scope: scan.scope, mappedPhaseDir };
171
577
  }
172
578
  // ─── #4155: covered-input fingerprint ──────────────────────────────────────────
173
579
  /**
174
580
  * Bump on any change to the digest's input shape (path list, hashing order,
175
581
  * per-file hash algorithm) so an old stored digest can never collide with a
176
582
  * differently-computed new one — a version mismatch is just a mismatch.
583
+ *
584
+ * Version history:
585
+ * v1 (#4155) — every covered path's whole bytes, uniformly.
586
+ * v2 (#4623) — repo-wide planning documents (`isSharedPlanningDoc`) are
587
+ * excluded from the hash by construction.
588
+ * v3 (#5095, ADR-5057 Phase 2) — v2 PLUS: (a) report-shaped declared paths
589
+ * (`isVerificationReportPath`) are filtered before hashing — a
590
+ * report is never an input to its own digest (#4857); (b) the
591
+ * hashed set is the declared list UNIONED with the phase's own
592
+ * live `*-PLAN.md`/`*-SUMMARY.md` artifacts
593
+ * (`phaseArtifactPaths`), computed identically on both the
594
+ * emit (`cmdVerificationFingerprint`) and check
595
+ * (`readVerificationStatus`) sides — a plan/summary added
596
+ * after fingerprinting moves the digest itself, with no
597
+ * separate live-directory rescan needed (#4817); (c)
598
+ * containment additionally admits a `.planning`-spelled path
599
+ * whose realpath sits in one of the anchored planning scopes
600
+ * `resolveContainmentRoots` enumerates (`.planning`,
601
+ * `.planning/<project>`, `.planning/workstreams/<ws>`,
602
+ * `.planning/<project>/workstreams/<ws>`), covering a
603
+ * per-project/per-workstream store symlink (amendment 1,
604
+ * revised R7).
605
+ *
606
+ * A stored digest names its own version (`v<N>:sha256:…`), and
607
+ * `readVerificationStatus` recomputes under the STORED version rather than
608
+ * this constant — so bumping it does not flip every already-verified phase
609
+ * to `stale` on upgrade. A legacy v1/v2 report keeps its own semantics
610
+ * (shared documents included for v1; no report filter or artifact union for
611
+ * either) until it is re-fingerprinted; only a version outside
612
+ * `KNOWN_FINGERPRINT_VERSIONS` is unrecomputable and fails closed.
613
+ */
614
+ const FINGERPRINT_VERSION = 3;
615
+ const KNOWN_FINGERPRINT_VERSIONS = new Set([1, 2, 3]);
616
+ /**
617
+ * #4623: the planning roots whose DIRECT children are repo-wide planning
618
+ * documents, as project-root-relative posix paths. Always `.planning`; plus
619
+ * the phase's OWN planning root when a phase directory is known — the parent
620
+ * of its `phases/` directory, which is how `planningDir` lays out every
621
+ * scope (`.planning`, `.planning/<project>`, `.planning/workstreams/<ws>`,
622
+ * `.planning/<project>/workstreams/<ws>`; `planning-workspace.cts`). Derived
623
+ * from the phase's position rather than from a list of layouts so a
624
+ * workstream-scoped `ROADMAP.md` is recognised without this function
625
+ * knowing what a workstream is, and so `.planning/research/notes.md` is
626
+ * NOT mistaken for one — lexically the two are indistinguishable from
627
+ * `.planning/<project>/ROADMAP.md`. A phase directory that does not sit
628
+ * under the project root (unit fixtures at a bare tmpdir) contributes no
629
+ * extra root.
630
+ */
631
+ function sharedPlanningRoots(projectRoot, phaseDir) {
632
+ const roots = ['.planning'];
633
+ if (phaseDir) {
634
+ const phasesDir = node_path_1.default.dirname(node_path_1.default.resolve(phaseDir));
635
+ const planningDir = node_path_1.default.dirname(phasesDir);
636
+ const rel = normalizeRel(node_path_1.default.relative(node_path_1.default.resolve(projectRoot), planningDir));
637
+ // Two structural checks, both load-bearing: the phase dir's PARENT must be
638
+ // the `phases/` directory `planningDir` lays every scope out with, and the
639
+ // derived root must sit inside `.planning/`. Without them any accepted
640
+ // directory — `<root>/src/phases/01-fake` — would nominate `src` as a
641
+ // planning root and silently drop real implementation evidence from the
642
+ // digest (found by the cross-AI review of this change). A shape that fails
643
+ // either check contributes no extra root; `.planning` itself is already
644
+ // present.
645
+ if (node_path_1.default.basename(phasesDir) === 'phases' &&
646
+ rel.startsWith('.planning/') &&
647
+ !rel.includes('/../') &&
648
+ !roots.includes(rel)) {
649
+ roots.push(rel);
650
+ }
651
+ }
652
+ return roots;
653
+ }
654
+ /**
655
+ * #4623: a covered path names a repo-wide planning document when it sits
656
+ * DIRECTLY under one of `sharedPlanningRoots` — `ROADMAP.md`,
657
+ * `REQUIREMENTS.md`, `STATE.md`, `PROJECT.md`, `MILESTONES.md`,
658
+ * `config.json`, … — as opposed to a phase's own artifacts under
659
+ * `<root>/phases/<phase>/` or a research note under `.planning/research/`.
660
+ * Every phase rewrites these as ordinary bookkeeping (a roadmap checkbox, a
661
+ * requirement's traceability cell, STATE.md's position), so hashing their
662
+ * whole bytes into one phase's digest coupled every phase's staleness to
663
+ * every other phase's close — and to its OWN close, since `phase.complete`
664
+ * and `requirements mark-complete` write them after the verifier has
665
+ * already run.
666
+ *
667
+ * Defined by position, not by a name list, so the set cannot drift as new
668
+ * top-level planning documents appear (the tree already carries a dozen).
669
+ * `rel` is expected posix-normalized (`canonicalizeCoveredFiles`), so a
670
+ * `./.planning/ROADMAP.md` spelling has already collapsed to the bare form.
177
671
  */
178
- const FINGERPRINT_VERSION = 1;
179
- /**
180
- * #4155: recompute the deterministic content fingerprint over a verifier's
181
- * declared covered-input set (phase PLAN/SUMMARY, mapped requirements,
182
- * implementation files in the change set) and return the versioned digest
183
- * string, or `null` if the set cannot be resolved.
184
- *
185
- * Determinism: paths are de-duplicated and SORTED before hashing (directory
186
- * enumeration order is irrelevant), each path is resolved relative to
187
- * `projectRoot` (the absolute checkout path never enters the digest), and
188
- * file BYTES are hashed (mtime never enters the digest).
189
- *
190
- * NOT normalized: line endings. Unlike the report-frontmatter read (which
191
- * runs every VERIFICATION.md through `normalizeLineEndings`), covered-file
192
- * bytes are hashed exactly as they sit on disk. A covered text file checked
193
- * out with CRLF line endings (e.g. a Windows checkout without a `.gitattributes
194
- * eol=lf` rule pinning it to LF) hashes differently than the same file on an
195
- * LF checkout — a real cross-platform digest mismatch, not a bug, since GSD
196
- * installs into arbitrary user projects with no guaranteed line-ending policy.
197
- *
198
-
199
- * Fail closed: a covered path that is empty, absolute, escapes
200
- * `projectRoot` (`..` traversal), or cannot be read (missing, unreadable,
201
- * not a regular file) makes the WHOLE fingerprint unresolvable — returns
202
- * `null` — rather than silently hashing a partial set. Callers treat `null`
203
- * as stale (#4155), the same fail-closed shape #3057 B3 established for the
204
- * legacy mtime staleness check.
205
- *
206
- * Always reads through the REAL `node:fs`, never a caller-injected `FsLike`
207
- * seam — same reasoning as the root canonicalization below, extended to
208
- * every covered file: `covered_files` is expected to span the whole
209
- * `projectRoot` (implementation files under `src/`, not just `.planning/`
210
- * artifacts), so a caller-scoped containment wrapper narrower than
211
- * `projectRoot` (e.g. `planning-inspect.cts`'s `containmentEnforcingVerificationFs`,
212
- * confined to `.planning/`) would reject every implementation-file read and
213
- * report EVERY fingerprinted phase permanently `stale` regardless of actual
214
- * drift — the bug this comment now documents against regressing. The
215
- * `realRel`-vs-`realRoot` re-check a few lines below already does the real
216
- * confinement work (against `projectRoot`, the correct boundary for this
217
- * data), so no security property is lost by bypassing a narrower seam here.
218
- */
219
- function computeCoveredDigest(projectRoot, coveredFiles) {
220
- const uniqueSorted = canonicalizeCoveredFiles(coveredFiles);
221
- if (uniqueSorted.length === 0)
672
+ function isSharedPlanningDoc(rel, roots = ['.planning']) {
673
+ if (rel === '' || rel.endsWith('/'))
674
+ return false;
675
+ return roots.includes(node_path_1.default.posix.dirname(rel));
676
+ }
677
+ /**
678
+ * #5095 (R2): `sharedPlanningRoots` extended with the phase's own MAPPED
679
+ * (root-relative, posix) directory when one is available —
680
+ * `phaseArtifactPaths`'s `mappedPhaseDir` — so a workstream/project ROADMAP
681
+ * under an out-of-repo store is still recognized as shared even when
682
+ * `phaseDir` was addressed by its real, non-`.planning`-spelled path (the
683
+ * #4894 `--project-dir` shape). Falls back to the existing absolute-path
684
+ * derivation (`sharedPlanningRoots(projectRoot, phaseDir)`) when no mapped
685
+ * spelling is available — byte-for-behaviour identical to the pre-#5095
686
+ * seam for every caller that has no artifact-derivation step of its own.
687
+ */
688
+ function sharedRootsFor(projectRoot, phaseDir, mappedPhaseDir) {
689
+ const roots = sharedPlanningRoots(projectRoot, phaseDir);
690
+ if (mappedPhaseDir) {
691
+ const phasesDir = node_path_1.default.posix.dirname(mappedPhaseDir);
692
+ const planningDir = node_path_1.default.posix.dirname(phasesDir);
693
+ if (node_path_1.default.posix.basename(phasesDir) === 'phases' &&
694
+ (planningDir === '.planning' || planningDir.startsWith('.planning/')) &&
695
+ !planningDir.includes('/../') &&
696
+ !roots.includes(planningDir)) {
697
+ roots.push(planningDir);
698
+ }
699
+ }
700
+ return roots;
701
+ }
702
+ /**
703
+ * #4623: the fingerprint version a stored `covered_digest` was computed
704
+ * under, or `null` when the prefix is absent, malformed, or names a version
705
+ * this build cannot recompute (an unknown version is a mismatch by
706
+ * construction — the fail-closed shape `FINGERPRINT_VERSION`'s doc promises).
707
+ */
708
+ function parseFingerprintVersion(digest) {
709
+ const m = /^v(\d+):sha256:/.exec(digest);
710
+ if (!m)
222
711
  return null;
223
- // Canonicalize the root ONCE — every candidate's realpath is checked against
224
- // this, not the possibly-symlinked `projectRoot` argument itself. Always via
225
- // the REAL fs, never fsImpl: `projectRoot` is a trusted anchor the CALLER
226
- // derived (findProjectRoot), not attacker-influenced covered-input data —
227
- // routing it through a caller-scoped containment seam (e.g. #4155's
712
+ const version = Number(m[1]);
713
+ return KNOWN_FINGERPRINT_VERSIONS.has(version) ? version : null;
714
+ }
715
+ function deriveCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
716
+ // #4623: `version` selects the input shape to hash under — the CURRENT
717
+ // one for a fresh fingerprint (the CLI verb), or the STORED one when
718
+ // `readVerificationStatus` recomputes against a report's own digest.
719
+ // `opts.phaseDir` lets v2+ recognise the phase's own planning root
720
+ // (`sharedPlanningRoots`); without it only `.planning/` itself is shared.
721
+ if (!KNOWN_FINGERPRINT_VERSIONS.has(version))
722
+ return { ok: true, digest: null, files: [], mappedPhaseDir: null };
723
+ // #5095 (R1, ADR-5057 Phase 2): for v3+ with a known phaseDir, the hashed
724
+ // set is the declared list UNIONED with the phase's own live artifacts —
725
+ // computed here, ONCE, on BOTH the emit and check sides, rather than
726
+ // separately in the CLI emitter and again in here (#5095 R7: the prior
727
+ // shape ran `phaseArtifactPaths` twice on the emit path — once in
728
+ // `cmdVerificationFingerprint` to build the emitted `covered_files`, again
729
+ // in here to hash — a race window where a plan/summary added between the
730
+ // two calls could make the emitted list and the hashed set disagree). A
731
+ // plan/summary added after fingerprinting therefore moves the digest
732
+ // directly; `--raw` / MCP callers that write their own declared list
733
+ // (without enumerating plans/summaries) still match, because the checker
734
+ // adds the identical union. An artifact-resolution failure makes the WHOLE
735
+ // fingerprint unresolvable (fail closed).
736
+ let declared = canonicalizeCoveredFiles(coveredFiles, { version });
737
+ let artifactMappedPhaseDir = null;
738
+ if (version >= 3 && opts.phaseDir) {
739
+ const artifacts = phaseArtifactPaths(opts.phaseDir, projectRoot);
740
+ if (!artifacts.ok)
741
+ return { ok: false, reason: artifacts.reason };
742
+ artifactMappedPhaseDir = artifacts.mappedPhaseDir;
743
+ declared = canonicalizeCoveredFiles([...declared, ...artifacts.paths], { version });
744
+ }
745
+ if (declared.length === 0)
746
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
747
+ const sharedRoots = version >= 2 ? sharedRootsFor(projectRoot, opts.phaseDir, artifactMappedPhaseDir) : [];
748
+ let hashed = 0;
749
+ // Canonicalize the roots ONCE — every candidate's realpath is checked
750
+ // against these, not the possibly-symlinked `projectRoot`/`.planning`
751
+ // arguments themselves. Always via the REAL fs, never fsImpl: `projectRoot`
752
+ // is a trusted anchor the CALLER derived (resolveProjectRoot), not
753
+ // attacker-influenced covered-input data — routing it through a
754
+ // caller-scoped containment seam (e.g. #4155's
228
755
  // containmentEnforcingVerificationFs, confined to `.planning/`, a proper
229
756
  // SUBSET of `projectRoot`) would reject the root itself and fail every
230
757
  // lookup regardless of whether the covered files are legitimate.
231
- let realRoot;
232
- try {
233
- realRoot = node_fs_1.default.realpathSync(projectRoot);
234
- }
235
- catch {
236
- return null;
237
- }
758
+ const roots = resolveContainmentRoots(projectRoot);
759
+ if (roots.realRoot === null)
760
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
238
761
  const parts = [];
239
- for (const rel of uniqueSorted) {
762
+ for (const rel of declared) {
240
763
  // `normalizeRel` (already applied by `canonicalizeCoveredFiles` above)
241
764
  // collapses internal `..` segments before `rel` ever reaches here
242
765
  // (`a/../../b` → `../b`), so this start-of-string check is already the
243
766
  // full lexical confinement test — no separate post-`path.resolve`
244
767
  // re-check can observe a different answer.
245
- if (rel === '' || rel === '..' || rel.startsWith('../') || node_path_1.default.isAbsolute(rel))
246
- return null;
768
+ if (rel === '' || rel === '..' || rel.startsWith('../') || node_path_1.default.isAbsolute(rel)) {
769
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
770
+ }
771
+ // #5095 (R7): a `.planning`-spelled path may resolve inside the checkout
772
+ // root OR the LONGEST-prefix anchored planning scope that claims it
773
+ // (`bestPlanningScopeForRel`) — every other path is confined to the
774
+ // checkout root alone, exactly as before.
775
+ const firstSegment = rel.split('/')[0];
776
+ const admissibleRoots = firstSegment === '.planning'
777
+ ? [roots.realRoot, bestPlanningScopeForRel(rel, roots.planningScopes)?.real ?? null].filter((r) => r !== null)
778
+ : [roots.realRoot];
247
779
  const resolved = node_path_1.default.resolve(projectRoot, rel);
248
780
  let bytes;
249
781
  try {
250
- // A regular file INSIDE projectRoot can still be a symlink whose TARGET
251
- // escapes it — statSync/readFileSync follow symlinks, so the lexical
252
- // confinement check above is not enough. realpathSync resolves the
253
- // actual target; re-confining against realRoot closes that gap.
782
+ // A regular file INSIDE a known root can still be a symlink whose
783
+ // TARGET escapes every known root — statSync/readFileSync follow
784
+ // symlinks, so the lexical confinement check above is not enough.
785
+ // realpathSync resolves the actual target; re-confining against the
786
+ // admissible roots closes that gap.
254
787
  const real = node_fs_1.default.realpathSync(resolved);
255
- // Both operands are already realpath-resolved (this fn's own realpathSync calls
256
- // above), so the shared containment comparison applies directly (ADR-4650) —
257
- // no re-resolution through assertWithinRoot/tryWithinRoot, which would redo work
258
- // this function already owns for its exists-vs-escaped tri-state.
259
- if (!(0, security_cjs_1.isContainedIn)(real, realRoot)) {
260
- return null;
788
+ // Every operand is already realpath-resolved (this fn's own
789
+ // realpathSync calls above), so the shared containment comparison
790
+ // applies directly (ADR-4650) — no re-resolution through
791
+ // assertWithinRoot/tryWithinRoot, which would redo work this function
792
+ // already owns for its exists-vs-escaped tri-state.
793
+ if (!admissibleRoots.some((root) => (0, security_cjs_1.isContainedIn)(real, root))) {
794
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
261
795
  }
262
796
  const st = node_fs_1.default.statSync(real);
263
797
  if (!st.isFile())
264
- return null;
798
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
799
+ // #4623 (v2+): a repo-wide planning document is VALIDATED exactly as
800
+ // every other covered path — confined, present, a regular file; the
801
+ // fail-closed contract above is unchanged — but its bytes contribute
802
+ // nothing to the digest. It may stay declared in `covered_files` (the
803
+ // verifier's instructions long said to list the mapped requirement,
804
+ // and every report already written does); its bookkeeping churn can
805
+ // no longer read as drift.
806
+ if (isSharedPlanningDoc(rel, sharedRoots))
807
+ continue;
265
808
  bytes = node_fs_1.default.readFileSync(real);
266
809
  }
267
810
  catch {
268
- return null;
811
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
269
812
  }
270
813
  const fileHash = node_crypto_1.default.createHash('sha256').update(bytes).digest('hex');
271
814
  parts.push(`${rel}\n${fileHash}\n`);
815
+ hashed++;
816
+ }
817
+ // #4623 (v2+): a declaration made ONLY of shared planning documents has no
818
+ // evidence in it at all — a constant digest over the header would satisfy
819
+ // the fingerprint pair while grounding the verification in nothing. Fail
820
+ // closed, the same way an empty declaration does.
821
+ if (version >= 2 && hashed === 0) {
822
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
272
823
  }
273
824
  const aggregate = node_crypto_1.default
274
825
  .createHash('sha256')
275
- .update(`v${FINGERPRINT_VERSION}\n${parts.join('')}`, 'utf-8')
826
+ .update(`v${version}\n${parts.join('')}`, 'utf-8')
276
827
  .digest('hex');
277
- return `v${FINGERPRINT_VERSION}:sha256:${aggregate}`;
828
+ return { ok: true, digest: `v${version}:sha256:${aggregate}`, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
829
+ }
830
+ /**
831
+ * #4155/#5095: recompute the deterministic content fingerprint over a
832
+ * verifier's declared covered-input set, returning JUST the digest string
833
+ * (or `null` if unresolvable) — the signature every existing caller
834
+ * (`readVerificationStatus`, tests) already depends on. Thin wrapper over
835
+ * `deriveCoveredDigest`, collapsing its `{ ok: false, reason }` arm to `null`
836
+ * exactly as the pre-#5095 single-function shape did.
837
+ */
838
+ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
839
+ const result = deriveCoveredDigest(projectRoot, coveredFiles, version, opts);
840
+ return result.ok ? result.digest : null;
278
841
  }
279
842
  /**
280
843
  * #4155: the content fingerprint only recomputes digests for paths the
@@ -381,18 +944,159 @@ function defaultPhaseCleanCommitTimesMs(phaseDir, files, execGitFn = shell_comma
381
944
  return commitTimes;
382
945
  }
383
946
  /**
384
- * Build a 'missing' result from the routing table.
385
- * Used for two early-return paths: no *-VERIFICATION.md file found, and
386
- * file present but no parseable frontmatter status.
947
+ * The one result builder (#5118): every `VerificationStatusResult` is
948
+ * projected from `VERIFICATION_ROUTES[status]` here — `route` (the bare
949
+ * command) and `next_command` (its runtime projection, #2617) come from the
950
+ * same table entry, so they cannot disagree.
387
951
  */
388
- function missingResult(runtime, phaseArg) {
389
- const route = VERIFICATION_ROUTING_TABLE['missing'];
952
+ function routeResult(status, ctx) {
953
+ const entry = VERIFICATION_ROUTES[status];
390
954
  return {
391
- status: route.status,
392
- next_action: route.next_action,
393
- next_command: projectNextCommand(route.next_command, runtime, phaseArg),
955
+ status,
956
+ next_action: entry.next_action,
957
+ next_command: projectNextCommand(entry.command, ctx.runtime, `${ctx.phaseArg}${entry.tail}`),
958
+ route: entry.command,
959
+ ...(ctx.message !== undefined ? { message: ctx.message } : {}),
960
+ ...(ctx.staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
394
961
  };
395
962
  }
963
+ /**
964
+ * ADR-5057 amendment 2 (#4987): true only when there is no phase DIRECTORY at
965
+ * `phaseDir` — `stat` fails with ENOENT / ENOTDIR (a dangling symlink stats as
966
+ * ENOENT), or it succeeds on something that is not a directory (a regular
967
+ * file). Any other stat failure — EACCES, or a code-less containment error
968
+ * from an injected FsLike (planning-inspect's seam) — is NOT "not found": the
969
+ * caller falls through to the existing readdir path (`missing`).
970
+ */
971
+ function isPhaseDirNotFound(fsImpl, phaseDir) {
972
+ let st;
973
+ try {
974
+ st = fsImpl.statSync(phaseDir);
975
+ }
976
+ catch (err) {
977
+ const code = err?.code;
978
+ return code === 'ENOENT' || code === 'ENOTDIR';
979
+ }
980
+ return typeof st?.isDirectory === 'function' && !st.isDirectory();
981
+ }
982
+ function phaseDirNotFoundMessage(phaseDir) {
983
+ return `Usage error: phase directory not found at ${io.formatDiagnosticToken(phaseDir)}`;
984
+ }
985
+ /**
986
+ * The PHASES ROOT a phase directory must live under: the parent of `phaseDir`
987
+ * in its own (unresolved) spelling. It is the fixed anchor of the containment
988
+ * check — a phase directory symlinked outside the project resolves outside, so
989
+ * containing a report against that directory's OWN realpath alone would admit
990
+ * the escape (both resolve outside). A phases root that is itself a symlinked
991
+ * per-scope store resolves consistently on both sides of the comparison.
992
+ */
993
+ function planningContainmentRoot(phaseDir) {
994
+ return node_path_1.default.dirname(node_path_1.default.resolve(phaseDir));
995
+ }
996
+ /**
997
+ * True when the phase directory really lives under its phases root AND
998
+ * `filePath` really lives inside that phase directory (symlinks followed on
999
+ * both). Either escape reads `missing`. Unresolvable → false.
1000
+ */
1001
+ function isReportContained(phaseDir, filePath) {
1002
+ try {
1003
+ const realDir = node_fs_1.default.realpathSync(phaseDir);
1004
+ return (0, security_cjs_1.isContainedIn)(realDir, node_fs_1.default.realpathSync(planningContainmentRoot(phaseDir)))
1005
+ && (0, security_cjs_1.isContainedIn)(node_fs_1.default.realpathSync(filePath), realDir);
1006
+ }
1007
+ catch {
1008
+ return false;
1009
+ }
1010
+ }
1011
+ /**
1012
+ * #5118: steps 0-2 of `readVerificationStatus` — no phase directory
1013
+ * (`phase_dir_not_found`), no report or no `status` (`missing`), a report
1014
+ * whose frontmatter is not YAML (`unparseable`), or the report's writer-set
1015
+ * status. Reads only the report's frontmatter (no staleness check, no git).
1016
+ * THROWS `VerificationStatusError` for a status outside the writer set — the
1017
+ * one judgement (`reportStatusOf`) every reader shares.
1018
+ */
1019
+ function locatePhaseReport(phaseDir, fsImpl, convention) {
1020
+ if (isPhaseDirNotFound(fsImpl, phaseDir))
1021
+ return { kind: 'phase_dir_not_found' };
1022
+ const baseName = node_path_1.default.basename(phaseDir);
1023
+ let verificationFile = null;
1024
+ try {
1025
+ const entries = fsImpl.readdirSync(phaseDir);
1026
+ // #3492: pin selection to THIS phase's own token so a stray cross-phase
1027
+ // or sentinel-numbered canonically-shaped file cannot outrank this phase's
1028
+ // own report. #612: bracket directories resolve with the convention-aware
1029
+ // token. #4187: keep the bare report tier aligned with every other reader.
1030
+ const resolutionToken = convention === 'bracket'
1031
+ ? extractPhaseToken(baseName, convention)
1032
+ : extractPhaseToken(baseName);
1033
+ verificationFile = resolveVerificationFile(entries, {
1034
+ allowBare: true,
1035
+ phaseToken: resolutionToken,
1036
+ phaseDirName: baseName,
1037
+ convention,
1038
+ });
1039
+ }
1040
+ catch {
1041
+ // Directory unreadable → treat as missing
1042
+ verificationFile = null;
1043
+ }
1044
+ if (!verificationFile)
1045
+ return { kind: 'missing' };
1046
+ // extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
1047
+ const filePath = node_path_1.default.join(phaseDir, verificationFile);
1048
+ // #5118 security review (containment): a report whose real path escapes its
1049
+ // own phase directory (a symlink out of the project) is refused BEFORE a
1050
+ // byte of it is read — it reads `missing`, and no value from it can ever
1051
+ // reach a message (the out-of-set error echoes the report's `status`). An
1052
+ // injected FsLike (planning-inspect's containment seam) enforces its own
1053
+ // containment by throwing, which the read below folds to `missing` too.
1054
+ if (fsImpl === defaultFsImpl && !isReportContained(phaseDir, filePath))
1055
+ return { kind: 'missing' };
1056
+ let fm = {};
1057
+ try {
1058
+ // #3707-CR: normalize line endings at this read boundary so a lone-CR
1059
+ // report's `---\r…\r---` fence still matches extractFrontmatter's check.
1060
+ const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
1061
+ fm = extractFrontmatter(content, filePath);
1062
+ // #4806: an unparseable frontmatter block is NOT "missing" — the file
1063
+ // exists and verification ran; the caller is sent to fix the YAML.
1064
+ if (fm[FRONTMATTER_UNPARSEABLE] === true) {
1065
+ return { kind: 'unparseable' };
1066
+ }
1067
+ }
1068
+ catch {
1069
+ // An unreadable report reads as carrying no status (`missing`).
1070
+ fm = {};
1071
+ }
1072
+ // #5118: judged OUTSIDE the parse `try`, so an out-of-set value can never be
1073
+ // swallowed into `missing`.
1074
+ const status = reportStatusOf(fm, filePath);
1075
+ if (status === null)
1076
+ return { kind: 'missing' };
1077
+ return { kind: 'status', status, filePath, fm };
1078
+ }
1079
+ /**
1080
+ * #5118 (ADR-5057 Phase 4, "no write before the error"): the first report
1081
+ * among `phaseDirs` whose `status` is outside the closed set, or `null`.
1082
+ * Frontmatter-only (the same locator `readVerificationStatus` runs), so a
1083
+ * command that WRITES validates every report it will read BEFORE its first
1084
+ * write and fails having written nothing.
1085
+ */
1086
+ function findVerificationStatusError(phaseDirs, deps = {}) {
1087
+ const fsImpl = deps.fs ?? defaultFsImpl;
1088
+ for (const phaseDir of phaseDirs) {
1089
+ try {
1090
+ locatePhaseReport(phaseDir, fsImpl, deps.convention);
1091
+ }
1092
+ catch (err) {
1093
+ if (err instanceof VerificationStatusError)
1094
+ return err;
1095
+ throw err;
1096
+ }
1097
+ }
1098
+ return null;
1099
+ }
396
1100
  /**
397
1101
  * #3518: the shared phase-pinned artifact-selection core BOTH single-pick
398
1102
  * resolvers (`resolveVerificationFile` for `*-VERIFICATION.md`,
@@ -608,7 +1312,16 @@ function findStaleVerificationSummary(phaseDir, fsImpl = defaultFsImpl, phaseCle
608
1312
  * 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
609
1313
  * parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
610
1314
  * If no frontmatter block or no `status` key → status 'missing'.
611
- * 3. Map to routing table. Unknown non-empty value → status 'unknown'.
1315
+ * 3. Route through VERIFICATION_ROUTES (`routeResult`). A status outside the
1316
+ * writer set (`VERIFIER_STATUSES`) — any other string, case variant,
1317
+ * reader-only member, or non-string value — THROWS `VerificationStatusError`
1318
+ * (#5118). The throw sits outside the parse `try`, and before the
1319
+ * `gaps_found` short-circuit and the staleness check, so it can neither be
1320
+ * folded into `missing` nor masked by `stale` (#4817 Part 2).
1321
+ *
1322
+ * #5118 / ADR-5057 amendment 2: a path with no phase DIRECTORY behind it
1323
+ * (ENOENT, ENOTDIR, a non-directory) reads `phase_dir_not_found` — a usage
1324
+ * error with no next command — not `missing` (#4987).
612
1325
  *
613
1326
  * The internal staleness check can itself fail (fs / scanPhasePlans / clock
614
1327
  * error); when it does, `status` is routed as if nothing were stale (the
@@ -642,74 +1355,26 @@ function readVerificationStatus(phaseDir, opts = {}) {
642
1355
  // that already know the number (init) pass it explicitly and always get it.
643
1356
  const phaseArgSource = opts.phaseNumber ?? (/^\d+(\.\d+)*$/.test(derivedPhaseNumber) ? derivedPhaseNumber : '');
644
1357
  const phaseArg = phaseArgSource ? ` ${phaseArgSource}` : '';
645
- // 1. Find *-VERIFICATION.md
646
- let verificationFile = null;
647
- try {
648
- const entries = fsImpl.readdirSync(phaseDir);
649
- // #3492: pin selection to THIS phase's own token so a stray cross-phase
650
- // or sentinel-numbered canonically-shaped file cannot outrank this phase's
651
- // own report. #612: derive a separate convention-aware RESOLUTION token
652
- // for bracket directories while the routed command argument above stays
653
- // convention-less and milestone-unambiguous. #4187: keep the bare report
654
- // tier aligned with every other verification reader.
655
- const resolutionToken = opts.convention === 'bracket'
656
- ? extractPhaseToken(baseName, opts.convention)
657
- : phaseToken;
658
- verificationFile = resolveVerificationFile(entries, {
659
- allowBare: true,
660
- phaseToken: resolutionToken,
661
- phaseDirName: baseName,
662
- convention: opts.convention,
663
- });
664
- }
665
- catch {
666
- // Directory unreadable → treat as missing
667
- verificationFile = null;
668
- }
669
- if (!verificationFile) {
670
- return missingResult(runtime, phaseArg);
671
- }
672
- // 2. Read and parse frontmatter using the shared parser.
673
- // extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
674
- const filePath = node_path_1.default.join(phaseDir, verificationFile);
675
- let rawStatus = null;
676
- let fm = {};
677
- try {
678
- // #3707-CR follow-up MINOR 1: normalize line endings at this read
679
- // boundary — this function's own `readFileSync` is the equivalent seam
680
- // `planning.inspect`'s `buildUatRows`/`readDocument` route through for
681
- // UAT/REQUIREMENTS documents, but `readVerificationStatus` had no such
682
- // normalization of its own. A lone-CR VERIFICATION.md's `---\r...\r---`
683
- // frontmatter fence never matched `extractFrontmatter`'s byte-0
684
- // `---\n`/`---\r\n` check, so `status: passed` was read as absent and
685
- // this function reported 'missing' — under-reporting a completed
686
- // verification as if the step never ran, the fail-safe direction but the
687
- // same root cause as the false-clean class fixed elsewhere in #3707-CR.
688
- const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
689
- fm = extractFrontmatter(content, filePath);
690
- const statusVal = fm['status'];
691
- // status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
692
- // only accept string values — arrays and objects are not valid status values.
693
- if (typeof statusVal === 'string') {
694
- const trimmed = statusVal.trim();
695
- rawStatus = trimmed.length > 0 ? trimmed : null;
696
- }
697
- }
698
- catch {
699
- rawStatus = null;
700
- }
701
- if (!rawStatus) {
702
- return missingResult(runtime, phaseArg);
1358
+ const route = (status, extra = {}) => routeResult(status, { runtime, phaseArg, ...extra });
1359
+ // Steps 0-2 (no phase dir → usage error; find the report; parse its
1360
+ // frontmatter and judge `status`) are the frontmatter-only locator shared
1361
+ // with the pre-write validator (`findVerificationStatusError`). An
1362
+ // out-of-set status THROWS VerificationStatusError out of the locator —
1363
+ // before the gaps_found short-circuit and the staleness check below, so
1364
+ // `stale` can never mask it (#4817 Part 2).
1365
+ const located = locatePhaseReport(phaseDir, fsImpl, opts.convention);
1366
+ if (located.kind === 'phase_dir_not_found') {
1367
+ return route(VERIFICATION_STATUS.PHASE_DIR_NOT_FOUND, { message: phaseDirNotFoundMessage(phaseDir) });
703
1368
  }
1369
+ if (located.kind === 'missing')
1370
+ return route(VERIFICATION_STATUS.MISSING);
1371
+ if (located.kind === 'unparseable')
1372
+ return route(VERIFICATION_STATUS.UNPARSEABLE);
1373
+ const { status: reportStatus, fm } = located;
704
1374
  // gaps_found takes priority over stale — gap closure is the correct next
705
1375
  // step regardless of whether summaries are newer than the verification file.
706
- if (rawStatus === 'gaps_found') {
707
- const entry = VERIFICATION_ROUTING_TABLE['gaps_found'];
708
- return {
709
- status: entry.status,
710
- next_action: entry.next_action,
711
- next_command: projectNextCommand('plan-phase', runtime, `${phaseArg} --gaps`),
712
- };
1376
+ if (reportStatus === VERIFICATION_STATUS.GAPS_FOUND) {
1377
+ return route(VERIFICATION_STATUS.GAPS_FOUND);
713
1378
  }
714
1379
  // #4155: a report that declares a covered-input fingerprint is checked by
715
1380
  // RECOMPUTING that fingerprint over current file content — strictly
@@ -741,10 +1406,35 @@ function readVerificationStatus(phaseDir, opts = {}) {
741
1406
  // short-circuit in turn: the live-directory re-scan (for a plan/summary
742
1407
  // added AFTER verification and never declared in covered_files) only
743
1408
  // runs once the digest itself has already matched.
744
- isStale =
745
- !hasWellFormedFingerprint ||
746
- computeCoveredDigest((0, project_root_cjs_1.findProjectRoot)(phaseDir), coveredFilesVal) !== coveredDigestVal ||
747
- !allCurrentArtifactsCovered(phaseDir, coveredFilesVal);
1409
+ //
1410
+ // #4623: recompute under the STORED digest's own version, not the
1411
+ // current constant — a v1/v2 report written before a later semantics
1412
+ // change keeps its own semantics rather than going stale on upgrade. An
1413
+ // unknown version parses to `null`, which `computeCoveredDigest`
1414
+ // refuses (returns `null`), so the compare below fails closed.
1415
+ const projectRoot = (0, project_root_cjs_1.resolveProjectRoot)(phaseDir);
1416
+ const storedVersion = hasWellFormedFingerprint && typeof coveredDigestVal === 'string'
1417
+ ? parseFingerprintVersion(coveredDigestVal)
1418
+ : null;
1419
+ if (!hasWellFormedFingerprint || storedVersion === null) {
1420
+ isStale = true;
1421
+ }
1422
+ else if (storedVersion >= 3) {
1423
+ // #5095 (R1): v3's digest is computed over the declared set UNIONED
1424
+ // with the phase's own live artifacts — a plan/summary added after
1425
+ // fingerprinting already moves the digest itself, so the separate
1426
+ // live-directory re-scan (`allCurrentArtifactsCovered`) is redundant
1427
+ // for v3 and is not run.
1428
+ isStale =
1429
+ computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal;
1430
+ }
1431
+ else {
1432
+ // v1/v2: unchanged — the digest alone cannot see a plan/summary that
1433
+ // was never declared, so the live-directory re-scan still runs.
1434
+ isStale =
1435
+ computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal ||
1436
+ !allCurrentArtifactsCovered(phaseDir, coveredFilesVal);
1437
+ }
748
1438
  }
749
1439
  else {
750
1440
  const staleCheck = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs, opts.convention);
@@ -757,36 +1447,12 @@ function readVerificationStatus(phaseDir, opts = {}) {
757
1447
  staleCheckIndeterminate = !staleCheck.determined;
758
1448
  }
759
1449
  if (isStale) {
760
- const entry = VERIFICATION_ROUTING_TABLE['stale'];
761
- return {
762
- status: entry.status,
763
- next_action: entry.next_action,
764
- next_command: projectNextCommand('verify-work', runtime, phaseArg),
765
- };
766
- }
767
- // 3. Route — exclude internal sentinels from raw-file lookup (they are
768
- // constructed internally above, never written by the verifier).
769
- if (rawStatus in VERIFICATION_ROUTING_TABLE &&
770
- rawStatus !== 'missing' &&
771
- rawStatus !== 'unknown' &&
772
- rawStatus !== 'stale' &&
773
- rawStatus !== 'gaps_found') {
774
- const entry = VERIFICATION_ROUTING_TABLE[rawStatus];
775
- return {
776
- status: entry.status,
777
- next_action: entry.next_action,
778
- next_command: projectNextCommand(entry.next_command, runtime, phaseArg),
779
- ...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
780
- };
781
- }
782
- // Unknown value
783
- const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
784
- return {
785
- status: unknownRoute.status,
786
- next_action: `Unexpected verification status '${rawStatus}'. If this is an intentional non-standard marker (e.g. a hand-set failed/superseded state), no action is needed. Otherwise, run execute-phase to regenerate verification — it will not re-run plans that already have a SUMMARY.md.`,
787
- next_command: projectNextCommand(unknownRoute.next_command, runtime, phaseArg),
788
- ...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
789
- };
1450
+ // #4682 / #5118: the one stale route — execute-phase's verify_phase_goal
1451
+ // step re-runs the verifier, regenerating VERIFICATION.md and its digest.
1452
+ return route(VERIFICATION_STATUS.STALE);
1453
+ }
1454
+ // 3. Route the writer-set member through the one table.
1455
+ return route(reportStatus, { staleCheckIndeterminate });
790
1456
  }
791
1457
  /**
792
1458
  * isPhaseComplete — the single canonical owner of "is phase P complete?"
@@ -799,11 +1465,18 @@ function readVerificationStatus(phaseDir, opts = {}) {
799
1465
  * `*-VERIFICATION.md` is complete (#3168). A ROADMAP checkbox has no machine
800
1466
  * authority and is never consulted — this function never reads ROADMAP.md.
801
1467
  *
802
- * `complete` is exactly `verification.status === 'passed'`. `verification`
803
- * carries the FULL routing result (status/next_action/next_command), so a
804
- * caller can distinguish a failing verdict (`gaps_found`/`human_needed`/
805
- * `stale`/`unknown`) from an absent one (`missing`) — both are "not
806
- * complete", but they are not the same non-answer.
1468
+ * `complete` is exactly `verification.status === VERIFICATION_STATUS.PASSED`.
1469
+ * `verification` carries the FULL routing result
1470
+ * (status/next_action/next_command/route), so a caller can distinguish a
1471
+ * failing verdict (`gaps_found`/`human_needed`/`stale`) from an absent one
1472
+ * (`missing`) — both are "not complete", but they are not the same
1473
+ * non-answer.
1474
+ *
1475
+ * #5118: a report whose `status` is outside the closed set does NOT throw
1476
+ * out of here (ADR-5057 :223 — the no-throw contract Phases 2–4 preserve): it
1477
+ * degrades to scope UNREADABLE, `verification.status` is `null` (route `''`,
1478
+ * the error's message as `next_action`), `complete` is false, and
1479
+ * `value.statusError` holds the typed error for the caller to carry.
807
1480
  *
808
1481
  * `scope` is UNREADABLE when `phaseDir` itself could not be listed — this is
809
1482
  * INDEPENDENT of readVerificationStatus's own no-throw fail-open contract for
@@ -826,17 +1499,29 @@ function isPhaseComplete(phaseDir, deps = {}) {
826
1499
  catch {
827
1500
  readable = false;
828
1501
  }
829
- const verification = readVerificationStatus(phaseDir, {
830
- fs: deps.fs,
831
- phaseCleanCommitTimesMs: deps.phaseCleanCommitTimesMs,
832
- runtime: deps.runtime,
833
- phaseNumber: deps.phaseNumber,
834
- convention: deps.convention,
835
- });
1502
+ let verification;
1503
+ let statusError;
1504
+ try {
1505
+ verification = readVerificationStatus(phaseDir, {
1506
+ fs: deps.fs,
1507
+ phaseCleanCommitTimesMs: deps.phaseCleanCommitTimesMs,
1508
+ runtime: deps.runtime,
1509
+ phaseNumber: deps.phaseNumber,
1510
+ convention: deps.convention,
1511
+ });
1512
+ }
1513
+ catch (err) {
1514
+ if (!(err instanceof VerificationStatusError))
1515
+ throw err;
1516
+ statusError = err;
1517
+ readable = false;
1518
+ verification = { status: null, next_action: err.message, next_command: '', route: '' };
1519
+ }
836
1520
  return {
837
1521
  value: {
838
- complete: verification.status === 'passed',
1522
+ complete: verification.status === VERIFICATION_STATUS.PASSED,
839
1523
  verification,
1524
+ ...(statusError ? { statusError } : {}),
840
1525
  },
841
1526
  scope: readable ? SCOPE.COMPLETE : SCOPE.UNREADABLE,
842
1527
  };
@@ -845,6 +1530,13 @@ function isPhaseComplete(phaseDir, deps = {}) {
845
1530
  * CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
846
1531
  * emit via io.output().
847
1532
  *
1533
+ * #5118: an out-of-set report status throws `VerificationStatusError` out of
1534
+ * here with nothing on stdout; gsd-tools.cjs translates it (once, centrally)
1535
+ * into ERROR_REASON `verification_status_invalid`. A nonexistent phase
1536
+ * directory is an ANSWER (`phase_dir_not_found`, `route: ''`, a `message`,
1537
+ * no `error` field — so `--pick status` prints it and the run is not
1538
+ * DEGRADED), not a failure.
1539
+ *
848
1540
  * @param cwd - Current working directory (used to resolve phaseDirArg).
849
1541
  * @param phaseDirArg - Phase directory path (absolute or relative to cwd).
850
1542
  * @param raw - Whether to emit raw (non-JSON) output.
@@ -873,6 +1565,11 @@ function cmdVerificationStatus(cwd, phaseDirArg, raw) {
873
1565
  * bare path string (possibly empty) so `VAR=$(gsd_run query
874
1566
  * verification.resolve-file "$PHASE_DIR" --raw)` is directly assignable.
875
1567
  *
1568
+ * #5118 (#4987 item 2): a path with no phase directory behind it adds the
1569
+ * marker `status: "phase_dir_not_found"` and a `message` — never an `error`
1570
+ * field (that would declare DEGRADED) — so it is distinguishable from an
1571
+ * existing directory with no report, where `""` alone keeps its meaning.
1572
+ *
876
1573
  * @param cwd - Current working directory (used to resolve phaseDirArg).
877
1574
  * @param phaseDirArg - Phase directory path (absolute or relative to cwd).
878
1575
  * @param raw - Whether to emit raw (non-JSON) output.
@@ -883,6 +1580,14 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
883
1580
  return;
884
1581
  }
885
1582
  const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
1583
+ if (isPhaseDirNotFound(defaultFsImpl, phaseDir)) {
1584
+ output({
1585
+ verification_file: '',
1586
+ status: VERIFICATION_STATUS.PHASE_DIR_NOT_FOUND,
1587
+ message: phaseDirNotFoundMessage(phaseDir),
1588
+ }, raw, '');
1589
+ return;
1590
+ }
886
1591
  let verificationPath = '';
887
1592
  try {
888
1593
  const entries = node_fs_1.default.readdirSync(phaseDir);
@@ -898,6 +1603,69 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
898
1603
  }
899
1604
  output({ verification_file: verificationPath }, raw, verificationPath);
900
1605
  }
1606
+ /**
1607
+ * #4623: parse the argv tokens after `verification.fingerprint <phaseDir>`
1608
+ * into a covered-file list. The router hands over a raw positional slice,
1609
+ * so every `--files`-style form other `gsd-tools` verbs accept (`commit
1610
+ * --files a b`, `docs/CLI-TOOLS.md`) used to reach `computeCoveredDigest`
1611
+ * with the literal token `--files` — or an unsplit `"a,b"` — as a covered
1612
+ * path, and the whole command failed closed with "a covered file is
1613
+ * missing, unreadable, or escapes the project root". On the reporting
1614
+ * project that message convinced two people the digest was permanently
1615
+ * unrecomputable.
1616
+ *
1617
+ * Accepted, all equivalent and freely mixed:
1618
+ * - bare positionals `a b` (the documented form, unchanged)
1619
+ * - a single flag `--files a`
1620
+ * - a comma-separated value `--files a,b` (also `--files=a,b`)
1621
+ * - a repeated flag `--files a --files b`
1622
+ *
1623
+ * Only a `--files` VALUE is comma-split: a bare positional keeps its bytes,
1624
+ * so the documented form's behaviour on a comma-bearing filename is
1625
+ * unchanged. Any other `--flag` is an explicit usage error, never a path —
1626
+ * a mis-typed flag must not fail as "file missing" again. (`--raw` never
1627
+ * reaches here; the CLI entry point splices it out before routing.)
1628
+ */
1629
+ function parseFingerprintFileArgs(tokens) {
1630
+ const files = [];
1631
+ const EMPTY_VALUE = '--files requires at least one path for verification.fingerprint (a path, or a comma-separated list)';
1632
+ const splitList = (value) => value
1633
+ .split(',')
1634
+ .map((s) => s.trim())
1635
+ .filter((s) => s.length > 0);
1636
+ for (let i = 0; i < tokens.length; i++) {
1637
+ const token = tokens[i];
1638
+ if (token === '--files') {
1639
+ const value = tokens[i + 1];
1640
+ if (value === undefined || value.startsWith('--')) {
1641
+ return { error: '--files requires a value for verification.fingerprint (a path, or a comma-separated list)' };
1642
+ }
1643
+ const list = splitList(value);
1644
+ // An empty or all-comma value is a usage error, never a silent no-op —
1645
+ // the caller would otherwise meet the generic zero-files error and go
1646
+ // looking for a missing path.
1647
+ if (list.length === 0)
1648
+ return { error: EMPTY_VALUE };
1649
+ files.push(...list);
1650
+ i++;
1651
+ }
1652
+ else if (token.startsWith('--files=')) {
1653
+ const list = splitList(token.slice('--files='.length));
1654
+ if (list.length === 0)
1655
+ return { error: EMPTY_VALUE };
1656
+ files.push(...list);
1657
+ }
1658
+ else if (token.startsWith('--')) {
1659
+ return {
1660
+ error: `unknown flag ${token} for verification.fingerprint (covered files are bare positionals or --files <a[,b]>, repeatable)`,
1661
+ };
1662
+ }
1663
+ else {
1664
+ files.push(token);
1665
+ }
1666
+ }
1667
+ return { files };
1668
+ }
901
1669
  /**
902
1670
  * CLI command handler (#4155): compute the covered-input fingerprint the
903
1671
  * verifier embeds in VERIFICATION.md frontmatter (`covered_files`,
@@ -913,43 +1681,371 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
913
1681
  * @param cwd - Current working directory.
914
1682
  * @param phaseDirArg - Phase directory path (absolute or relative to cwd);
915
1683
  * its project root is the base covered paths resolve against.
916
- * @param files - Covered-input paths, relative to the project root.
1684
+ * Must be an existing directory (#4623): with the
1685
+ * phase dir omitted, the first covered file used to be
1686
+ * taken as the phase dir and the rest hashed — a
1687
+ * plausible digest over the wrong set, at exit 0.
1688
+ * @param fileArgs - The argv tokens after the phase dir, parsed by
1689
+ * `parseFingerprintFileArgs`: covered-input paths
1690
+ * relative to the project root, bare or via `--files`.
917
1691
  * @param raw - Whether to emit raw (non-JSON) output: just the
918
1692
  * `covered_digest` string, so `VAR=$(gsd_run query
919
1693
  * verification.fingerprint "$PHASE_DIR" ... --raw)` is
920
- * directly assignable. `covered_files` is unambiguous
921
- * from the caller's own input list in that mode, so
922
- * only the computed digest needs a raw form.
1694
+ * directly assignable. #5095 (ADR-5057 Phase 2): `raw`
1695
+ * returning only the digest is safe even though the
1696
+ * emitted `covered_files` is a SUPERSET of the caller's
1697
+ * declared list — the v3 digest is computed over
1698
+ * `canonicalize(declared ∪ phaseArtifactPaths)` on BOTH
1699
+ * the emit side (here) and the check side
1700
+ * (`readVerificationStatus`), so a `--raw` caller that
1701
+ * writes its OWN declared list (without enumerating
1702
+ * plans/summaries) alongside the raw digest still
1703
+ * matches — the checker adds the identical union.
923
1704
  */
924
- function cmdVerificationFingerprint(cwd, phaseDirArg, files, raw) {
1705
+ function cmdVerificationFingerprint(cwd, phaseDirArg, fileArgs, raw) {
925
1706
  if (!phaseDirArg) {
926
1707
  error('phase directory required for verification.fingerprint');
927
1708
  return;
928
1709
  }
1710
+ const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
1711
+ let phaseDirIsDir = false;
1712
+ try {
1713
+ phaseDirIsDir = node_fs_1.default.statSync(phaseDir).isDirectory();
1714
+ }
1715
+ catch {
1716
+ // not found → not a directory
1717
+ }
1718
+ if (!phaseDirIsDir) {
1719
+ error(`phase directory not found: ${phaseDirArg} — verification.fingerprint takes the phase directory first, then the covered files`);
1720
+ return;
1721
+ }
1722
+ const parsed = parseFingerprintFileArgs(fileArgs);
1723
+ if ('error' in parsed) {
1724
+ error(parsed.error);
1725
+ return;
1726
+ }
1727
+ const files = parsed.files;
929
1728
  if (files.length === 0) {
930
1729
  error('at least one covered file required for verification.fingerprint');
931
1730
  return;
932
1731
  }
933
- const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
934
- const projectRoot = (0, project_root_cjs_1.findProjectRoot)(phaseDir);
935
- // canonicalizeCoveredFiles here is for the emitted `covered_files` field —
936
- // computeCoveredDigest canonicalizes its own `coveredFiles` argument
937
- // internally too (it must, for callers like readVerificationStatus that
938
- // pass raw, un-canonicalized frontmatter values), so passing an
939
- // already-canonical list keeps that internal pass a cheap no-op rather
940
- // than a second meaningfully different canonicalization.
941
- const uniqueSorted = canonicalizeCoveredFiles(files);
942
- const digest = computeCoveredDigest(projectRoot, uniqueSorted);
1732
+ const projectRoot = (0, project_root_cjs_1.resolveProjectRoot)(phaseDir);
1733
+ // canonicalizeCoveredFiles here is for `deriveCoveredDigest`'s
1734
+ // `coveredFiles` argument — it canonicalizes internally too (it must, for
1735
+ // callers like readVerificationStatus that pass raw, un-canonicalized
1736
+ // frontmatter values), so passing an already-canonical list keeps that
1737
+ // internal pass a cheap no-op rather than a second meaningfully different
1738
+ // canonicalization.
1739
+ const declaredCanonical = canonicalizeCoveredFiles(files, { version: FINGERPRINT_VERSION });
1740
+ // #5095 (R1/R2/R7, ADR-5057 Phase 2): ONE call derives both the emitted
1741
+ // `covered_files` (`.files`, the declared set unioned with the phase's own
1742
+ // live artifacts) and the hashed digest (`.digest`) — the CLI no longer
1743
+ // runs `phaseArtifactPaths` itself and again inside the digest computation,
1744
+ // which used to leave a race window where the two calls could see a
1745
+ // different phase directory and disagree. An artifact-resolution failure
1746
+ // fails the WHOLE command (fail closed: the emitter cannot vouch for a set
1747
+ // it could not fully see).
1748
+ const derivation = deriveCoveredDigest(projectRoot, declaredCanonical, FINGERPRINT_VERSION, { phaseDir });
1749
+ if (!derivation.ok) {
1750
+ error(`could not compute fingerprint — ${derivation.reason}`);
1751
+ return;
1752
+ }
1753
+ const unionSorted = derivation.files;
1754
+ if (unionSorted.length === 0) {
1755
+ error('at least one covered file required for verification.fingerprint');
1756
+ return;
1757
+ }
1758
+ const digest = derivation.digest;
943
1759
  if (digest === null) {
1760
+ // #4623: name the one null that is NOT a bad path — a declaration made
1761
+ // only of shared planning documents (with no other evidence) hashes
1762
+ // nothing under v2+, and the generic message below would send the caller
1763
+ // looking for a missing file that is not missing. Discriminated AFTER
1764
+ // the versioned attempt, and only when a v1 pass over the same list
1765
+ // (which hashes, and therefore validates, every path) succeeds: an
1766
+ // all-shared list with a missing or directory member is a bad path
1767
+ // first, and gets the generic message. #5095 (R5): a phase WITH real
1768
+ // artifacts never reaches this branch — the union above already supplied
1769
+ // evidence — so this error is reachable only when every declared path is
1770
+ // a shared planning doc AND the phase has no plans/summaries of its own.
1771
+ const sharedRoots = sharedRootsFor(projectRoot, phaseDir, derivation.mappedPhaseDir);
1772
+ if (unionSorted.every((f) => isSharedPlanningDoc(f, sharedRoots)) &&
1773
+ computeCoveredDigest(projectRoot, unionSorted, 1) !== null) {
1774
+ error(`could not compute fingerprint — every covered file is a repo-wide planning document (direct children of ${sharedRoots.join(', ')} never enter the digest); declare the phase's own artifacts and implementation files`);
1775
+ return;
1776
+ }
944
1777
  error('could not compute fingerprint — a covered file is missing, unreadable, or escapes the project root');
945
1778
  return;
946
1779
  }
947
- output({ covered_files: uniqueSorted, covered_digest: digest }, raw, digest);
1780
+ output({ covered_files: unionSorted, covered_digest: digest }, raw, digest);
1781
+ }
1782
+ // ─── verification.append-audit (#5105 R3) ──────────────────────────────────
1783
+ /**
1784
+ * Render a single `## <heading> <date>` block followed by a `| Metric |
1785
+ * Count |` table over `rows` — the exact shape `secure-phase.md` /
1786
+ * `validate-phase.md` compose by hand today (#4887 Defect 2, #4981).
1787
+ */
1788
+ function renderAuditBlock(heading, date, rows) {
1789
+ const lines = [`## ${heading} ${date}`, '', '| Metric | Count |', '|---|---|'];
1790
+ for (const [k, v] of Object.entries(rows))
1791
+ lines.push(`| ${k} | ${String(v)} |`);
1792
+ return lines.join('\n') + '\n';
1793
+ }
1794
+ /**
1795
+ * #5105 (S4): parse a block body's `| Metric | Count |`-shaped table into a
1796
+ * plain metric→count string map, addressed by the table's ACTUAL first/second
1797
+ * column (never a hard-coded `Metric`/`Count` name) so a legacy block with
1798
+ * different header text still compares. Whitespace/separator-width tolerant
1799
+ * by construction — `parseMarkdownTable` trims every cell and accepts any
1800
+ * `-{1,}` delimiter width. Returns `null` when the body carries no parseable
1801
+ * 2+-column table (no prior block to compare against).
1802
+ */
1803
+ function parseAuditTableRows(bodyText) {
1804
+ const parsed = (0, markdown_table_cjs_1.parseMarkdownTable)(bodyText);
1805
+ if (!parsed.ok || parsed.value.columns.length < 2)
1806
+ return null;
1807
+ const [metricCol, countCol] = parsed.value.columns;
1808
+ const map = {};
1809
+ for (const row of parsed.value.rows) {
1810
+ map[row[metricCol]] = String(row[countCol]).trim();
1811
+ }
1812
+ return map;
1813
+ }
1814
+ /** Order-insensitive equality over two metric→count maps. */
1815
+ function auditRowsEqual(live, candidate) {
1816
+ if (!live)
1817
+ return false;
1818
+ const liveKeys = Object.keys(live);
1819
+ const candidateKeys = Object.keys(candidate);
1820
+ if (liveKeys.length !== candidateKeys.length)
1821
+ return false;
1822
+ return liveKeys.every((k) => Object.prototype.hasOwnProperty.call(candidate, k) && live[k] === candidate[k]);
1823
+ }
1824
+ /**
1825
+ * #5105 R3 — pure core of `verification.append-audit`.
1826
+ *
1827
+ * Finds the LAST `## <heading> <date>` block in `content` — a level-2
1828
+ * heading whose text matches `^<heading> (\d{4}-\d{2}-\d{2})\b` — via the
1829
+ * shared, fence-aware `tokenizeHeadings`/`collectSection` primitives (#5105
1830
+ * S6) instead of a hand-rolled `^## ` scan: a `## <heading> <date>`-looking
1831
+ * line inside a fenced code block is not a heading and cannot be selected,
1832
+ * and a heading whose trailing word ISN'T a date (e.g. the template's bare
1833
+ * `## Security Audit Trail`) is not matched either (#5105 S4 — the anchored
1834
+ * heading date shape, not `(\S+)`, is what excludes it).
1835
+ *
1836
+ * Comparison (#5105 S4) is over the block's PARSED table rows
1837
+ * (`parseAuditTableRows`/`auditRowsEqual`) — whitespace/separator-width
1838
+ * insensitive, order-insensitive, and tolerant of an optional blank line
1839
+ * after the heading — never a byte-for-byte body string compare. Identical
1840
+ * rows on the last block → `{ appended: false }`, no write. Different rows
1841
+ * (or no prior block) → appends the new block at the end and returns
1842
+ * `{ appended: true }`.
1843
+ *
1844
+ * Deliberately compares against the LAST block only, never any earlier one —
1845
+ * a re-audit that regresses back to an earlier count must still append.
1846
+ *
1847
+ * `date` defaults through the `clock` seam (default: the global `Date`
1848
+ * constructor) rather than a bare `new Date()` call, so a caller can pin the
1849
+ * date deterministically — directly (pass `clock`) or via `node:test`
1850
+ * `mock.timers` (which replaces global `Date`, picked up automatically since
1851
+ * the default is evaluated per call).
1852
+ */
1853
+ function planAuditAppend(content, { heading, rows, date, clock = Date }) {
1854
+ const resolvedDate = date ?? new clock().toISOString().slice(0, 10);
1855
+ const escapedHeading = (0, pattern_cjs_1.escapeRegex)(heading);
1856
+ const headingRe = new RegExp(`^${escapedHeading} (\\d{4}-\\d{2}-\\d{2})\\b`);
1857
+ const matchingHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).filter((h) => h.level === 2 && headingRe.test(h.text));
1858
+ const newBlock = renderAuditBlock(heading, resolvedDate, rows);
1859
+ const newRowsMap = {};
1860
+ for (const [k, v] of Object.entries(rows))
1861
+ newRowsMap[k] = String(v);
1862
+ if (matchingHeadings.length > 0) {
1863
+ const last = matchingHeadings[matchingHeadings.length - 1];
1864
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.offset === last.offset);
1865
+ const liveRows = section ? parseAuditTableRows(section.body) : null;
1866
+ if (auditRowsEqual(liveRows, newRowsMap)) {
1867
+ return { appended: false, content };
1868
+ }
1869
+ }
1870
+ const trimmed = content.replace(/\s+$/, '');
1871
+ const appendedContent = (trimmed.length > 0 ? trimmed + '\n\n' : '') + newBlock;
1872
+ return { appended: true, content: appendedContent };
1873
+ }
1874
+ /** Reject a `\r`, `\n`, or `|` — any of the three would corrupt the rendered heading/table shape. */
1875
+ function hasForbiddenAuditChar(s) {
1876
+ return /[\r\n|]/.test(s);
1877
+ }
1878
+ /** `rows` values must be a non-negative integer, as either a JSON number or an all-digit string. */
1879
+ function isNonNegativeIntegerValue(v) {
1880
+ if (typeof v === 'number')
1881
+ return Number.isInteger(v) && v >= 0;
1882
+ if (typeof v === 'string')
1883
+ return /^\d+$/.test(v);
1884
+ return false;
1885
+ }
1886
+ /** Reject a value carrying leading/trailing whitespace — a heading or row key
1887
+ * with padding would not match `parseMarkdownTable`'s trimmed reads on a
1888
+ * later append, so the same key would silently fail to be recognized as the
1889
+ * "already present" row (re-review finding 6). */
1890
+ function hasLeadingOrTrailingWhitespace(s) {
1891
+ return s !== s.trim();
1892
+ }
1893
+ /** True calendar-date check for `--date` (re-review finding 9): rejects an
1894
+ * out-of-range month/day (e.g. `2026-99-99`) or a day that does not exist in
1895
+ * that month (e.g. `2026-02-30`), which `/^\d{4}-\d{2}-\d{2}$/` alone lets
1896
+ * through — `Date.UTC` normalizes overflow instead of raising, so the parsed
1897
+ * fields must be compared back against the input. */
1898
+ function isRealCalendarDate(date) {
1899
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
1900
+ if (!m)
1901
+ return false;
1902
+ const year = Number(m[1]);
1903
+ const month = Number(m[2]);
1904
+ const day = Number(m[3]);
1905
+ const d = new Date(Date.UTC(year, month - 1, day));
1906
+ return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
1907
+ }
1908
+ /**
1909
+ * #5105 S3 — validate `verification.append-audit` input shape before it ever
1910
+ * reaches `planAuditAppend`/the file. `--rows` being valid JSON (checked by
1911
+ * the caller before this runs) is necessary but not sufficient: a newline,
1912
+ * `\r`, or `|` in the heading or any row key/value would corrupt the
1913
+ * rendered `## heading date` line or `| key | value |` row, and a non-integer
1914
+ * or negative count is not a countable metric.
1915
+ */
1916
+ function validateAuditAppendInput(heading, rows, date) {
1917
+ if (hasForbiddenAuditChar(heading)) {
1918
+ return { ok: false, reason: '--heading must not contain a newline, carriage return, or |' };
1919
+ }
1920
+ if (hasLeadingOrTrailingWhitespace(heading)) {
1921
+ return { ok: false, reason: '--heading must not have leading or trailing whitespace' };
1922
+ }
1923
+ for (const [key, value] of Object.entries(rows)) {
1924
+ if (hasForbiddenAuditChar(key)) {
1925
+ return { ok: false, reason: `--rows key ${JSON.stringify(key)} must not contain a newline, carriage return, or |` };
1926
+ }
1927
+ if (hasLeadingOrTrailingWhitespace(key)) {
1928
+ return { ok: false, reason: `--rows key ${JSON.stringify(key)} must not have leading or trailing whitespace` };
1929
+ }
1930
+ if (typeof value === 'string' && hasForbiddenAuditChar(value)) {
1931
+ return { ok: false, reason: `--rows value for ${JSON.stringify(key)} must not contain a newline, carriage return, or |` };
1932
+ }
1933
+ if (!isNonNegativeIntegerValue(value)) {
1934
+ return { ok: false, reason: `--rows value for ${JSON.stringify(key)} must be a non-negative integer` };
1935
+ }
1936
+ }
1937
+ if (date !== undefined && !isRealCalendarDate(date)) {
1938
+ return { ok: false, reason: '--date must be a real calendar date in YYYY-MM-DD form' };
1939
+ }
1940
+ return { ok: true };
1941
+ }
1942
+ /**
1943
+ * #5105 S2 — case-insensitive re-implementation of `isVerificationReportPath`'s
1944
+ * shape. That helper is deliberately case-SENSITIVE (matching
1945
+ * `resolveVerificationFile`'s own convention — see its doc comment), so it
1946
+ * cannot be reused directly for a containment refusal that must catch a
1947
+ * lowercase `07-verification.md` too.
1948
+ */
1949
+ function isVerificationReportBasenameCI(basename) {
1950
+ const lower = basename.toLowerCase();
1951
+ return lower === 'verification.md' || lower.endsWith('-verification.md');
1952
+ }
1953
+ /** #5105 S2 — the only two shapes `verification.append-audit` may target. */
1954
+ function isAllowedAuditTargetBasenameCI(basename) {
1955
+ const lower = basename.toLowerCase();
1956
+ return lower.endsWith('-security.md') || lower.endsWith('-validation.md');
1957
+ }
1958
+ /**
1959
+ * CLI command handler (#5105 R3): `verification.append-audit <file>
1960
+ * --heading <H> --rows '<json {metric:count}>' [--date <YYYY-MM-DD>]`.
1961
+ *
1962
+ * Never reads or writes `covered_files`/`covered_digest` (#4981 invariant
1963
+ * 5) — a genuinely changed count publishes and stales any report that covers
1964
+ * `file`; nothing here ever restamps it.
1965
+ *
1966
+ * #5105 S2: `file` is resolved through the same `requireSafePath(...,
1967
+ * PathAcceptance.AbsoluteInsideRoot)` seam `uat.complete-session` uses —
1968
+ * refusing an absolute-outside-root target or a `../` escape by throwing
1969
+ * before any read/write is attempted (uncaught here, matching every other
1970
+ * `requireSafePath` call site in this codebase — a top-level command
1971
+ * dispatcher turns the throw into a failed exit). The REAL (symlink-resolved)
1972
+ * basename is then checked twice, case-insensitively: it must not be a
1973
+ * verification report itself, and it must be a `*-SECURITY.md` or
1974
+ * `*-VALIDATION.md` file — the only two artifact kinds this command may
1975
+ * mutate.
1976
+ */
1977
+ function cmdVerificationAppendAudit(cwd, fileArg, argTokens, raw) {
1978
+ if (!fileArg) {
1979
+ error('file required for verification.append-audit');
1980
+ return;
1981
+ }
1982
+ const { heading, rows: rowsArg, date: dateArg } = (0, command_arg_projection_cjs_1.parseNamedArgsOrExit)(argTokens, { valueFlags: ['heading', 'rows', 'date'], positionals: 0 }, error);
1983
+ if (!heading) {
1984
+ error('--heading required for verification.append-audit');
1985
+ return;
1986
+ }
1987
+ if (!rowsArg) {
1988
+ error('--rows required for verification.append-audit');
1989
+ return;
1990
+ }
1991
+ let rows;
1992
+ try {
1993
+ const parsedRows = JSON.parse(rowsArg);
1994
+ if (!parsedRows || typeof parsedRows !== 'object' || Array.isArray(parsedRows)) {
1995
+ throw new Error('not an object');
1996
+ }
1997
+ rows = parsedRows;
1998
+ }
1999
+ catch {
2000
+ error('--rows must be a JSON object for verification.append-audit');
2001
+ return;
2002
+ }
2003
+ const date = dateArg ?? undefined;
2004
+ const validation = validateAuditAppendInput(heading, rows, date);
2005
+ if (!validation.ok) {
2006
+ error(validation.reason);
2007
+ return;
2008
+ }
2009
+ const resolvedPath = (0, security_cjs_1.requireSafePath)(fileArg, cwd, 'verification.append-audit file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
2010
+ const realBasename = node_path_1.default.basename(resolvedPath);
2011
+ if (isVerificationReportBasenameCI(realBasename)) {
2012
+ error('verification.append-audit refuses a verification report path');
2013
+ return;
2014
+ }
2015
+ if (!isAllowedAuditTargetBasenameCI(realBasename)) {
2016
+ error('verification.append-audit target must be a *-SECURITY.md or *-VALIDATION.md file');
2017
+ return;
2018
+ }
2019
+ let content;
2020
+ try {
2021
+ content = node_fs_1.default.readFileSync(resolvedPath, 'utf-8');
2022
+ }
2023
+ catch {
2024
+ error(`file not found: ${fileArg}`);
2025
+ return;
2026
+ }
2027
+ const result = planAuditAppend(content, { heading: heading, rows, date });
2028
+ if (result.appended) {
2029
+ node_fs_1.default.writeFileSync(resolvedPath, result.content);
2030
+ }
2031
+ output({ appended: result.appended }, raw);
948
2032
  }
949
- module.exports = {
2033
+ const verificationModule = {
2034
+ VERIFICATION_STATUS,
950
2035
  VERIFIER_STATUSES,
951
- VERIFICATION_ROUTING_TABLE,
2036
+ VERIFICATION_ROUTES,
2037
+ isVerificationStatus,
2038
+ assertVerificationStatus,
2039
+ VerificationStatusError,
2040
+ VERIFICATION_STATUS_ERROR_CODE,
2041
+ failOnVerificationStatusError,
2042
+ firstStatusError,
2043
+ reportStatusOf,
2044
+ isReportContained,
2045
+ routeResult,
2046
+ findVerificationStatusError,
952
2047
  defaultPhaseCleanCommitTimesMs,
2048
+ resolvePhaseArtifactFile,
953
2049
  resolveVerificationFile,
954
2050
  resolveUatFile,
955
2051
  findStaleVerificationSummary,
@@ -958,5 +2054,13 @@ module.exports = {
958
2054
  cmdVerificationStatus,
959
2055
  cmdVerificationResolveFile,
960
2056
  computeCoveredDigest,
2057
+ sharedPlanningRoots,
2058
+ isSharedPlanningDoc,
2059
+ isVerificationReportPath,
2060
+ parseFingerprintVersion,
2061
+ parseFingerprintFileArgs,
961
2062
  cmdVerificationFingerprint,
2063
+ planAuditAppend,
2064
+ cmdVerificationAppendAudit,
962
2065
  };
2066
+ module.exports = verificationModule;