@opengsd/gsd-core 1.15.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 (487) 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/agents/gsd-code-fixer.compact.md +1 -1
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-code-reviewer.compact.md +5 -3
  7. package/agents/gsd-code-reviewer.md +8 -6
  8. package/agents/gsd-debug-session-manager.compact.md +1 -1
  9. package/agents/gsd-debug-session-manager.md +1 -1
  10. package/agents/gsd-debugger.md +2 -2
  11. package/agents/gsd-eval-auditor.compact.md +1 -1
  12. package/agents/gsd-eval-auditor.md +1 -1
  13. package/agents/gsd-executor.md +4 -4
  14. package/agents/gsd-intel-updater.compact.md +1 -1
  15. package/agents/gsd-intel-updater.md +1 -1
  16. package/agents/gsd-mempalace-curator.md +2 -2
  17. package/agents/gsd-phase-researcher.md +1 -1
  18. package/agents/gsd-plan-checker.md +7 -2
  19. package/agents/gsd-planner.md +3 -3
  20. package/agents/gsd-project-researcher.compact.md +1 -1
  21. package/agents/gsd-project-researcher.md +1 -1
  22. package/agents/gsd-research-synthesizer.compact.md +1 -1
  23. package/agents/gsd-research-synthesizer.md +1 -1
  24. package/agents/gsd-ui-auditor.compact.md +21 -30
  25. package/agents/gsd-ui-auditor.md +42 -51
  26. package/agents/gsd-ui-researcher.compact.md +1 -1
  27. package/agents/gsd-ui-researcher.md +1 -1
  28. package/agents/gsd-verifier.md +31 -9
  29. package/bin/install.js +126 -196
  30. package/commands/gsd/add-tests.md +6 -1
  31. package/commands/gsd/ai-integration-phase.md +6 -1
  32. package/commands/gsd/audit-fix.md +5 -0
  33. package/commands/gsd/audit-milestone.md +6 -1
  34. package/commands/gsd/autonomous.md +5 -0
  35. package/commands/gsd/capture.md +8 -4
  36. package/commands/gsd/code-review.md +7 -2
  37. package/commands/gsd/complete-milestone.md +4 -0
  38. package/commands/gsd/config.md +7 -3
  39. package/commands/gsd/debug.md +11 -7
  40. package/commands/gsd/discuss-phase.md +7 -3
  41. package/commands/gsd/docs-update.md +12 -7
  42. package/commands/gsd/eval-review.md +6 -1
  43. package/commands/gsd/execute-phase.md +12 -7
  44. package/commands/gsd/extract-learnings.md +5 -0
  45. package/commands/gsd/fast.md +4 -0
  46. package/commands/gsd/forensics.md +5 -1
  47. package/commands/gsd/graphify.md +10 -6
  48. package/commands/gsd/health.md +5 -0
  49. package/commands/gsd/help.md +7 -2
  50. package/commands/gsd/import.md +7 -3
  51. package/commands/gsd/inbox.md +5 -0
  52. package/commands/gsd/ingest-docs.md +5 -1
  53. package/commands/gsd/manager.md +6 -1
  54. package/commands/gsd/map-codebase.md +7 -3
  55. package/commands/gsd/mempalace-capture.md +5 -1
  56. package/commands/gsd/mempalace-recall.md +5 -1
  57. package/commands/gsd/milestone-summary.md +5 -1
  58. package/commands/gsd/mvp-phase.md +8 -3
  59. package/commands/gsd/new-milestone.md +6 -1
  60. package/commands/gsd/new-project.md +5 -0
  61. package/commands/gsd/next.md +6 -1
  62. package/commands/gsd/ns-context.md +4 -0
  63. package/commands/gsd/ns-ideate.md +4 -0
  64. package/commands/gsd/ns-manage.md +4 -0
  65. package/commands/gsd/ns-project.md +4 -0
  66. package/commands/gsd/ns-review.md +4 -0
  67. package/commands/gsd/ns-workflow.md +4 -0
  68. package/commands/gsd/onboard.md +6 -1
  69. package/commands/gsd/pause-work.md +5 -1
  70. package/commands/gsd/phase.md +8 -4
  71. package/commands/gsd/plan-phase.md +6 -1
  72. package/commands/gsd/plan-review-convergence.md +5 -1
  73. package/commands/gsd/pr-branch.md +4 -0
  74. package/commands/gsd/profile-user.md +5 -1
  75. package/commands/gsd/progress.md +6 -1
  76. package/commands/gsd/quick-batch.md +20 -8
  77. package/commands/gsd/quick.md +12 -7
  78. package/commands/gsd/review.md +5 -1
  79. package/commands/gsd/secure-phase.md +6 -1
  80. package/commands/gsd/ship.md +5 -0
  81. package/commands/gsd/sketch.md +7 -2
  82. package/commands/gsd/spec-phase.md +5 -1
  83. package/commands/gsd/spike.md +8 -3
  84. package/commands/gsd/surface.md +5 -1
  85. package/commands/gsd/thread.md +4 -0
  86. package/commands/gsd/ui-phase.md +6 -1
  87. package/commands/gsd/ui-review.md +6 -1
  88. package/commands/gsd/ultraplan-phase.md +5 -1
  89. package/commands/gsd/undo.md +5 -1
  90. package/commands/gsd/update.md +6 -2
  91. package/commands/gsd/validate-phase.md +6 -1
  92. package/commands/gsd/verify-work.md +6 -1
  93. package/commands/gsd/workspace.md +7 -3
  94. package/gsd-core/bin/gsd-tools.cjs +142 -56
  95. package/gsd-core/bin/lib/active-workstream-store.cjs +15 -0
  96. package/gsd-core/bin/lib/agent-install-check.cjs +4 -1
  97. package/gsd-core/bin/lib/audit.cjs +78 -44
  98. package/gsd-core/bin/lib/broken-windows.cjs +13 -13
  99. package/gsd-core/bin/lib/capability-activation.cjs +9 -4
  100. package/gsd-core/bin/lib/capability-registry.cjs +183 -103
  101. package/gsd-core/bin/lib/capability-validator.cjs +16 -0
  102. package/gsd-core/bin/lib/check-auto-mode.cjs +35 -0
  103. package/gsd-core/bin/lib/check-command-router.cjs +164 -1712
  104. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +13 -2
  105. package/gsd-core/bin/lib/cli-exit.cjs +12 -0
  106. package/gsd-core/bin/lib/codex-agent-toml.cjs +11 -8
  107. package/gsd-core/bin/lib/command-aliases.cjs +7 -0
  108. package/gsd-core/bin/lib/command-routing-hub.cjs +48 -1
  109. package/gsd-core/bin/lib/commands.cjs +173 -170
  110. package/gsd-core/bin/lib/complexity-trigger.cjs +8 -7
  111. package/gsd-core/bin/lib/config.cjs +43 -12
  112. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  113. package/gsd-core/bin/lib/coverage.cjs +4 -8
  114. package/gsd-core/bin/lib/decision-coverage-support.cjs +259 -0
  115. package/gsd-core/bin/lib/drift.cjs +177 -42
  116. package/gsd-core/bin/lib/frontmatter-fence.cjs +90 -0
  117. package/gsd-core/bin/lib/frontmatter-splice.cjs +494 -0
  118. package/gsd-core/bin/lib/frontmatter.cjs +413 -234
  119. package/gsd-core/bin/lib/gap-checker.cjs +72 -29
  120. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +381 -0
  121. package/gsd-core/bin/lib/gate-args.cjs +53 -0
  122. package/gsd-core/bin/lib/gate-codebase-drift.cjs +285 -0
  123. package/gsd-core/bin/lib/gate-config.cjs +46 -0
  124. package/gsd-core/bin/lib/gate-context-drift.cjs +141 -0
  125. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +169 -0
  126. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +126 -0
  127. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +555 -0
  128. package/gsd-core/bin/lib/gate-evidence.cjs +138 -0
  129. package/gsd-core/bin/lib/gate-exit.cjs +27 -0
  130. package/gsd-core/bin/lib/gate-gap-analysis-plan-post.cjs +61 -0
  131. package/gsd-core/bin/lib/gate-phase-context.cjs +170 -0
  132. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +1 -1
  133. package/gsd-core/bin/lib/gate-predicate.cjs +165 -0
  134. package/gsd-core/bin/lib/gate-prohibition-enforcement.cjs +94 -0
  135. package/gsd-core/bin/lib/gate-schema-drift.cjs +165 -0
  136. package/gsd-core/bin/lib/gate-tdd-red-evidence.cjs +100 -0
  137. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +182 -0
  138. package/gsd-core/bin/lib/gate-ui-plan.cjs +86 -0
  139. package/gsd-core/bin/lib/gate-ui-safety.cjs +80 -0
  140. package/gsd-core/bin/lib/gate-verdict.cjs +64 -0
  141. package/gsd-core/bin/lib/gate-verify-command-paths.cjs +78 -0
  142. package/gsd-core/bin/lib/gate-verify-failure-directions.cjs +41 -0
  143. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +43 -47
  144. package/gsd-core/bin/lib/health-diagnostic.cjs +45 -8
  145. package/gsd-core/bin/lib/init.cjs +162 -96
  146. package/gsd-core/bin/lib/install-engine.cjs +19 -35
  147. package/gsd-core/bin/lib/install-profiles.cjs +7 -4
  148. package/gsd-core/bin/lib/io.cjs +122 -3
  149. package/gsd-core/bin/lib/loop-resolver.cjs +95 -0
  150. package/gsd-core/bin/lib/markdown-sectionizer.cjs +75 -1
  151. package/gsd-core/bin/lib/milestone.cjs +19 -1
  152. package/gsd-core/bin/lib/model-resolver.cjs +18 -18
  153. package/gsd-core/bin/lib/observability/event.cjs +1 -1
  154. package/gsd-core/bin/lib/observability/logger.cjs +46 -1
  155. package/gsd-core/bin/lib/pattern.cjs +10 -0
  156. package/gsd-core/bin/lib/phase-command-router.cjs +11 -4
  157. package/gsd-core/bin/lib/phase-estimation.cjs +5 -4
  158. package/gsd-core/bin/lib/phase-id.cjs +1 -1
  159. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -2
  160. package/gsd-core/bin/lib/phase-status.cjs +360 -0
  161. package/gsd-core/bin/lib/phase.cjs +280 -80
  162. package/gsd-core/bin/lib/plan-document.cjs +93 -19
  163. package/gsd-core/bin/lib/plan-drift-guard.cjs +5 -0
  164. package/gsd-core/bin/lib/planning-document.cjs +273 -40
  165. package/gsd-core/bin/lib/planning-inspect.cjs +42 -8
  166. package/gsd-core/bin/lib/planning-snapshot.cjs +18 -0
  167. package/gsd-core/bin/lib/planning-workspace.cjs +76 -53
  168. package/gsd-core/bin/lib/pristine-baseline.cjs +10 -0
  169. package/gsd-core/bin/lib/profile-output.cjs +6 -3
  170. package/gsd-core/bin/lib/prohibition-enforcement.cjs +0 -55
  171. package/gsd-core/bin/lib/quick-batch-command-router.cjs +35 -9
  172. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +11 -8
  173. package/gsd-core/bin/lib/real-home-guard.cjs +9 -1
  174. package/gsd-core/bin/lib/report-parser.cjs +269 -0
  175. package/gsd-core/bin/lib/roadmap-command-router.cjs +13 -15
  176. package/gsd-core/bin/lib/roadmap-parser.cjs +82 -15
  177. package/gsd-core/bin/lib/roadmap-upgrade.cjs +127 -65
  178. package/gsd-core/bin/lib/roadmap.cjs +169 -72
  179. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +91 -157
  180. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -1
  181. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +57 -1
  182. package/gsd-core/bin/lib/runtime-homes.cjs +13 -10
  183. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +307 -591
  184. package/gsd-core/bin/lib/runtime-name-policy.cjs +142 -27
  185. package/gsd-core/bin/lib/runtime-slash.cjs +47 -30
  186. package/gsd-core/bin/lib/shell-command-projection.cjs +37 -2
  187. package/gsd-core/bin/lib/smart-entry.cjs +19 -3
  188. package/gsd-core/bin/lib/stale-bake-guard.cjs +32 -48
  189. package/gsd-core/bin/lib/state-contract.cjs +15 -18
  190. package/gsd-core/bin/lib/state-document.cjs +100 -22
  191. package/gsd-core/bin/lib/state.cjs +214 -105
  192. package/gsd-core/bin/lib/surface.cjs +2 -1
  193. package/gsd-core/bin/lib/tdd-red-evidence.cjs +48 -152
  194. package/gsd-core/bin/lib/uat-predicate.cjs +313 -35
  195. package/gsd-core/bin/lib/uat.cjs +424 -7
  196. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +94 -58
  197. package/gsd-core/bin/lib/vendor/README.md +31 -9
  198. package/gsd-core/bin/lib/vendor/saxes.cjs +1934 -0
  199. package/gsd-core/bin/lib/vendor/saxes.cjs.LICENSE.txt +92 -0
  200. package/gsd-core/bin/lib/vendor/tap-parser.cjs +8927 -0
  201. package/gsd-core/bin/lib/vendor/tap-parser.cjs.LICENSE.txt +152 -0
  202. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  203. package/gsd-core/bin/lib/verification.cjs +1165 -314
  204. package/gsd-core/bin/lib/verify-command-router.cjs +18 -7
  205. package/gsd-core/bin/lib/verify.cjs +286 -567
  206. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +14 -6
  207. package/gsd-core/bin/lib/workstream-inventory.cjs +31 -21
  208. package/gsd-core/bin/lib/workstream-name-policy.cjs +31 -1
  209. package/gsd-core/bin/lib/workstream.cjs +11 -2
  210. package/gsd-core/bin/shared/config-defaults.manifest.json +5 -0
  211. package/gsd-core/bin/shared/config-schema.manifest.json +4 -0
  212. package/gsd-core/references/autonomous-smart-discuss.md +2 -1
  213. package/gsd-core/references/autonomous-ui-design-contract.md +3 -3
  214. package/gsd-core/references/execute-mvp-tdd.md +5 -10
  215. package/gsd-core/references/execute-phase-between-wave-reset.md +3 -0
  216. package/gsd-core/references/execute-phase-response-language.md +1 -1
  217. package/gsd-core/references/gsd-run-resolver.md +1 -1
  218. package/gsd-core/references/loop-hook-dispatch.md +7 -1
  219. package/gsd-core/references/offer-next.md +1 -1
  220. package/gsd-core/references/planning-config.md +1 -1
  221. package/gsd-core/references/spidr-splitting.md +1 -1
  222. package/gsd-core/references/tdd.md +10 -4
  223. package/gsd-core/references/verifier-phase-gates.md +5 -2
  224. package/gsd-core/references/verify-mvp-mode.md +2 -2
  225. package/gsd-core/references/workstream-flag.md +33 -3
  226. package/gsd-core/templates/README.md +1 -1
  227. package/gsd-core/templates/UAT.md +17 -1
  228. package/gsd-core/templates/config.json +2 -11
  229. package/gsd-core/templates/verification-report.md +1 -1
  230. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  231. package/gsd-core/workflows/add-backlog.md +1 -1
  232. package/gsd-core/workflows/add-phase.md +8 -7
  233. package/gsd-core/workflows/add-tests.md +3 -2
  234. package/gsd-core/workflows/add-todo.md +4 -3
  235. package/gsd-core/workflows/ai-integration-phase.md +3 -2
  236. package/gsd-core/workflows/audit-fix.md +1 -1
  237. package/gsd-core/workflows/audit-milestone.md +4 -3
  238. package/gsd-core/workflows/audit-uat.md +1 -1
  239. package/gsd-core/workflows/autonomous.md +28 -18
  240. package/gsd-core/workflows/check-todos.md +6 -5
  241. package/gsd-core/workflows/cleanup.md +1 -1
  242. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +20 -13
  243. package/gsd-core/workflows/code-review-fix.md +5 -4
  244. package/gsd-core/workflows/code-review.md +84 -90
  245. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +4 -3
  246. package/gsd-core/workflows/complete-milestone.md +12 -7
  247. package/gsd-core/workflows/debug.md +30 -5
  248. package/gsd-core/workflows/diagnose-issues.md +3 -2
  249. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  250. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  251. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  252. package/gsd-core/workflows/discuss-phase.md +4 -3
  253. package/gsd-core/workflows/do.md +1 -1
  254. package/gsd-core/workflows/docs-update.md +4 -3
  255. package/gsd-core/workflows/edit-phase.md +4 -3
  256. package/gsd-core/workflows/eval-review.md +5 -3
  257. package/gsd-core/workflows/execute-phase/detail/elaboration.md +1 -1
  258. package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +4 -2
  259. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +15 -4
  260. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +10 -7
  261. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
  262. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +3 -1
  263. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +2 -2
  264. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  265. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
  266. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  267. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
  268. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
  269. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -0
  270. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
  271. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +187 -0
  272. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +2 -3
  273. package/gsd-core/workflows/execute-phase.md +78 -170
  274. package/gsd-core/workflows/execute-plan.md +13 -19
  275. package/gsd-core/workflows/explore.md +2 -2
  276. package/gsd-core/workflows/extract-learnings.md +3 -2
  277. package/gsd-core/workflows/fast.md +1 -1
  278. package/gsd-core/workflows/forensics.md +1 -1
  279. package/gsd-core/workflows/graduation.md +1 -1
  280. package/gsd-core/workflows/health.md +2 -1
  281. package/gsd-core/workflows/import.md +3 -2
  282. package/gsd-core/workflows/inbox.md +1 -1
  283. package/gsd-core/workflows/ingest-docs.md +1 -1
  284. package/gsd-core/workflows/insert-phase.md +4 -3
  285. package/gsd-core/workflows/list-seeds.md +1 -1
  286. package/gsd-core/workflows/list-workspaces.md +1 -1
  287. package/gsd-core/workflows/manager.md +5 -3
  288. package/gsd-core/workflows/map-codebase.md +4 -3
  289. package/gsd-core/workflows/milestone-summary.md +3 -2
  290. package/gsd-core/workflows/mvp-phase.md +14 -14
  291. package/gsd-core/workflows/new-milestone.md +10 -10
  292. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  293. package/gsd-core/workflows/new-project.md +1 -1
  294. package/gsd-core/workflows/new-workspace.md +1 -1
  295. package/gsd-core/workflows/next.md +1 -1
  296. package/gsd-core/workflows/pause-work.md +2 -2
  297. package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
  298. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  299. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +1 -1
  300. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
  301. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +1 -1
  302. package/gsd-core/workflows/plan-phase.md +27 -18
  303. package/gsd-core/workflows/plan-review-convergence.md +1 -1
  304. package/gsd-core/workflows/plant-seed.md +1 -1
  305. package/gsd-core/workflows/pr-branch.md +1 -1
  306. package/gsd-core/workflows/profile-user.md +1 -1
  307. package/gsd-core/workflows/progress.md +19 -49
  308. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -2
  309. package/gsd-core/workflows/quick/steps/quick-verification.md +4 -4
  310. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +30 -11
  311. package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
  312. package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
  313. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
  314. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
  315. package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
  316. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
  317. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +9 -3
  318. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
  319. package/gsd-core/workflows/quick-batch.md +15 -10
  320. package/gsd-core/workflows/quick.md +42 -31
  321. package/gsd-core/workflows/remove-phase.md +3 -2
  322. package/gsd-core/workflows/remove-workspace.md +1 -1
  323. package/gsd-core/workflows/resume-project.md +3 -2
  324. package/gsd-core/workflows/review.md +3 -2
  325. package/gsd-core/workflows/scan.md +3 -2
  326. package/gsd-core/workflows/secure-phase.md +11 -12
  327. package/gsd-core/workflows/settings-advanced.md +1 -1
  328. package/gsd-core/workflows/settings-integrations.md +1 -1
  329. package/gsd-core/workflows/settings.md +1 -1
  330. package/gsd-core/workflows/ship.md +12 -12
  331. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  332. package/gsd-core/workflows/sketch.md +1 -1
  333. package/gsd-core/workflows/smart-entry.md +1 -1
  334. package/gsd-core/workflows/spec-phase.md +1 -1
  335. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  336. package/gsd-core/workflows/spike.md +1 -1
  337. package/gsd-core/workflows/stats.md +1 -1
  338. package/gsd-core/workflows/sync-skills.md +1 -1
  339. package/gsd-core/workflows/thread.md +2 -2
  340. package/gsd-core/workflows/transition.md +13 -23
  341. package/gsd-core/workflows/ui-phase.md +5 -4
  342. package/gsd-core/workflows/ui-review.md +4 -3
  343. package/gsd-core/workflows/ultraplan-phase.md +3 -2
  344. package/gsd-core/workflows/undo.md +1 -1
  345. package/gsd-core/workflows/validate-phase.md +10 -12
  346. package/gsd-core/workflows/verify-work/detail/elaboration.md +43 -3
  347. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
  348. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -3
  349. package/gsd-core/workflows/verify-work.md +68 -59
  350. package/hooks/dist/gsd-agent-isolation-guard.js +8 -0
  351. package/hooks/dist/gsd-check-update-worker.js +8 -0
  352. package/hooks/dist/gsd-check-update.js +8 -0
  353. package/hooks/dist/gsd-context-monitor.js +31 -8
  354. package/hooks/dist/gsd-cursor-subagent-start.js +8 -0
  355. package/hooks/dist/gsd-secret-read-guard.js +147 -16
  356. package/hooks/dist/gsd-statusline.js +15 -7
  357. package/hooks/dist/gsd-update-banner.js +8 -0
  358. package/hooks/dist/gsd-windsurf-pre-write.js +11 -2
  359. package/hooks/dist/gsd-workflow-guard.js +5 -4
  360. package/hooks/dist/gsd-worktree-path-guard.js +6 -2
  361. package/hooks/dist/lib/cli-exit.js +12 -0
  362. package/hooks/dist/lib/git-probe.js +17 -1
  363. package/hooks/dist/lib/isolation-sentinel.js +2 -2
  364. package/hooks/gsd-agent-isolation-guard.js +8 -0
  365. package/hooks/gsd-check-update-worker.js +8 -0
  366. package/hooks/gsd-check-update.js +8 -0
  367. package/hooks/gsd-context-monitor.js +31 -8
  368. package/hooks/gsd-cursor-subagent-start.js +8 -0
  369. package/hooks/gsd-secret-read-guard.js +147 -16
  370. package/hooks/gsd-statusline.js +15 -7
  371. package/hooks/gsd-update-banner.js +8 -0
  372. package/hooks/gsd-windsurf-pre-write.js +11 -2
  373. package/hooks/gsd-workflow-guard.js +5 -4
  374. package/hooks/gsd-worktree-path-guard.js +6 -2
  375. package/hooks/hooks.json +5 -5
  376. package/hooks/lib/cli-exit.js +12 -0
  377. package/hooks/lib/git-probe.js +17 -1
  378. package/hooks/lib/isolation-sentinel.js +2 -2
  379. package/package.json +21 -4
  380. package/scripts/changeset/parse.cjs +52 -4
  381. package/scripts/ci-timeout-report.cjs +770 -4
  382. package/scripts/command-contract-helpers.cjs +12 -8
  383. package/scripts/docs-guard-registry.cjs +6 -0
  384. package/scripts/gen-features.cjs +13 -8
  385. package/scripts/gen-hooks-cli-exit.cjs +12 -28
  386. package/scripts/gen-loop-host-contract.cjs +10 -1
  387. package/scripts/gen-platform-conformance-tier.cjs +187 -1
  388. package/scripts/gen-plugin-skills.cjs +87 -1
  389. package/scripts/gen-research-agents.cjs +24 -31
  390. package/scripts/gen-scripts-cli-exit.cjs +30 -3
  391. package/scripts/gen-test-timings.cjs +32 -7
  392. package/scripts/lib/cli-exit.cjs +12 -0
  393. package/scripts/lib/macos-conformance-tier.generated.cjs +20 -2
  394. package/scripts/lib/ndjson-reporter.cjs +28 -3
  395. package/scripts/lib/platform-conformance-tier.generated.cjs +34 -5
  396. package/scripts/lib/registration-ledger-preload.cjs +155 -0
  397. package/scripts/lib/vendor-bundle.cjs +59 -0
  398. package/scripts/lib/vendor-licenses/saxes-6.0.0.txt +64 -0
  399. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  400. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  401. package/scripts/lint-completion-predicate-drift.cjs +18 -19
  402. package/scripts/lint-descriptions.cjs +7 -3
  403. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +10 -0
  404. package/scripts/lint-eslint-glob-coverage.allowlist.json +20 -0
  405. package/scripts/lint-frontmatter-fence-drift.cjs +313 -0
  406. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +122 -16
  407. package/scripts/lint-phase-enumeration-drift.cjs +12 -8
  408. package/scripts/lint-phase-id-drift.cjs +29 -0
  409. package/scripts/lint-planning-document-positive-control.cjs +329 -0
  410. package/scripts/lint-response-language-coverage.cjs +3 -0
  411. package/scripts/lint-skill-deps.cjs +7 -3
  412. package/scripts/lint-test-file-count.allowlist.json +11 -1
  413. package/scripts/lint-test-file-count.cjs +34 -1
  414. package/scripts/lint-vendored-deps.cjs +41 -5
  415. package/scripts/lint-workflow-shellcheck-baseline.json +10 -10
  416. package/scripts/mutation-matrix.cjs +50 -5
  417. package/scripts/require-issue-link-policy.cjs +6 -2
  418. package/scripts/sync-runtime-launcher.cjs +184 -2
  419. package/scripts/verify-npm-publish.cjs +76 -20
  420. package/skills/gsd-add-tests/SKILL.md +6 -1
  421. package/skills/gsd-ai-integration-phase/SKILL.md +6 -1
  422. package/skills/gsd-audit-fix/SKILL.md +5 -0
  423. package/skills/gsd-audit-milestone/SKILL.md +6 -1
  424. package/skills/gsd-autonomous/SKILL.md +5 -0
  425. package/skills/gsd-capture/SKILL.md +8 -4
  426. package/skills/gsd-code-review/SKILL.md +7 -2
  427. package/skills/gsd-complete-milestone/SKILL.md +4 -0
  428. package/skills/gsd-config/SKILL.md +8 -4
  429. package/skills/gsd-debug/SKILL.md +11 -7
  430. package/skills/gsd-discuss-phase/SKILL.md +7 -3
  431. package/skills/gsd-docs-update/SKILL.md +12 -7
  432. package/skills/gsd-eval-review/SKILL.md +6 -1
  433. package/skills/gsd-execute-phase/SKILL.md +12 -7
  434. package/skills/gsd-extract-learnings/SKILL.md +5 -0
  435. package/skills/gsd-fast/SKILL.md +4 -0
  436. package/skills/gsd-forensics/SKILL.md +5 -1
  437. package/skills/gsd-graphify/SKILL.md +10 -6
  438. package/skills/gsd-health/SKILL.md +5 -0
  439. package/skills/gsd-help/SKILL.md +7 -2
  440. package/skills/gsd-import/SKILL.md +7 -3
  441. package/skills/gsd-inbox/SKILL.md +5 -0
  442. package/skills/gsd-ingest-docs/SKILL.md +5 -1
  443. package/skills/gsd-manager/SKILL.md +6 -1
  444. package/skills/gsd-map-codebase/SKILL.md +7 -3
  445. package/skills/gsd-mempalace-capture/SKILL.md +5 -1
  446. package/skills/gsd-mempalace-recall/SKILL.md +5 -1
  447. package/skills/gsd-milestone-summary/SKILL.md +5 -1
  448. package/skills/gsd-mvp-phase/SKILL.md +8 -3
  449. package/skills/gsd-new-milestone/SKILL.md +6 -1
  450. package/skills/gsd-new-project/SKILL.md +5 -0
  451. package/skills/gsd-next/SKILL.md +6 -1
  452. package/skills/gsd-ns-context/SKILL.md +4 -0
  453. package/skills/gsd-ns-ideate/SKILL.md +4 -0
  454. package/skills/gsd-ns-manage/SKILL.md +4 -0
  455. package/skills/gsd-ns-project/SKILL.md +4 -0
  456. package/skills/gsd-ns-review/SKILL.md +4 -0
  457. package/skills/gsd-ns-workflow/SKILL.md +4 -0
  458. package/skills/gsd-onboard/SKILL.md +6 -1
  459. package/skills/gsd-pause-work/SKILL.md +5 -1
  460. package/skills/gsd-phase/SKILL.md +8 -4
  461. package/skills/gsd-plan-phase/SKILL.md +6 -1
  462. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  463. package/skills/gsd-pr-branch/SKILL.md +4 -0
  464. package/skills/gsd-profile-user/SKILL.md +5 -1
  465. package/skills/gsd-progress/SKILL.md +6 -1
  466. package/skills/gsd-quick/SKILL.md +16 -10
  467. package/skills/gsd-quick-batch/SKILL.md +20 -8
  468. package/skills/gsd-review/SKILL.md +5 -1
  469. package/skills/gsd-review-backlog/SKILL.md +3 -2
  470. package/skills/gsd-secure-phase/SKILL.md +6 -1
  471. package/skills/gsd-ship/SKILL.md +5 -0
  472. package/skills/gsd-sketch/SKILL.md +7 -2
  473. package/skills/gsd-spec-phase/SKILL.md +5 -1
  474. package/skills/gsd-spike/SKILL.md +8 -3
  475. package/skills/gsd-surface/SKILL.md +5 -1
  476. package/skills/gsd-thread/SKILL.md +4 -0
  477. package/skills/gsd-ui-phase/SKILL.md +6 -1
  478. package/skills/gsd-ui-review/SKILL.md +6 -1
  479. package/skills/gsd-ultraplan-phase/SKILL.md +5 -1
  480. package/skills/gsd-undo/SKILL.md +5 -1
  481. package/skills/gsd-update/SKILL.md +6 -2
  482. package/skills/gsd-validate-phase/SKILL.md +6 -1
  483. package/skills/gsd-verify-work/SKILL.md +6 -1
  484. package/skills/gsd-workspace/SKILL.md +7 -3
  485. package/skills/gsd-workstreams/SKILL.md +6 -6
  486. package/vscode/package.json +1 -1
  487. package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +0 -24
@@ -49,92 +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
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.
224
+ *
225
+ * `fm` is `extractFrontmatter`'s result for the report at `filePath`.
226
+ */
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`.
74
254
  *
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.
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).
80
260
  */
81
- const VERIFICATION_ROUTING_TABLE = {
82
- passed: {
83
- status: 'passed',
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
- // #4682: staleness means covered source files changed after the verifier
106
- // last ran — the only remedy is re-running the verifier.
107
- // /gsd-verify-work never rewrites VERIFICATION.md, so advising it from
108
- // here was an advice loop. execute-phase resumes at the verification
109
- // gates and re-runs the verifier (its resume tree routes a stale report
110
- // to re-verification), which regenerates VERIFICATION.md and its digest.
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.
111
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.',
112
- next_command: 'execute-phase',
113
- },
114
- // INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
115
- // the file has no parseable frontmatter status. Never emitted by the verifier.
116
- missing: {
117
- status: 'missing',
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({
118
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).',
119
- next_command: 'execute-phase',
120
- },
290
+ command: 'execute-phase',
291
+ tail: '',
292
+ }),
121
293
  // #4806: the report EXISTS but its frontmatter is not parseable YAML —
122
- // fundamentally different from "missing" (the verify step DID run; re-running
123
- // execute-phase cannot fix a YAML typo). Consumers treat any non-'passed'
124
- // status as blocking, so this value fails safe while telling the truth.
125
- unparseable: {
126
- status: 'unparseable',
294
+ // re-running execute-phase cannot fix a YAML typo in an existing report.
295
+ unparseable: Object.freeze({
127
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.",
128
- next_command: '',
129
- },
130
- // INTERNAL SENTINEL: constructed when the file has a status value not in
131
- // VERIFIER_STATUSES. Never emitted by the verifier.
132
- unknown: {
133
- status: 'unknown',
134
- next_action: '', // filled in dynamically with the raw value
135
- next_command: 'execute-phase',
136
- },
137
- };
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
+ });
138
309
  /**
139
310
  * Project a BARE command name (plus optional argument tail) into the surface the
140
311
  * given runtime actually installs (#2617).
@@ -178,11 +349,231 @@ function toPosix(p) {
178
349
  function normalizeRel(p) {
179
350
  return node_path_1.default.posix.normalize(toPosix(p));
180
351
  }
181
- /** Canonicalize a covered-files list: normalize, de-duplicate, sort — the SAME
182
- * transform computeCoveredDigest and cmdVerificationFingerprint both need
183
- * (the digest's own key order; the CLI's own `covered_files` JSON output). */
184
- function canonicalizeCoveredFiles(files) {
185
- 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 };
186
577
  }
187
578
  // ─── #4155: covered-input fingerprint ──────────────────────────────────────────
188
579
  /**
@@ -194,16 +585,34 @@ function canonicalizeCoveredFiles(files) {
194
585
  * v1 (#4155) — every covered path's whole bytes, uniformly.
195
586
  * v2 (#4623) — repo-wide planning documents (`isSharedPlanningDoc`) are
196
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).
197
605
  *
198
606
  * A stored digest names its own version (`v<N>:sha256:…`), and
199
607
  * `readVerificationStatus` recomputes under the STORED version rather than
200
608
  * this constant — so bumping it does not flip every already-verified phase
201
- * to `stale` on upgrade. A legacy v1 report keeps v1 semantics, shared
202
- * documents included, until it is re-fingerprinted; only a version outside
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
203
612
  * `KNOWN_FINGERPRINT_VERSIONS` is unrecomputable and fails closed.
204
613
  */
205
- const FINGERPRINT_VERSION = 2;
206
- const KNOWN_FINGERPRINT_VERSIONS = new Set([1, 2]);
614
+ const FINGERPRINT_VERSION = 3;
615
+ const KNOWN_FINGERPRINT_VERSIONS = new Set([1, 2, 3]);
207
616
  /**
208
617
  * #4623: the planning roots whose DIRECT children are repo-wide planning
209
618
  * documents, as project-root-relative posix paths. Always `.planning`; plus
@@ -265,6 +674,31 @@ function isSharedPlanningDoc(rel, roots = ['.planning']) {
265
674
  return false;
266
675
  return roots.includes(node_path_1.default.posix.dirname(rel));
267
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
+ }
268
702
  /**
269
703
  * #4623: the fingerprint version a stored `covered_digest` was computed
270
704
  * under, or `null` when the prefix is absent, malformed, or names a version
@@ -278,101 +712,90 @@ function parseFingerprintVersion(digest) {
278
712
  const version = Number(m[1]);
279
713
  return KNOWN_FINGERPRINT_VERSIONS.has(version) ? version : null;
280
714
  }
281
- /**
282
- * #4155: recompute the deterministic content fingerprint over a verifier's
283
- * declared covered-input set (phase PLAN/SUMMARY, mapped requirements,
284
- * implementation files in the change set) and return the versioned digest
285
- * string, or `null` if the set cannot be resolved.
286
- *
287
- * Determinism: paths are de-duplicated and SORTED before hashing (directory
288
- * enumeration order is irrelevant), each path is resolved relative to
289
- * `projectRoot` (the absolute checkout path never enters the digest), and
290
- * file BYTES are hashed (mtime never enters the digest).
291
- *
292
- * NOT normalized: line endings. Unlike the report-frontmatter read (which
293
- * runs every VERIFICATION.md through `normalizeLineEndings`), covered-file
294
- * bytes are hashed exactly as they sit on disk. A covered text file checked
295
- * out with CRLF line endings (e.g. a Windows checkout without a `.gitattributes
296
- * eol=lf` rule pinning it to LF) hashes differently than the same file on an
297
- * LF checkout — a real cross-platform digest mismatch, not a bug, since GSD
298
- * installs into arbitrary user projects with no guaranteed line-ending policy.
299
- *
300
-
301
- * Fail closed: a covered path that is empty, absolute, escapes
302
- * `projectRoot` (`..` traversal), or cannot be read (missing, unreadable,
303
- * not a regular file) makes the WHOLE fingerprint unresolvable — returns
304
- * `null` — rather than silently hashing a partial set. Callers treat `null`
305
- * as stale (#4155), the same fail-closed shape #3057 B3 established for the
306
- * legacy mtime staleness check.
307
- *
308
- * Always reads through the REAL `node:fs`, never a caller-injected `FsLike`
309
- * seam — same reasoning as the root canonicalization below, extended to
310
- * every covered file: `covered_files` is expected to span the whole
311
- * `projectRoot` (implementation files under `src/`, not just `.planning/`
312
- * artifacts), so a caller-scoped containment wrapper narrower than
313
- * `projectRoot` (e.g. `planning-inspect.cts`'s `containmentEnforcingVerificationFs`,
314
- * confined to `.planning/`) would reject every implementation-file read and
315
- * report EVERY fingerprinted phase permanently `stale` regardless of actual
316
- * drift — the bug this comment now documents against regressing. The
317
- * `realRel`-vs-`realRoot` re-check a few lines below already does the real
318
- * confinement work (against `projectRoot`, the correct boundary for this
319
- * data), so no security property is lost by bypassing a narrower seam here.
320
- */
321
- function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
715
+ function deriveCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
322
716
  // #4623: `version` selects the input shape to hash under — the CURRENT
323
717
  // one for a fresh fingerprint (the CLI verb), or the STORED one when
324
718
  // `readVerificationStatus` recomputes against a report's own digest.
325
- // `opts.phaseDir` lets v2 recognise the phase's own planning root
719
+ // `opts.phaseDir` lets v2+ recognise the phase's own planning root
326
720
  // (`sharedPlanningRoots`); without it only `.planning/` itself is shared.
327
721
  if (!KNOWN_FINGERPRINT_VERSIONS.has(version))
328
- return null;
329
- const uniqueSorted = canonicalizeCoveredFiles(coveredFiles);
330
- if (uniqueSorted.length === 0)
331
- return null;
332
- const sharedRoots = version >= 2 ? sharedPlanningRoots(projectRoot, opts.phaseDir) : [];
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) : [];
333
748
  let hashed = 0;
334
- // Canonicalize the root ONCE — every candidate's realpath is checked against
335
- // this, not the possibly-symlinked `projectRoot` argument itself. Always via
336
- // the REAL fs, never fsImpl: `projectRoot` is a trusted anchor the CALLER
337
- // derived (resolveProjectRoot), not attacker-influenced covered-input data —
338
- // routing it through a caller-scoped containment seam (e.g. #4155's
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
339
755
  // containmentEnforcingVerificationFs, confined to `.planning/`, a proper
340
756
  // SUBSET of `projectRoot`) would reject the root itself and fail every
341
757
  // lookup regardless of whether the covered files are legitimate.
342
- let realRoot;
343
- try {
344
- realRoot = node_fs_1.default.realpathSync(projectRoot);
345
- }
346
- catch {
347
- return null;
348
- }
758
+ const roots = resolveContainmentRoots(projectRoot);
759
+ if (roots.realRoot === null)
760
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
349
761
  const parts = [];
350
- for (const rel of uniqueSorted) {
762
+ for (const rel of declared) {
351
763
  // `normalizeRel` (already applied by `canonicalizeCoveredFiles` above)
352
764
  // collapses internal `..` segments before `rel` ever reaches here
353
765
  // (`a/../../b` → `../b`), so this start-of-string check is already the
354
766
  // full lexical confinement test — no separate post-`path.resolve`
355
767
  // re-check can observe a different answer.
356
- if (rel === '' || rel === '..' || rel.startsWith('../') || node_path_1.default.isAbsolute(rel))
357
- 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];
358
779
  const resolved = node_path_1.default.resolve(projectRoot, rel);
359
780
  let bytes;
360
781
  try {
361
- // A regular file INSIDE projectRoot can still be a symlink whose TARGET
362
- // escapes it — statSync/readFileSync follow symlinks, so the lexical
363
- // confinement check above is not enough. realpathSync resolves the
364
- // 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.
365
787
  const real = node_fs_1.default.realpathSync(resolved);
366
- // Both operands are already realpath-resolved (this fn's own realpathSync calls
367
- // above), so the shared containment comparison applies directly (ADR-4650) —
368
- // no re-resolution through assertWithinRoot/tryWithinRoot, which would redo work
369
- // this function already owns for its exists-vs-escaped tri-state.
370
- if (!(0, security_cjs_1.isContainedIn)(real, realRoot)) {
371
- 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 };
372
795
  }
373
796
  const st = node_fs_1.default.statSync(real);
374
797
  if (!st.isFile())
375
- return null;
798
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
376
799
  // #4623 (v2+): a repo-wide planning document is VALIDATED exactly as
377
800
  // every other covered path — confined, present, a regular file; the
378
801
  // fail-closed contract above is unchanged — but its bytes contribute
@@ -385,7 +808,7 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
385
808
  bytes = node_fs_1.default.readFileSync(real);
386
809
  }
387
810
  catch {
388
- return null;
811
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
389
812
  }
390
813
  const fileHash = node_crypto_1.default.createHash('sha256').update(bytes).digest('hex');
391
814
  parts.push(`${rel}\n${fileHash}\n`);
@@ -395,13 +818,26 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
395
818
  // evidence in it at all — a constant digest over the header would satisfy
396
819
  // the fingerprint pair while grounding the verification in nothing. Fail
397
820
  // closed, the same way an empty declaration does.
398
- if (version >= 2 && hashed === 0)
399
- return null;
821
+ if (version >= 2 && hashed === 0) {
822
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
823
+ }
400
824
  const aggregate = node_crypto_1.default
401
825
  .createHash('sha256')
402
826
  .update(`v${version}\n${parts.join('')}`, 'utf-8')
403
827
  .digest('hex');
404
- return `v${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;
405
841
  }
406
842
  /**
407
843
  * #4155: the content fingerprint only recomputes digests for paths the
@@ -508,18 +944,159 @@ function defaultPhaseCleanCommitTimesMs(phaseDir, files, execGitFn = shell_comma
508
944
  return commitTimes;
509
945
  }
510
946
  /**
511
- * Build a 'missing' result from the routing table.
512
- * Used for two early-return paths: no *-VERIFICATION.md file found, and
513
- * 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.
514
951
  */
515
- function missingResult(runtime, phaseArg) {
516
- const route = VERIFICATION_ROUTING_TABLE['missing'];
952
+ function routeResult(status, ctx) {
953
+ const entry = VERIFICATION_ROUTES[status];
517
954
  return {
518
- status: route.status,
519
- next_action: route.next_action,
520
- 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 } : {}),
521
961
  };
522
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
+ }
523
1100
  /**
524
1101
  * #3518: the shared phase-pinned artifact-selection core BOTH single-pick
525
1102
  * resolvers (`resolveVerificationFile` for `*-VERIFICATION.md`,
@@ -735,7 +1312,16 @@ function findStaleVerificationSummary(phaseDir, fsImpl = defaultFsImpl, phaseCle
735
1312
  * 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
736
1313
  * parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
737
1314
  * If no frontmatter block or no `status` key → status 'missing'.
738
- * 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).
739
1325
  *
740
1326
  * The internal staleness check can itself fail (fs / scanPhasePlans / clock
741
1327
  * error); when it does, `status` is routed as if nothing were stale (the
@@ -769,84 +1355,26 @@ function readVerificationStatus(phaseDir, opts = {}) {
769
1355
  // that already know the number (init) pass it explicitly and always get it.
770
1356
  const phaseArgSource = opts.phaseNumber ?? (/^\d+(\.\d+)*$/.test(derivedPhaseNumber) ? derivedPhaseNumber : '');
771
1357
  const phaseArg = phaseArgSource ? ` ${phaseArgSource}` : '';
772
- // 1. Find *-VERIFICATION.md
773
- let verificationFile = null;
774
- try {
775
- const entries = fsImpl.readdirSync(phaseDir);
776
- // #3492: pin selection to THIS phase's own token so a stray cross-phase
777
- // or sentinel-numbered canonically-shaped file cannot outrank this phase's
778
- // own report. #612: derive a separate convention-aware RESOLUTION token
779
- // for bracket directories while the routed command argument above stays
780
- // convention-less and milestone-unambiguous. #4187: keep the bare report
781
- // tier aligned with every other verification reader.
782
- const resolutionToken = opts.convention === 'bracket'
783
- ? extractPhaseToken(baseName, opts.convention)
784
- : phaseToken;
785
- verificationFile = resolveVerificationFile(entries, {
786
- allowBare: true,
787
- phaseToken: resolutionToken,
788
- phaseDirName: baseName,
789
- convention: opts.convention,
790
- });
791
- }
792
- catch {
793
- // Directory unreadable → treat as missing
794
- verificationFile = null;
795
- }
796
- if (!verificationFile) {
797
- return missingResult(runtime, phaseArg);
798
- }
799
- // 2. Read and parse frontmatter using the shared parser.
800
- // extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
801
- const filePath = node_path_1.default.join(phaseDir, verificationFile);
802
- let rawStatus = null;
803
- let fm = {};
804
- try {
805
- // #3707-CR follow-up MINOR 1: normalize line endings at this read
806
- // boundary — this function's own `readFileSync` is the equivalent seam
807
- // `planning.inspect`'s `buildUatRows`/`readDocument` route through for
808
- // UAT/REQUIREMENTS documents, but `readVerificationStatus` had no such
809
- // normalization of its own. A lone-CR VERIFICATION.md's `---\r...\r---`
810
- // frontmatter fence never matched `extractFrontmatter`'s byte-0
811
- // `---\n`/`---\r\n` check, so `status: passed` was read as absent and
812
- // this function reported 'missing' — under-reporting a completed
813
- // verification as if the step never ran, the fail-safe direction but the
814
- // same root cause as the false-clean class fixed elsewhere in #3707-CR.
815
- const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
816
- fm = extractFrontmatter(content, filePath);
817
- // #4806: an unparseable frontmatter block is NOT "missing" — the file
818
- // exists and verification ran. Report a distinct status so the caller is
819
- // sent to fix the YAML, not to re-run execute-phase.
820
- if (fm[FRONTMATTER_UNPARSEABLE] === true) {
821
- return {
822
- status: 'unparseable',
823
- 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.",
824
- next_command: '',
825
- };
826
- }
827
- const statusVal = fm['status'];
828
- // status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
829
- // only accept string values — arrays and objects are not valid status values.
830
- if (typeof statusVal === 'string') {
831
- const trimmed = statusVal.trim();
832
- rawStatus = trimmed.length > 0 ? trimmed : null;
833
- }
834
- }
835
- catch {
836
- rawStatus = null;
837
- }
838
- if (!rawStatus) {
839
- 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) });
840
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;
841
1374
  // gaps_found takes priority over stale — gap closure is the correct next
842
1375
  // step regardless of whether summaries are newer than the verification file.
843
- if (rawStatus === 'gaps_found') {
844
- const entry = VERIFICATION_ROUTING_TABLE['gaps_found'];
845
- return {
846
- status: entry.status,
847
- next_action: entry.next_action,
848
- next_command: projectNextCommand('plan-phase', runtime, `${phaseArg} --gaps`),
849
- };
1376
+ if (reportStatus === VERIFICATION_STATUS.GAPS_FOUND) {
1377
+ return route(VERIFICATION_STATUS.GAPS_FOUND);
850
1378
  }
851
1379
  // #4155: a report that declares a covered-input fingerprint is checked by
852
1380
  // RECOMPUTING that fingerprint over current file content — strictly
@@ -880,18 +1408,33 @@ function readVerificationStatus(phaseDir, opts = {}) {
880
1408
  // runs once the digest itself has already matched.
881
1409
  //
882
1410
  // #4623: recompute under the STORED digest's own version, not the
883
- // current constant — a v1 report written before the shared-document
884
- // exclusion keeps v1 semantics rather than going stale on upgrade. An
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
885
1413
  // unknown version parses to `null`, which `computeCoveredDigest`
886
1414
  // refuses (returns `null`), so the compare below fails closed.
1415
+ const projectRoot = (0, project_root_cjs_1.resolveProjectRoot)(phaseDir);
887
1416
  const storedVersion = hasWellFormedFingerprint && typeof coveredDigestVal === 'string'
888
1417
  ? parseFingerprintVersion(coveredDigestVal)
889
1418
  : null;
890
- isStale =
891
- !hasWellFormedFingerprint ||
892
- storedVersion === null ||
893
- computeCoveredDigest((0, project_root_cjs_1.resolveProjectRoot)(phaseDir), coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal ||
894
- !allCurrentArtifactsCovered(phaseDir, coveredFilesVal);
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
+ }
895
1438
  }
896
1439
  else {
897
1440
  const staleCheck = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs, opts.convention);
@@ -904,39 +1447,12 @@ function readVerificationStatus(phaseDir, opts = {}) {
904
1447
  staleCheckIndeterminate = !staleCheck.determined;
905
1448
  }
906
1449
  if (isStale) {
907
- const entry = VERIFICATION_ROUTING_TABLE['stale'];
908
- return {
909
- status: entry.status,
910
- next_action: entry.next_action,
911
- // #4682: execute-phase resumes at the verification gates and re-runs
912
- // the verifier, regenerating VERIFICATION.md and its digest — the same
913
- // routing the `missing` sentinel has used since #2868.
914
- next_command: projectNextCommand('execute-phase', runtime, phaseArg),
915
- };
916
- }
917
- // 3. Route — exclude internal sentinels from raw-file lookup (they are
918
- // constructed internally above, never written by the verifier).
919
- if (rawStatus in VERIFICATION_ROUTING_TABLE &&
920
- rawStatus !== 'missing' &&
921
- rawStatus !== 'unknown' &&
922
- rawStatus !== 'stale' &&
923
- rawStatus !== 'gaps_found') {
924
- const entry = VERIFICATION_ROUTING_TABLE[rawStatus];
925
- return {
926
- status: entry.status,
927
- next_action: entry.next_action,
928
- next_command: projectNextCommand(entry.next_command, runtime, phaseArg),
929
- ...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
930
- };
931
- }
932
- // Unknown value
933
- const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
934
- return {
935
- status: unknownRoute.status,
936
- 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.`,
937
- next_command: projectNextCommand(unknownRoute.next_command, runtime, phaseArg),
938
- ...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
939
- };
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 });
940
1456
  }
941
1457
  /**
942
1458
  * isPhaseComplete — the single canonical owner of "is phase P complete?"
@@ -949,11 +1465,18 @@ function readVerificationStatus(phaseDir, opts = {}) {
949
1465
  * `*-VERIFICATION.md` is complete (#3168). A ROADMAP checkbox has no machine
950
1466
  * authority and is never consulted — this function never reads ROADMAP.md.
951
1467
  *
952
- * `complete` is exactly `verification.status === 'passed'`. `verification`
953
- * carries the FULL routing result (status/next_action/next_command), so a
954
- * caller can distinguish a failing verdict (`gaps_found`/`human_needed`/
955
- * `stale`/`unknown`) from an absent one (`missing`) — both are "not
956
- * 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.
957
1480
  *
958
1481
  * `scope` is UNREADABLE when `phaseDir` itself could not be listed — this is
959
1482
  * INDEPENDENT of readVerificationStatus's own no-throw fail-open contract for
@@ -976,17 +1499,29 @@ function isPhaseComplete(phaseDir, deps = {}) {
976
1499
  catch {
977
1500
  readable = false;
978
1501
  }
979
- const verification = readVerificationStatus(phaseDir, {
980
- fs: deps.fs,
981
- phaseCleanCommitTimesMs: deps.phaseCleanCommitTimesMs,
982
- runtime: deps.runtime,
983
- phaseNumber: deps.phaseNumber,
984
- convention: deps.convention,
985
- });
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
+ }
986
1520
  return {
987
1521
  value: {
988
- complete: verification.status === 'passed',
1522
+ complete: verification.status === VERIFICATION_STATUS.PASSED,
989
1523
  verification,
1524
+ ...(statusError ? { statusError } : {}),
990
1525
  },
991
1526
  scope: readable ? SCOPE.COMPLETE : SCOPE.UNREADABLE,
992
1527
  };
@@ -995,6 +1530,13 @@ function isPhaseComplete(phaseDir, deps = {}) {
995
1530
  * CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
996
1531
  * emit via io.output().
997
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
+ *
998
1540
  * @param cwd - Current working directory (used to resolve phaseDirArg).
999
1541
  * @param phaseDirArg - Phase directory path (absolute or relative to cwd).
1000
1542
  * @param raw - Whether to emit raw (non-JSON) output.
@@ -1023,6 +1565,11 @@ function cmdVerificationStatus(cwd, phaseDirArg, raw) {
1023
1565
  * bare path string (possibly empty) so `VAR=$(gsd_run query
1024
1566
  * verification.resolve-file "$PHASE_DIR" --raw)` is directly assignable.
1025
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
+ *
1026
1573
  * @param cwd - Current working directory (used to resolve phaseDirArg).
1027
1574
  * @param phaseDirArg - Phase directory path (absolute or relative to cwd).
1028
1575
  * @param raw - Whether to emit raw (non-JSON) output.
@@ -1033,6 +1580,14 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
1033
1580
  return;
1034
1581
  }
1035
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
+ }
1036
1591
  let verificationPath = '';
1037
1592
  try {
1038
1593
  const entries = node_fs_1.default.readdirSync(phaseDir);
@@ -1136,9 +1691,16 @@ function parseFingerprintFileArgs(tokens) {
1136
1691
  * @param raw - Whether to emit raw (non-JSON) output: just the
1137
1692
  * `covered_digest` string, so `VAR=$(gsd_run query
1138
1693
  * verification.fingerprint "$PHASE_DIR" ... --raw)` is
1139
- * directly assignable. `covered_files` is unambiguous
1140
- * from the caller's own input list in that mode, so
1141
- * 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.
1142
1704
  */
1143
1705
  function cmdVerificationFingerprint(cwd, phaseDirArg, fileArgs, raw) {
1144
1706
  if (!phaseDirArg) {
@@ -1168,37 +1730,322 @@ function cmdVerificationFingerprint(cwd, phaseDirArg, fileArgs, raw) {
1168
1730
  return;
1169
1731
  }
1170
1732
  const projectRoot = (0, project_root_cjs_1.resolveProjectRoot)(phaseDir);
1171
- // canonicalizeCoveredFiles here is for the emitted `covered_files` field —
1172
- // computeCoveredDigest canonicalizes its own `coveredFiles` argument
1173
- // internally too (it must, for callers like readVerificationStatus that
1174
- // pass raw, un-canonicalized frontmatter values), so passing an
1175
- // already-canonical list keeps that internal pass a cheap no-op rather
1176
- // than a second meaningfully different canonicalization.
1177
- const uniqueSorted = canonicalizeCoveredFiles(files);
1178
- const digest = computeCoveredDigest(projectRoot, uniqueSorted, FINGERPRINT_VERSION, { 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;
1179
1759
  if (digest === null) {
1180
1760
  // #4623: name the one null that is NOT a bad path — a declaration made
1181
- // only of shared planning documents hashes nothing under v2, and the
1182
- // generic message below would send the caller looking for a missing file
1183
- // that is not missing. Discriminated AFTER the v2 attempt, and only when a
1184
- // v1 pass over the same list (which hashes, and therefore validates, every
1185
- // path) succeeds: an all-shared list with a missing or directory member is
1186
- // a bad path first, and gets the generic message.
1187
- const sharedRoots = sharedPlanningRoots(projectRoot, phaseDir);
1188
- if (uniqueSorted.every((f) => isSharedPlanningDoc(f, sharedRoots)) &&
1189
- computeCoveredDigest(projectRoot, uniqueSorted, 1) !== null) {
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) {
1190
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`);
1191
1775
  return;
1192
1776
  }
1193
1777
  error('could not compute fingerprint — a covered file is missing, unreadable, or escapes the project root');
1194
1778
  return;
1195
1779
  }
1196
- 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);
1197
2032
  }
1198
- module.exports = {
2033
+ const verificationModule = {
2034
+ VERIFICATION_STATUS,
1199
2035
  VERIFIER_STATUSES,
1200
- 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,
1201
2047
  defaultPhaseCleanCommitTimesMs,
2048
+ resolvePhaseArtifactFile,
1202
2049
  resolveVerificationFile,
1203
2050
  resolveUatFile,
1204
2051
  findStaleVerificationSummary,
@@ -1209,7 +2056,11 @@ module.exports = {
1209
2056
  computeCoveredDigest,
1210
2057
  sharedPlanningRoots,
1211
2058
  isSharedPlanningDoc,
2059
+ isVerificationReportPath,
1212
2060
  parseFingerprintVersion,
1213
2061
  parseFingerprintFileArgs,
1214
2062
  cmdVerificationFingerprint,
2063
+ planAuditAppend,
2064
+ cmdVerificationAppendAudit,
1215
2065
  };
2066
+ module.exports = verificationModule;