@opengsd/gsd-core 1.15.0 → 1.17.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 (505) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -2
  3. package/.opencode/plugins/gsd-core.js +336 -6
  4. package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
  5. package/GEMINI.md +1 -1
  6. package/agents/gsd-code-fixer.compact.md +1 -1
  7. package/agents/gsd-code-fixer.md +1 -1
  8. package/agents/gsd-code-reviewer.compact.md +5 -3
  9. package/agents/gsd-code-reviewer.md +8 -6
  10. package/agents/gsd-debug-session-manager.compact.md +7 -1
  11. package/agents/gsd-debug-session-manager.md +7 -1
  12. package/agents/gsd-debugger.md +2 -2
  13. package/agents/gsd-eval-auditor.compact.md +1 -1
  14. package/agents/gsd-eval-auditor.md +1 -1
  15. package/agents/gsd-executor.md +4 -4
  16. package/agents/gsd-intel-updater.compact.md +1 -1
  17. package/agents/gsd-intel-updater.md +1 -1
  18. package/agents/gsd-mempalace-curator.md +2 -2
  19. package/agents/gsd-phase-researcher.md +1 -1
  20. package/agents/gsd-plan-checker.md +7 -2
  21. package/agents/gsd-planner.md +3 -3
  22. package/agents/gsd-project-researcher.compact.md +1 -1
  23. package/agents/gsd-project-researcher.md +1 -1
  24. package/agents/gsd-research-synthesizer.compact.md +1 -1
  25. package/agents/gsd-research-synthesizer.md +1 -1
  26. package/agents/gsd-ui-auditor.compact.md +21 -30
  27. package/agents/gsd-ui-auditor.md +42 -51
  28. package/agents/gsd-ui-researcher.compact.md +1 -1
  29. package/agents/gsd-ui-researcher.md +1 -1
  30. package/agents/gsd-verifier.md +31 -9
  31. package/bin/gsd-mcp-server.js +3 -1
  32. package/bin/install.js +487 -290
  33. package/commands/gsd/add-tests.md +6 -1
  34. package/commands/gsd/ai-integration-phase.md +6 -1
  35. package/commands/gsd/audit-fix.md +5 -0
  36. package/commands/gsd/audit-milestone.md +6 -1
  37. package/commands/gsd/autonomous.md +5 -0
  38. package/commands/gsd/capture.md +8 -4
  39. package/commands/gsd/code-review.md +7 -2
  40. package/commands/gsd/complete-milestone.md +4 -0
  41. package/commands/gsd/config.md +7 -3
  42. package/commands/gsd/debug.md +11 -7
  43. package/commands/gsd/discuss-phase.md +7 -3
  44. package/commands/gsd/docs-update.md +12 -7
  45. package/commands/gsd/eval-review.md +6 -1
  46. package/commands/gsd/execute-phase.md +12 -7
  47. package/commands/gsd/extract-learnings.md +5 -0
  48. package/commands/gsd/fast.md +4 -0
  49. package/commands/gsd/forensics.md +5 -1
  50. package/commands/gsd/graphify.md +10 -6
  51. package/commands/gsd/health.md +5 -0
  52. package/commands/gsd/help.md +7 -2
  53. package/commands/gsd/import.md +7 -3
  54. package/commands/gsd/inbox.md +5 -0
  55. package/commands/gsd/ingest-docs.md +5 -1
  56. package/commands/gsd/manager.md +6 -1
  57. package/commands/gsd/map-codebase.md +7 -3
  58. package/commands/gsd/mempalace-capture.md +5 -1
  59. package/commands/gsd/mempalace-recall.md +5 -1
  60. package/commands/gsd/milestone-summary.md +5 -1
  61. package/commands/gsd/mvp-phase.md +8 -3
  62. package/commands/gsd/new-milestone.md +6 -1
  63. package/commands/gsd/new-project.md +5 -0
  64. package/commands/gsd/next.md +6 -1
  65. package/commands/gsd/ns-context.md +4 -0
  66. package/commands/gsd/ns-ideate.md +4 -0
  67. package/commands/gsd/ns-manage.md +4 -0
  68. package/commands/gsd/ns-project.md +4 -0
  69. package/commands/gsd/ns-review.md +4 -0
  70. package/commands/gsd/ns-workflow.md +4 -0
  71. package/commands/gsd/onboard.md +6 -1
  72. package/commands/gsd/pause-work.md +5 -1
  73. package/commands/gsd/phase.md +8 -4
  74. package/commands/gsd/plan-phase.md +6 -1
  75. package/commands/gsd/plan-review-convergence.md +5 -1
  76. package/commands/gsd/pr-branch.md +4 -0
  77. package/commands/gsd/profile-user.md +5 -1
  78. package/commands/gsd/progress.md +6 -1
  79. package/commands/gsd/quick-batch.md +20 -8
  80. package/commands/gsd/quick.md +12 -7
  81. package/commands/gsd/review.md +5 -1
  82. package/commands/gsd/secure-phase.md +6 -1
  83. package/commands/gsd/ship.md +5 -0
  84. package/commands/gsd/sketch.md +7 -2
  85. package/commands/gsd/spec-phase.md +5 -1
  86. package/commands/gsd/spike.md +8 -3
  87. package/commands/gsd/surface.md +5 -1
  88. package/commands/gsd/thread.md +4 -0
  89. package/commands/gsd/ui-phase.md +6 -1
  90. package/commands/gsd/ui-review.md +6 -1
  91. package/commands/gsd/ultraplan-phase.md +5 -1
  92. package/commands/gsd/undo.md +5 -1
  93. package/commands/gsd/update.md +6 -2
  94. package/commands/gsd/validate-phase.md +6 -1
  95. package/commands/gsd/verify-work.md +6 -1
  96. package/commands/gsd/workspace.md +7 -3
  97. package/gsd-core/bin/gsd-tools.cjs +159 -58
  98. package/gsd-core/bin/lib/active-workstream-store.cjs +15 -0
  99. package/gsd-core/bin/lib/adr-parser.cjs +37 -5
  100. package/gsd-core/bin/lib/agent-install-check.cjs +4 -1
  101. package/gsd-core/bin/lib/audit.cjs +78 -44
  102. package/gsd-core/bin/lib/broken-windows.cjs +13 -13
  103. package/gsd-core/bin/lib/capability-activation.cjs +9 -4
  104. package/gsd-core/bin/lib/capability-registry.cjs +249 -117
  105. package/gsd-core/bin/lib/capability-validator.cjs +18 -1
  106. package/gsd-core/bin/lib/capability-writer.cjs +2 -1
  107. package/gsd-core/bin/lib/check-auto-mode.cjs +35 -0
  108. package/gsd-core/bin/lib/check-command-router.cjs +164 -1712
  109. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +13 -2
  110. package/gsd-core/bin/lib/cli-exit.cjs +12 -0
  111. package/gsd-core/bin/lib/codex-agent-toml.cjs +11 -8
  112. package/gsd-core/bin/lib/command-aliases.cjs +7 -0
  113. package/gsd-core/bin/lib/command-routing-hub.cjs +48 -1
  114. package/gsd-core/bin/lib/commands.cjs +201 -178
  115. package/gsd-core/bin/lib/complexity-trigger.cjs +8 -7
  116. package/gsd-core/bin/lib/config.cjs +43 -12
  117. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  118. package/gsd-core/bin/lib/coverage.cjs +4 -8
  119. package/gsd-core/bin/lib/decision-coverage-support.cjs +259 -0
  120. package/gsd-core/bin/lib/drift.cjs +177 -42
  121. package/gsd-core/bin/lib/frontmatter-fence.cjs +90 -0
  122. package/gsd-core/bin/lib/frontmatter-splice.cjs +494 -0
  123. package/gsd-core/bin/lib/frontmatter.cjs +413 -234
  124. package/gsd-core/bin/lib/gap-checker.cjs +72 -29
  125. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +381 -0
  126. package/gsd-core/bin/lib/gate-args.cjs +53 -0
  127. package/gsd-core/bin/lib/gate-codebase-drift.cjs +285 -0
  128. package/gsd-core/bin/lib/gate-config.cjs +46 -0
  129. package/gsd-core/bin/lib/gate-context-drift.cjs +142 -0
  130. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +169 -0
  131. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +126 -0
  132. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +555 -0
  133. package/gsd-core/bin/lib/gate-evidence.cjs +138 -0
  134. package/gsd-core/bin/lib/gate-exit.cjs +27 -0
  135. package/gsd-core/bin/lib/gate-gap-analysis-plan-post.cjs +61 -0
  136. package/gsd-core/bin/lib/gate-phase-context.cjs +170 -0
  137. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +1 -1
  138. package/gsd-core/bin/lib/gate-predicate.cjs +165 -0
  139. package/gsd-core/bin/lib/gate-prohibition-enforcement.cjs +94 -0
  140. package/gsd-core/bin/lib/gate-schema-drift.cjs +165 -0
  141. package/gsd-core/bin/lib/gate-tdd-red-evidence.cjs +100 -0
  142. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +182 -0
  143. package/gsd-core/bin/lib/gate-ui-plan.cjs +86 -0
  144. package/gsd-core/bin/lib/gate-ui-safety.cjs +81 -0
  145. package/gsd-core/bin/lib/gate-verdict.cjs +64 -0
  146. package/gsd-core/bin/lib/gate-verify-command-paths.cjs +78 -0
  147. package/gsd-core/bin/lib/gate-verify-failure-directions.cjs +41 -0
  148. package/gsd-core/bin/lib/graphify.cjs +3 -2
  149. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +43 -47
  150. package/gsd-core/bin/lib/health-diagnostic.cjs +45 -8
  151. package/gsd-core/bin/lib/init.cjs +194 -108
  152. package/gsd-core/bin/lib/install-engine.cjs +30 -35
  153. package/gsd-core/bin/lib/install-profiles.cjs +7 -4
  154. package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
  155. package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
  156. package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
  157. package/gsd-core/bin/lib/io.cjs +122 -3
  158. package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
  159. package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
  160. package/gsd-core/bin/lib/loop-resolver.cjs +95 -0
  161. package/gsd-core/bin/lib/markdown-sectionizer.cjs +75 -1
  162. package/gsd-core/bin/lib/mcp-server.cjs +5 -1
  163. package/gsd-core/bin/lib/milestone.cjs +19 -1
  164. package/gsd-core/bin/lib/model-resolver.cjs +18 -18
  165. package/gsd-core/bin/lib/observability/event.cjs +1 -1
  166. package/gsd-core/bin/lib/observability/logger.cjs +46 -1
  167. package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
  168. package/gsd-core/bin/lib/opencode-version.cjs +486 -0
  169. package/gsd-core/bin/lib/pattern.cjs +10 -0
  170. package/gsd-core/bin/lib/phase-command-router.cjs +11 -4
  171. package/gsd-core/bin/lib/phase-estimation.cjs +5 -4
  172. package/gsd-core/bin/lib/phase-id.cjs +1 -1
  173. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -2
  174. package/gsd-core/bin/lib/phase-status.cjs +360 -0
  175. package/gsd-core/bin/lib/phase.cjs +301 -80
  176. package/gsd-core/bin/lib/plan-document.cjs +93 -19
  177. package/gsd-core/bin/lib/plan-drift-guard.cjs +5 -0
  178. package/gsd-core/bin/lib/planning-document.cjs +273 -40
  179. package/gsd-core/bin/lib/planning-inspect.cjs +42 -8
  180. package/gsd-core/bin/lib/planning-snapshot.cjs +18 -0
  181. package/gsd-core/bin/lib/planning-workspace.cjs +76 -53
  182. package/gsd-core/bin/lib/pristine-baseline.cjs +10 -0
  183. package/gsd-core/bin/lib/profile-output.cjs +6 -3
  184. package/gsd-core/bin/lib/prohibition-enforcement.cjs +0 -55
  185. package/gsd-core/bin/lib/quick-batch-command-router.cjs +35 -9
  186. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +13 -9
  187. package/gsd-core/bin/lib/real-home-guard.cjs +9 -1
  188. package/gsd-core/bin/lib/report-parser.cjs +269 -0
  189. package/gsd-core/bin/lib/roadmap-command-router.cjs +13 -15
  190. package/gsd-core/bin/lib/roadmap-parser.cjs +82 -15
  191. package/gsd-core/bin/lib/roadmap-upgrade.cjs +127 -65
  192. package/gsd-core/bin/lib/roadmap.cjs +169 -72
  193. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +186 -161
  194. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -1
  195. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +57 -1
  196. package/gsd-core/bin/lib/runtime-homes.cjs +13 -10
  197. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +365 -594
  198. package/gsd-core/bin/lib/runtime-name-policy.cjs +142 -27
  199. package/gsd-core/bin/lib/runtime-slash.cjs +60 -30
  200. package/gsd-core/bin/lib/shell-command-projection.cjs +37 -2
  201. package/gsd-core/bin/lib/smart-entry.cjs +111 -56
  202. package/gsd-core/bin/lib/stale-bake-guard.cjs +32 -48
  203. package/gsd-core/bin/lib/state-contract.cjs +15 -18
  204. package/gsd-core/bin/lib/state-document.cjs +100 -22
  205. package/gsd-core/bin/lib/state-transition.cjs +67 -9
  206. package/gsd-core/bin/lib/state.cjs +668 -171
  207. package/gsd-core/bin/lib/surface.cjs +2 -1
  208. package/gsd-core/bin/lib/tdd-red-evidence.cjs +48 -152
  209. package/gsd-core/bin/lib/uat-predicate.cjs +313 -35
  210. package/gsd-core/bin/lib/uat.cjs +424 -7
  211. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +94 -58
  212. package/gsd-core/bin/lib/update-context.cjs +18 -1
  213. package/gsd-core/bin/lib/vendor/README.md +31 -9
  214. package/gsd-core/bin/lib/vendor/saxes.cjs +1934 -0
  215. package/gsd-core/bin/lib/vendor/saxes.cjs.LICENSE.txt +92 -0
  216. package/gsd-core/bin/lib/vendor/tap-parser.cjs +8927 -0
  217. package/gsd-core/bin/lib/vendor/tap-parser.cjs.LICENSE.txt +152 -0
  218. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  219. package/gsd-core/bin/lib/verification.cjs +1265 -324
  220. package/gsd-core/bin/lib/verify-command-router.cjs +18 -7
  221. package/gsd-core/bin/lib/verify.cjs +286 -567
  222. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +14 -6
  223. package/gsd-core/bin/lib/workstream-inventory.cjs +31 -21
  224. package/gsd-core/bin/lib/workstream-name-policy.cjs +31 -1
  225. package/gsd-core/bin/lib/workstream.cjs +11 -2
  226. package/gsd-core/bin/shared/config-defaults.manifest.json +5 -0
  227. package/gsd-core/bin/shared/config-schema.manifest.json +4 -0
  228. package/gsd-core/references/autonomous-smart-discuss.md +2 -1
  229. package/gsd-core/references/autonomous-ui-design-contract.md +3 -3
  230. package/gsd-core/references/execute-mvp-tdd.md +5 -10
  231. package/gsd-core/references/execute-phase-between-wave-reset.md +3 -0
  232. package/gsd-core/references/execute-phase-response-language.md +1 -1
  233. package/gsd-core/references/gsd-run-resolver.md +1 -1
  234. package/gsd-core/references/loop-hook-dispatch.md +7 -1
  235. package/gsd-core/references/offer-next.md +1 -1
  236. package/gsd-core/references/planning-config.md +1 -1
  237. package/gsd-core/references/spidr-splitting.md +1 -1
  238. package/gsd-core/references/tdd.md +10 -4
  239. package/gsd-core/references/verifier-phase-gates.md +5 -2
  240. package/gsd-core/references/verify-mvp-mode.md +2 -2
  241. package/gsd-core/references/workstream-flag.md +33 -3
  242. package/gsd-core/templates/README.md +1 -1
  243. package/gsd-core/templates/UAT.md +17 -1
  244. package/gsd-core/templates/config.json +2 -11
  245. package/gsd-core/templates/verification-report.md +1 -1
  246. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  247. package/gsd-core/workflows/add-backlog.md +1 -1
  248. package/gsd-core/workflows/add-phase.md +8 -7
  249. package/gsd-core/workflows/add-tests.md +3 -2
  250. package/gsd-core/workflows/add-todo.md +4 -3
  251. package/gsd-core/workflows/ai-integration-phase.md +3 -2
  252. package/gsd-core/workflows/audit-fix.md +1 -1
  253. package/gsd-core/workflows/audit-milestone.md +4 -3
  254. package/gsd-core/workflows/audit-uat.md +1 -1
  255. package/gsd-core/workflows/autonomous.md +28 -18
  256. package/gsd-core/workflows/check-todos.md +6 -5
  257. package/gsd-core/workflows/cleanup.md +1 -1
  258. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +20 -13
  259. package/gsd-core/workflows/code-review-fix.md +5 -4
  260. package/gsd-core/workflows/code-review.md +100 -91
  261. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +4 -3
  262. package/gsd-core/workflows/complete-milestone.md +12 -7
  263. package/gsd-core/workflows/debug.md +30 -5
  264. package/gsd-core/workflows/diagnose-issues.md +3 -2
  265. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  266. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  267. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  268. package/gsd-core/workflows/discuss-phase.md +4 -3
  269. package/gsd-core/workflows/do.md +1 -1
  270. package/gsd-core/workflows/docs-update.md +4 -3
  271. package/gsd-core/workflows/edit-phase.md +4 -3
  272. package/gsd-core/workflows/eval-review.md +5 -3
  273. package/gsd-core/workflows/execute-phase/detail/elaboration.md +1 -1
  274. package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +4 -2
  275. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +15 -4
  276. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +10 -7
  277. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +2 -2
  278. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +3 -1
  279. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +2 -2
  280. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  281. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
  282. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  283. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
  284. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
  285. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -0
  286. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +187 -0
  288. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +2 -3
  289. package/gsd-core/workflows/execute-phase.md +81 -171
  290. package/gsd-core/workflows/execute-plan.md +13 -19
  291. package/gsd-core/workflows/explore.md +2 -2
  292. package/gsd-core/workflows/extract-learnings.md +3 -2
  293. package/gsd-core/workflows/fast.md +1 -1
  294. package/gsd-core/workflows/forensics.md +1 -1
  295. package/gsd-core/workflows/graduation.md +1 -1
  296. package/gsd-core/workflows/health.md +2 -1
  297. package/gsd-core/workflows/import.md +3 -2
  298. package/gsd-core/workflows/inbox.md +1 -1
  299. package/gsd-core/workflows/ingest-docs.md +1 -1
  300. package/gsd-core/workflows/insert-phase.md +4 -3
  301. package/gsd-core/workflows/list-seeds.md +1 -1
  302. package/gsd-core/workflows/list-workspaces.md +1 -1
  303. package/gsd-core/workflows/manager.md +5 -3
  304. package/gsd-core/workflows/map-codebase.md +4 -3
  305. package/gsd-core/workflows/milestone-summary.md +3 -2
  306. package/gsd-core/workflows/mvp-phase.md +14 -14
  307. package/gsd-core/workflows/new-milestone.md +10 -10
  308. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  309. package/gsd-core/workflows/new-project.md +1 -1
  310. package/gsd-core/workflows/new-workspace.md +1 -1
  311. package/gsd-core/workflows/next.md +1 -1
  312. package/gsd-core/workflows/pause-work.md +2 -2
  313. package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
  314. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  315. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +1 -1
  316. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
  317. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +1 -1
  318. package/gsd-core/workflows/plan-phase.md +27 -18
  319. package/gsd-core/workflows/plan-review-convergence.md +1 -1
  320. package/gsd-core/workflows/plant-seed.md +1 -1
  321. package/gsd-core/workflows/pr-branch.md +1 -1
  322. package/gsd-core/workflows/profile-user.md +1 -1
  323. package/gsd-core/workflows/progress.md +19 -49
  324. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -2
  325. package/gsd-core/workflows/quick/steps/quick-verification.md +4 -4
  326. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +30 -11
  327. package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
  328. package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
  329. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
  330. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
  331. package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
  332. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
  333. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +9 -3
  334. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
  335. package/gsd-core/workflows/quick-batch.md +15 -10
  336. package/gsd-core/workflows/quick.md +42 -31
  337. package/gsd-core/workflows/remove-phase.md +3 -2
  338. package/gsd-core/workflows/remove-workspace.md +1 -1
  339. package/gsd-core/workflows/resume-project.md +17 -12
  340. package/gsd-core/workflows/review.md +3 -2
  341. package/gsd-core/workflows/scan.md +3 -2
  342. package/gsd-core/workflows/secure-phase.md +11 -12
  343. package/gsd-core/workflows/settings-advanced.md +1 -1
  344. package/gsd-core/workflows/settings-integrations.md +1 -1
  345. package/gsd-core/workflows/settings.md +1 -1
  346. package/gsd-core/workflows/ship.md +12 -12
  347. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  348. package/gsd-core/workflows/sketch.md +1 -1
  349. package/gsd-core/workflows/smart-entry.md +1 -1
  350. package/gsd-core/workflows/spec-phase.md +1 -1
  351. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  352. package/gsd-core/workflows/spike.md +1 -1
  353. package/gsd-core/workflows/stats.md +1 -1
  354. package/gsd-core/workflows/sync-skills.md +1 -1
  355. package/gsd-core/workflows/thread.md +2 -2
  356. package/gsd-core/workflows/transition.md +13 -23
  357. package/gsd-core/workflows/ui-phase.md +5 -4
  358. package/gsd-core/workflows/ui-review.md +4 -3
  359. package/gsd-core/workflows/ultraplan-phase.md +3 -2
  360. package/gsd-core/workflows/undo.md +1 -1
  361. package/gsd-core/workflows/validate-phase.md +10 -12
  362. package/gsd-core/workflows/verify-work/detail/elaboration.md +43 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -3
  365. package/gsd-core/workflows/verify-work.md +68 -59
  366. package/hooks/dist/gsd-agent-isolation-guard.js +8 -0
  367. package/hooks/dist/gsd-check-update-worker.js +8 -0
  368. package/hooks/dist/gsd-check-update.js +34 -20
  369. package/hooks/dist/gsd-context-monitor.js +45 -13
  370. package/hooks/dist/gsd-cursor-subagent-start.js +8 -0
  371. package/hooks/dist/gsd-secret-read-guard.js +263 -16
  372. package/hooks/dist/gsd-statusline.js +336 -29
  373. package/hooks/dist/gsd-update-banner.js +8 -0
  374. package/hooks/dist/gsd-windsurf-pre-write.js +11 -2
  375. package/hooks/dist/gsd-workflow-guard.js +5 -4
  376. package/hooks/dist/gsd-worktree-path-guard.js +6 -2
  377. package/hooks/dist/lib/cli-exit.js +12 -0
  378. package/hooks/dist/lib/git-probe.js +17 -1
  379. package/hooks/dist/lib/isolation-sentinel.js +2 -2
  380. package/hooks/gsd-agent-isolation-guard.js +8 -0
  381. package/hooks/gsd-check-update-worker.js +8 -0
  382. package/hooks/gsd-check-update.js +34 -20
  383. package/hooks/gsd-context-monitor.js +45 -13
  384. package/hooks/gsd-cursor-subagent-start.js +8 -0
  385. package/hooks/gsd-secret-read-guard.js +263 -16
  386. package/hooks/gsd-statusline.js +336 -29
  387. package/hooks/gsd-update-banner.js +8 -0
  388. package/hooks/gsd-windsurf-pre-write.js +11 -2
  389. package/hooks/gsd-workflow-guard.js +5 -4
  390. package/hooks/gsd-worktree-path-guard.js +6 -2
  391. package/hooks/hooks.json +5 -5
  392. package/hooks/lib/cli-exit.js +12 -0
  393. package/hooks/lib/git-probe.js +17 -1
  394. package/hooks/lib/isolation-sentinel.js +2 -2
  395. package/package.json +21 -4
  396. package/scripts/changeset/parse.cjs +52 -4
  397. package/scripts/ci-timeout-report.cjs +770 -4
  398. package/scripts/command-contract-helpers.cjs +12 -8
  399. package/scripts/docs-guard-registry.cjs +6 -0
  400. package/scripts/gen-capability-registry.cjs +7 -1
  401. package/scripts/gen-features.cjs +13 -8
  402. package/scripts/gen-hooks-cli-exit.cjs +12 -28
  403. package/scripts/gen-loop-host-contract.cjs +14 -2
  404. package/scripts/gen-platform-conformance-tier.cjs +187 -1
  405. package/scripts/gen-plugin-skills.cjs +87 -1
  406. package/scripts/gen-research-agents.cjs +24 -31
  407. package/scripts/gen-scripts-cli-exit.cjs +30 -3
  408. package/scripts/gen-test-timings.cjs +32 -7
  409. package/scripts/lib/cli-exit.cjs +12 -0
  410. package/scripts/lib/macos-conformance-tier.generated.cjs +24 -2
  411. package/scripts/lib/ndjson-reporter.cjs +28 -3
  412. package/scripts/lib/platform-conformance-tier.generated.cjs +44 -5
  413. package/scripts/lib/registration-ledger-preload.cjs +155 -0
  414. package/scripts/lib/vendor-bundle.cjs +59 -0
  415. package/scripts/lib/vendor-licenses/saxes-6.0.0.txt +64 -0
  416. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  417. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  418. package/scripts/lint-completion-predicate-drift.cjs +18 -19
  419. package/scripts/lint-descriptions.cjs +7 -3
  420. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +10 -0
  421. package/scripts/lint-eslint-glob-coverage.allowlist.json +20 -0
  422. package/scripts/lint-frontmatter-fence-drift.cjs +313 -0
  423. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +122 -16
  424. package/scripts/lint-phase-enumeration-drift.cjs +12 -8
  425. package/scripts/lint-phase-id-drift.cjs +29 -0
  426. package/scripts/lint-planning-document-positive-control.cjs +329 -0
  427. package/scripts/lint-response-language-coverage.cjs +3 -0
  428. package/scripts/lint-skill-deps.cjs +7 -3
  429. package/scripts/lint-test-file-count.allowlist.json +11 -1
  430. package/scripts/lint-test-file-count.cjs +34 -1
  431. package/scripts/lint-vendored-deps.cjs +41 -5
  432. package/scripts/lint-workflow-shellcheck-baseline.json +10 -10
  433. package/scripts/mutation-matrix.cjs +53 -6
  434. package/scripts/release-tarball-smoke.cjs +14 -7
  435. package/scripts/require-issue-link-policy.cjs +6 -2
  436. package/scripts/sync-runtime-launcher.cjs +184 -2
  437. package/scripts/verify-npm-publish.cjs +76 -20
  438. package/skills/gsd-add-tests/SKILL.md +6 -1
  439. package/skills/gsd-ai-integration-phase/SKILL.md +6 -1
  440. package/skills/gsd-audit-fix/SKILL.md +5 -0
  441. package/skills/gsd-audit-milestone/SKILL.md +6 -1
  442. package/skills/gsd-autonomous/SKILL.md +5 -0
  443. package/skills/gsd-capture/SKILL.md +8 -4
  444. package/skills/gsd-code-review/SKILL.md +7 -2
  445. package/skills/gsd-complete-milestone/SKILL.md +4 -0
  446. package/skills/gsd-config/SKILL.md +8 -4
  447. package/skills/gsd-debug/SKILL.md +11 -7
  448. package/skills/gsd-discuss-phase/SKILL.md +7 -3
  449. package/skills/gsd-docs-update/SKILL.md +12 -7
  450. package/skills/gsd-eval-review/SKILL.md +6 -1
  451. package/skills/gsd-execute-phase/SKILL.md +12 -7
  452. package/skills/gsd-extract-learnings/SKILL.md +5 -0
  453. package/skills/gsd-fast/SKILL.md +4 -0
  454. package/skills/gsd-forensics/SKILL.md +5 -1
  455. package/skills/gsd-graphify/SKILL.md +10 -6
  456. package/skills/gsd-health/SKILL.md +5 -0
  457. package/skills/gsd-help/SKILL.md +7 -2
  458. package/skills/gsd-import/SKILL.md +7 -3
  459. package/skills/gsd-inbox/SKILL.md +5 -0
  460. package/skills/gsd-ingest-docs/SKILL.md +5 -1
  461. package/skills/gsd-manager/SKILL.md +6 -1
  462. package/skills/gsd-map-codebase/SKILL.md +7 -3
  463. package/skills/gsd-mempalace-capture/SKILL.md +5 -1
  464. package/skills/gsd-mempalace-recall/SKILL.md +5 -1
  465. package/skills/gsd-milestone-summary/SKILL.md +5 -1
  466. package/skills/gsd-mvp-phase/SKILL.md +8 -3
  467. package/skills/gsd-new-milestone/SKILL.md +6 -1
  468. package/skills/gsd-new-project/SKILL.md +5 -0
  469. package/skills/gsd-next/SKILL.md +6 -1
  470. package/skills/gsd-ns-context/SKILL.md +4 -0
  471. package/skills/gsd-ns-ideate/SKILL.md +4 -0
  472. package/skills/gsd-ns-manage/SKILL.md +4 -0
  473. package/skills/gsd-ns-project/SKILL.md +4 -0
  474. package/skills/gsd-ns-review/SKILL.md +4 -0
  475. package/skills/gsd-ns-workflow/SKILL.md +4 -0
  476. package/skills/gsd-onboard/SKILL.md +6 -1
  477. package/skills/gsd-pause-work/SKILL.md +5 -1
  478. package/skills/gsd-phase/SKILL.md +8 -4
  479. package/skills/gsd-plan-phase/SKILL.md +6 -1
  480. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  481. package/skills/gsd-pr-branch/SKILL.md +4 -0
  482. package/skills/gsd-profile-user/SKILL.md +5 -1
  483. package/skills/gsd-progress/SKILL.md +6 -1
  484. package/skills/gsd-quick/SKILL.md +16 -10
  485. package/skills/gsd-quick-batch/SKILL.md +20 -8
  486. package/skills/gsd-review/SKILL.md +5 -1
  487. package/skills/gsd-review-backlog/SKILL.md +3 -2
  488. package/skills/gsd-secure-phase/SKILL.md +6 -1
  489. package/skills/gsd-ship/SKILL.md +5 -0
  490. package/skills/gsd-sketch/SKILL.md +7 -2
  491. package/skills/gsd-spec-phase/SKILL.md +5 -1
  492. package/skills/gsd-spike/SKILL.md +8 -3
  493. package/skills/gsd-surface/SKILL.md +5 -1
  494. package/skills/gsd-thread/SKILL.md +4 -0
  495. package/skills/gsd-ui-phase/SKILL.md +6 -1
  496. package/skills/gsd-ui-review/SKILL.md +6 -1
  497. package/skills/gsd-ultraplan-phase/SKILL.md +5 -1
  498. package/skills/gsd-undo/SKILL.md +5 -1
  499. package/skills/gsd-update/SKILL.md +6 -2
  500. package/skills/gsd-validate-phase/SKILL.md +6 -1
  501. package/skills/gsd-verify-work/SKILL.md +6 -1
  502. package/skills/gsd-workspace/SKILL.md +7 -3
  503. package/skills/gsd-workstreams/SKILL.md +6 -6
  504. package/vscode/package.json +1 -1
  505. package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +0 -24
@@ -49,92 +49,268 @@ 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).
74
174
  *
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.
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.
80
180
  */
81
- const VERIFICATION_ROUTING_TABLE = {
82
- passed: {
83
- status: 'passed',
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`.
254
+ *
255
+ * `stale` has ONE route: `execute-phase`. Its regenerating action is the
256
+ * shared step `gsd-core/workflows/execute-phase/steps/verify-phase-goal.md`
257
+ * (execute-phase's verify_phase_goal, which verify-work's stale arm includes
258
+ * too): it re-runs the verifier, which regenerates VERIFICATION.md and its
259
+ * digest (#4682, #4887).
260
+ */
261
+ /**
262
+ * The one remedy for a stale report (#4682 / #5118), shared by the route table and by the more specific
263
+ * diagnostic below so the two cannot drift: only re-running the verifier regenerates VERIFICATION.md.
264
+ */
265
+ const STALE_REMEDY = '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.';
266
+ const VERIFICATION_ROUTES = Object.freeze({
267
+ passed: Object.freeze({
84
268
  next_action: 'Verification passed — continue.',
85
- next_command: '',
86
- },
87
- gaps_found: {
88
- status: 'gaps_found',
269
+ command: '',
270
+ tail: '',
271
+ }),
272
+ gaps_found: Object.freeze({
89
273
  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',
274
+ command: 'plan-phase',
275
+ tail: ' --gaps',
276
+ }),
277
+ human_needed: Object.freeze({
95
278
  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.
111
- 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',
279
+ // #2617: init's projector and this table used to disagree on whether a
280
+ // next command existed at all; `verify-work <N>` is the useful answer.
281
+ command: 'verify-work',
282
+ tail: '',
283
+ }),
284
+ stale: Object.freeze({
285
+ // #4682 / #5118: the only remedy for a stale report is re-running the
286
+ // verifier; /gsd-verify-work on its own never rewrites VERIFICATION.md.
287
+ next_action: `Verification is stale — covered source files changed after the verifier last ran. ${STALE_REMEDY}`,
288
+ command: 'execute-phase',
289
+ tail: '',
290
+ }),
291
+ // The phase directory exists and holds no report, or the report has no
292
+ // `status`: the verify step never completed (#2868).
293
+ missing: Object.freeze({
118
294
  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
- },
295
+ command: 'execute-phase',
296
+ tail: '',
297
+ }),
121
298
  // #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',
299
+ // re-running execute-phase cannot fix a YAML typo in an existing report.
300
+ unparseable: Object.freeze({
127
301
  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
- };
302
+ command: '',
303
+ tail: '',
304
+ }),
305
+ // ADR-5057 amendment 2 / #4987: there was nothing to look in — a usage
306
+ // error, never execute-phase (which would re-run a phase that may already be
307
+ // archived under .planning/milestones/).
308
+ phase_dir_not_found: Object.freeze({
309
+ 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/).',
310
+ command: '',
311
+ tail: '',
312
+ }),
313
+ });
138
314
  /**
139
315
  * Project a BARE command name (plus optional argument tail) into the surface the
140
316
  * given runtime actually installs (#2617).
@@ -178,11 +354,231 @@ function toPosix(p) {
178
354
  function normalizeRel(p) {
179
355
  return node_path_1.default.posix.normalize(toPosix(p));
180
356
  }
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();
357
+ /**
358
+ * Canonicalize a covered-files list: normalize, de-duplicate, sort — the SAME
359
+ * transform computeCoveredDigest and cmdVerificationFingerprint both need
360
+ * (the digest's own key order; the CLI's own `covered_files` JSON output).
361
+ *
362
+ * #5095 (ADR-5057 Phase 2): `opts.version >= 3` additionally drops any
363
+ * report-shaped path (`isVerificationReportPath`) — a report is never an
364
+ * input to its own digest (#4857). Versionless callers (the default) keep
365
+ * the pre-#5095 behaviour: normalize/dedupe/sort only, no filtering.
366
+ */
367
+ function canonicalizeCoveredFiles(files, opts = {}) {
368
+ const normalized = files.map(normalizeRel);
369
+ const filtered = opts.version !== undefined && opts.version >= 3
370
+ ? normalized.filter((f) => !isVerificationReportPath(f))
371
+ : normalized;
372
+ return Array.from(new Set(filtered)).sort();
373
+ }
374
+ /**
375
+ * #5095 (ADR-5057 Phase 2, #4857): a covered-input path names a verification
376
+ * REPORT — basename `VERIFICATION.md` or ending `-VERIFICATION.md`
377
+ * (case-sensitive, as `resolveVerificationFile` is) — as opposed to any other
378
+ * evidence. `computeCoveredDigest(projectRoot, coveredFiles)` never receives
379
+ * the report's OWN path as a distinguished value — the report writes its own
380
+ * digest into its own frontmatter, so a comparison inside that function is
381
+ * structurally impossible (it would need to know, while computing a value,
382
+ * what that value is about to become). The filter therefore lives here, one
383
+ * function up from the digest, as a PATTERN match on shape, not a path
384
+ * identity check. Matches `docs/VERIFICATION.md` (same basename) but not
385
+ * `VERIFICATION-NOTES.md` or `07-VERIFICATION.md.bak` (different basename).
386
+ */
387
+ function isVerificationReportPath(rel) {
388
+ const basename = node_path_1.default.posix.basename(toPosix(rel));
389
+ return basename === 'VERIFICATION.md' || basename.endsWith('-VERIFICATION.md');
390
+ }
391
+ /**
392
+ * #5095 (R7): enumerate every anchored planning scope the Planning Workspace
393
+ * Module's layout admits, by walking the REAL directory tree rooted at
394
+ * `<projectRoot>/.planning` — never the caller's `phaseDir` (the pre-R7 bug:
395
+ * admitting a scope root on `basename(phaseDir's parent) === 'phases'` alone
396
+ * made any directory named `phases` anywhere an admissible root). Only
397
+ * directories are admitted, and a project/workstream segment failing
398
+ * `planningDir`'s own `BAD_SEGMENT` rule (a path separator or `..`) is
399
+ * skipped — the identical validation `planningDir` itself enforces, so every
400
+ * scope this function admits is one `planningDir` could also resolve to.
401
+ * `workstreams` is reserved as a layout segment (never itself a project
402
+ * name), so the walk cannot derive the same scope twice under two labels.
403
+ * Each planning BASE contributes two scopes — the base itself, and its own
404
+ * `phases/` subdirectory — since `phases/` may be independently symlinked to
405
+ * a per-project/per-workstream store while the base directory stays real
406
+ * (the #4894 layout, generalized to every base shape); the longest-`lexRel`
407
+ * -wins rule in `mapRealPathToRootRelative` / `bestPlanningScopeForRel` then
408
+ * prefers the `phases` scope for anything living under it.
409
+ *
410
+ * Security: a scope whose realpath is the filesystem root, or an ANCESTOR of
411
+ * `realRoot` itself, is refused outright (`real: null`) — an
412
+ * attacker-controlled `.planning -> /` (or `-> ..`) symlink must never become
413
+ * an admissible containment root.
414
+ */
415
+ function enumeratePlanningScopes(projectRoot, realRoot) {
416
+ const BAD_SEGMENT = /[/\\]|\.\./;
417
+ const listDirs = (dirAbs) => {
418
+ try {
419
+ return node_fs_1.default
420
+ .readdirSync(dirAbs, { withFileTypes: true })
421
+ .filter((e) => e.isDirectory() && !BAD_SEGMENT.test(e.name))
422
+ .map((e) => e.name);
423
+ }
424
+ catch {
425
+ return [];
426
+ }
427
+ };
428
+ const realOf = (p) => {
429
+ let real;
430
+ try {
431
+ real = node_fs_1.default.realpathSync(p);
432
+ }
433
+ catch {
434
+ return null;
435
+ }
436
+ if (node_path_1.default.dirname(real) === real)
437
+ return null; // filesystem root
438
+ if (realRoot !== null && real !== realRoot && (0, security_cjs_1.isContainedIn)(realRoot, real))
439
+ return null; // ancestor of realRoot
440
+ return real;
441
+ };
442
+ // Every planning BASE (a directory `planningDir` itself would resolve to)
443
+ // contributes TWO scopes: the base itself (for a direct child like
444
+ // `ROADMAP.md`/`config.json`) and its OWN `phases/` subdirectory (for phase
445
+ // artifacts) — kept separate because `phases/` may be independently
446
+ // symlinked to a per-project/per-workstream store while the base directory
447
+ // stays real (the #4894 layout, generalized to every base shape). The
448
+ // longest-`lexRel`-wins rule elsewhere then prefers the `phases` scope for
449
+ // anything under it.
450
+ const addBase = (lexRel, baseAbs) => {
451
+ scopes.push({ lexRel, real: realOf(baseAbs) });
452
+ scopes.push({ lexRel: `${lexRel}/phases`, real: realOf(node_path_1.default.join(baseAbs, 'phases')) });
453
+ };
454
+ const scopes = [];
455
+ const planningAbs = node_path_1.default.join(projectRoot, '.planning');
456
+ const planningBaseReal = realOf(planningAbs);
457
+ scopes.push({ lexRel: '.planning', real: planningBaseReal });
458
+ scopes.push({ lexRel: '.planning/phases', real: realOf(node_path_1.default.join(planningAbs, 'phases')) });
459
+ // #5095 (R7(c) follow-up, security): when `.planning` itself was refused as
460
+ // a dangerous root (filesystem root, or an ancestor of `realRoot` — see
461
+ // `realOf` above), NEVER walk its `workstreams`/project subdirectories to
462
+ // mint further scopes. `listDirs`/`path.join` operate on the LEXICAL,
463
+ // still-symlinked `planningAbs`, so with `.planning -> /` every entry of
464
+ // the real filesystem root (`/etc`, `/usr`, ...) would otherwise surface as
465
+ // an admissible `.planning/<project>` scope — legitimizing an attacker- or
466
+ // container-controlled root-filesystem directory as a containment root the
467
+ // instant it happens to share a name with something reachable from `/`.
468
+ // Each such child is realpath-resolved on its OWN merits (not an ancestor
469
+ // of `realRoot`, not the filesystem root itself), so it silently passes the
470
+ // per-scope refusal check even though its only claim to legitimacy is
471
+ // having been discovered by listing a root the outer check already
472
+ // rejected. Refusing to enumerate here is what makes that refusal actually
473
+ // stick.
474
+ if (planningBaseReal === null)
475
+ return scopes;
476
+ for (const ws of listDirs(node_path_1.default.join(planningAbs, 'workstreams'))) {
477
+ addBase(`.planning/workstreams/${ws}`, node_path_1.default.join(planningAbs, 'workstreams', ws));
478
+ }
479
+ for (const project of listDirs(planningAbs)) {
480
+ if (project === 'workstreams')
481
+ continue;
482
+ const projectAbs = node_path_1.default.join(planningAbs, project);
483
+ addBase(`.planning/${project}`, projectAbs);
484
+ for (const ws of listDirs(node_path_1.default.join(projectAbs, 'workstreams'))) {
485
+ addBase(`.planning/${project}/workstreams/${ws}`, node_path_1.default.join(projectAbs, 'workstreams', ws));
486
+ }
487
+ }
488
+ return scopes;
489
+ }
490
+ function resolveContainmentRoots(projectRoot) {
491
+ let realRoot;
492
+ try {
493
+ realRoot = node_fs_1.default.realpathSync(projectRoot);
494
+ }
495
+ catch {
496
+ realRoot = null;
497
+ }
498
+ return { realRoot, planningScopes: enumeratePlanningScopes(projectRoot, realRoot) };
499
+ }
500
+ function bestPlanningScopeForRel(rel, planningScopes) {
501
+ let best = null;
502
+ for (const scope of planningScopes) {
503
+ 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
504
+ (best === null || scope.lexRel.length > best.lexRel.length)) {
505
+ best = scope;
506
+ }
507
+ }
508
+ return best;
509
+ }
510
+ /**
511
+ * #5095 (R7): map an already-`realpathSync`-resolved target back to a
512
+ * root-relative, posix-normalized spelling. The ANCHORED scope with the
513
+ * LONGEST `lexRel` whose `real` contains the target wins — so a nested scope
514
+ * (e.g. `.planning/workstreams/ws1`) is preferred over the top-level
515
+ * `.planning` scope for a target reachable through both, and a
516
+ * same-named-but-different-store phase under the nested scope is never
517
+ * mapped onto the root scope's spelling. Falls back to a bare
518
+ * project-root-relative path when the target lives inside `realRoot` but no
519
+ * scope's real claims it; else `null` (fail closed — the caller must not
520
+ * guess a spelling for a path it cannot place).
521
+ */
522
+ function mapRealPathToRootRelative(real, realRoot, planningScopes) {
523
+ let best = null;
524
+ for (const scope of planningScopes) {
525
+ if (scope.real !== null &&
526
+ (0, security_cjs_1.isContainedIn)(real, scope.real) &&
527
+ (best === null || scope.lexRel.length > best.lexRel.length)) {
528
+ best = scope;
529
+ }
530
+ }
531
+ if (best !== null) {
532
+ const rel = normalizeRel(node_path_1.default.relative(best.real, real));
533
+ return rel === '' || rel === '.' ? best.lexRel : `${best.lexRel}/${rel}`;
534
+ }
535
+ if ((0, security_cjs_1.isContainedIn)(real, realRoot)) {
536
+ return normalizeRel(node_path_1.default.relative(realRoot, real));
537
+ }
538
+ return null;
539
+ }
540
+ function phaseArtifactPaths(phaseDir, projectRoot) {
541
+ const roots = resolveContainmentRoots(projectRoot);
542
+ if (roots.realRoot === null) {
543
+ return { ok: false, reason: `project root is unreadable: ${projectRoot}` };
544
+ }
545
+ const scan = scanPhasePlans(phaseDir);
546
+ // Fail closed on a non-COMPLETE scan (unreadable phase dir, or an
547
+ // unreadable nested plans/ dir) — a partial scan's invisible contents can
548
+ // never be proven covered (mirrors uncoveredCurrentArtifacts's own
549
+ // fail-closed contract).
550
+ if (scan.scope !== SCOPE.COMPLETE) {
551
+ return { ok: false, reason: `phase directory scan is incomplete (scope: ${scan.scope}): ${phaseDir}` };
552
+ }
553
+ const candidates = [...scan.allPlanFiles, ...scan.summaryFiles].filter((f) => !isVerificationReportPath(f));
554
+ const paths = [];
555
+ for (const f of candidates) {
556
+ let real;
557
+ try {
558
+ real = node_fs_1.default.realpathSync(node_path_1.default.join(phaseDir, f));
559
+ }
560
+ catch {
561
+ return { ok: false, reason: `phase artifact is unreadable: ${f}` };
562
+ }
563
+ const mapped = mapRealPathToRootRelative(real, roots.realRoot, roots.planningScopes);
564
+ if (mapped === null) {
565
+ return { ok: false, reason: `phase artifact escapes the project root: ${f}` };
566
+ }
567
+ paths.push(mapped);
568
+ }
569
+ // #5095 (R2): the phase dir's own mapped spelling, fed to `sharedRootsFor`
570
+ // so a workstream/project ROADMAP under an out-of-repo store is still
571
+ // recognized as a shared planning document even when `phaseDir` itself was
572
+ // addressed by its real (non-`.planning`-spelled) path.
573
+ let mappedPhaseDir = null;
574
+ try {
575
+ const realPhaseDir = node_fs_1.default.realpathSync(phaseDir);
576
+ mappedPhaseDir = mapRealPathToRootRelative(realPhaseDir, roots.realRoot, roots.planningScopes);
577
+ }
578
+ catch {
579
+ mappedPhaseDir = null;
580
+ }
581
+ return { ok: true, paths, scope: scan.scope, mappedPhaseDir };
186
582
  }
187
583
  // ─── #4155: covered-input fingerprint ──────────────────────────────────────────
188
584
  /**
@@ -194,16 +590,34 @@ function canonicalizeCoveredFiles(files) {
194
590
  * v1 (#4155) — every covered path's whole bytes, uniformly.
195
591
  * v2 (#4623) — repo-wide planning documents (`isSharedPlanningDoc`) are
196
592
  * excluded from the hash by construction.
593
+ * v3 (#5095, ADR-5057 Phase 2) — v2 PLUS: (a) report-shaped declared paths
594
+ * (`isVerificationReportPath`) are filtered before hashing — a
595
+ * report is never an input to its own digest (#4857); (b) the
596
+ * hashed set is the declared list UNIONED with the phase's own
597
+ * live `*-PLAN.md`/`*-SUMMARY.md` artifacts
598
+ * (`phaseArtifactPaths`), computed identically on both the
599
+ * emit (`cmdVerificationFingerprint`) and check
600
+ * (`readVerificationStatus`) sides — a plan/summary added
601
+ * after fingerprinting moves the digest itself, with no
602
+ * separate live-directory rescan needed (#4817); (c)
603
+ * containment additionally admits a `.planning`-spelled path
604
+ * whose realpath sits in one of the anchored planning scopes
605
+ * `resolveContainmentRoots` enumerates (`.planning`,
606
+ * `.planning/<project>`, `.planning/workstreams/<ws>`,
607
+ * `.planning/<project>/workstreams/<ws>`), covering a
608
+ * per-project/per-workstream store symlink (amendment 1,
609
+ * revised R7).
197
610
  *
198
611
  * A stored digest names its own version (`v<N>:sha256:…`), and
199
612
  * `readVerificationStatus` recomputes under the STORED version rather than
200
613
  * 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
614
+ * to `stale` on upgrade. A legacy v1/v2 report keeps its own semantics
615
+ * (shared documents included for v1; no report filter or artifact union for
616
+ * either) until it is re-fingerprinted; only a version outside
203
617
  * `KNOWN_FINGERPRINT_VERSIONS` is unrecomputable and fails closed.
204
618
  */
205
- const FINGERPRINT_VERSION = 2;
206
- const KNOWN_FINGERPRINT_VERSIONS = new Set([1, 2]);
619
+ const FINGERPRINT_VERSION = 3;
620
+ const KNOWN_FINGERPRINT_VERSIONS = new Set([1, 2, 3]);
207
621
  /**
208
622
  * #4623: the planning roots whose DIRECT children are repo-wide planning
209
623
  * documents, as project-root-relative posix paths. Always `.planning`; plus
@@ -265,6 +679,31 @@ function isSharedPlanningDoc(rel, roots = ['.planning']) {
265
679
  return false;
266
680
  return roots.includes(node_path_1.default.posix.dirname(rel));
267
681
  }
682
+ /**
683
+ * #5095 (R2): `sharedPlanningRoots` extended with the phase's own MAPPED
684
+ * (root-relative, posix) directory when one is available —
685
+ * `phaseArtifactPaths`'s `mappedPhaseDir` — so a workstream/project ROADMAP
686
+ * under an out-of-repo store is still recognized as shared even when
687
+ * `phaseDir` was addressed by its real, non-`.planning`-spelled path (the
688
+ * #4894 `--project-dir` shape). Falls back to the existing absolute-path
689
+ * derivation (`sharedPlanningRoots(projectRoot, phaseDir)`) when no mapped
690
+ * spelling is available — byte-for-behaviour identical to the pre-#5095
691
+ * seam for every caller that has no artifact-derivation step of its own.
692
+ */
693
+ function sharedRootsFor(projectRoot, phaseDir, mappedPhaseDir) {
694
+ const roots = sharedPlanningRoots(projectRoot, phaseDir);
695
+ if (mappedPhaseDir) {
696
+ const phasesDir = node_path_1.default.posix.dirname(mappedPhaseDir);
697
+ const planningDir = node_path_1.default.posix.dirname(phasesDir);
698
+ if (node_path_1.default.posix.basename(phasesDir) === 'phases' &&
699
+ (planningDir === '.planning' || planningDir.startsWith('.planning/')) &&
700
+ !planningDir.includes('/../') &&
701
+ !roots.includes(planningDir)) {
702
+ roots.push(planningDir);
703
+ }
704
+ }
705
+ return roots;
706
+ }
268
707
  /**
269
708
  * #4623: the fingerprint version a stored `covered_digest` was computed
270
709
  * under, or `null` when the prefix is absent, malformed, or names a version
@@ -278,101 +717,90 @@ function parseFingerprintVersion(digest) {
278
717
  const version = Number(m[1]);
279
718
  return KNOWN_FINGERPRINT_VERSIONS.has(version) ? version : null;
280
719
  }
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 = {}) {
720
+ function deriveCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
322
721
  // #4623: `version` selects the input shape to hash under — the CURRENT
323
722
  // one for a fresh fingerprint (the CLI verb), or the STORED one when
324
723
  // `readVerificationStatus` recomputes against a report's own digest.
325
- // `opts.phaseDir` lets v2 recognise the phase's own planning root
724
+ // `opts.phaseDir` lets v2+ recognise the phase's own planning root
326
725
  // (`sharedPlanningRoots`); without it only `.planning/` itself is shared.
327
726
  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) : [];
727
+ return { ok: true, digest: null, files: [], mappedPhaseDir: null };
728
+ // #5095 (R1, ADR-5057 Phase 2): for v3+ with a known phaseDir, the hashed
729
+ // set is the declared list UNIONED with the phase's own live artifacts —
730
+ // computed here, ONCE, on BOTH the emit and check sides, rather than
731
+ // separately in the CLI emitter and again in here (#5095 R7: the prior
732
+ // shape ran `phaseArtifactPaths` twice on the emit path — once in
733
+ // `cmdVerificationFingerprint` to build the emitted `covered_files`, again
734
+ // in here to hash — a race window where a plan/summary added between the
735
+ // two calls could make the emitted list and the hashed set disagree). A
736
+ // plan/summary added after fingerprinting therefore moves the digest
737
+ // directly; `--raw` / MCP callers that write their own declared list
738
+ // (without enumerating plans/summaries) still match, because the checker
739
+ // adds the identical union. An artifact-resolution failure makes the WHOLE
740
+ // fingerprint unresolvable (fail closed).
741
+ let declared = canonicalizeCoveredFiles(coveredFiles, { version });
742
+ let artifactMappedPhaseDir = null;
743
+ if (version >= 3 && opts.phaseDir) {
744
+ const artifacts = phaseArtifactPaths(opts.phaseDir, projectRoot);
745
+ if (!artifacts.ok)
746
+ return { ok: false, reason: artifacts.reason };
747
+ artifactMappedPhaseDir = artifacts.mappedPhaseDir;
748
+ declared = canonicalizeCoveredFiles([...declared, ...artifacts.paths], { version });
749
+ }
750
+ if (declared.length === 0)
751
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
752
+ const sharedRoots = version >= 2 ? sharedRootsFor(projectRoot, opts.phaseDir, artifactMappedPhaseDir) : [];
333
753
  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
754
+ // Canonicalize the roots ONCE — every candidate's realpath is checked
755
+ // against these, not the possibly-symlinked `projectRoot`/`.planning`
756
+ // arguments themselves. Always via the REAL fs, never fsImpl: `projectRoot`
757
+ // is a trusted anchor the CALLER derived (resolveProjectRoot), not
758
+ // attacker-influenced covered-input data — routing it through a
759
+ // caller-scoped containment seam (e.g. #4155's
339
760
  // containmentEnforcingVerificationFs, confined to `.planning/`, a proper
340
761
  // SUBSET of `projectRoot`) would reject the root itself and fail every
341
762
  // 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
- }
763
+ const roots = resolveContainmentRoots(projectRoot);
764
+ if (roots.realRoot === null)
765
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
349
766
  const parts = [];
350
- for (const rel of uniqueSorted) {
767
+ for (const rel of declared) {
351
768
  // `normalizeRel` (already applied by `canonicalizeCoveredFiles` above)
352
769
  // collapses internal `..` segments before `rel` ever reaches here
353
770
  // (`a/../../b` → `../b`), so this start-of-string check is already the
354
771
  // full lexical confinement test — no separate post-`path.resolve`
355
772
  // re-check can observe a different answer.
356
- if (rel === '' || rel === '..' || rel.startsWith('../') || node_path_1.default.isAbsolute(rel))
357
- return null;
773
+ if (rel === '' || rel === '..' || rel.startsWith('../') || node_path_1.default.isAbsolute(rel)) {
774
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
775
+ }
776
+ // #5095 (R7): a `.planning`-spelled path may resolve inside the checkout
777
+ // root OR the LONGEST-prefix anchored planning scope that claims it
778
+ // (`bestPlanningScopeForRel`) — every other path is confined to the
779
+ // checkout root alone, exactly as before.
780
+ const firstSegment = rel.split('/')[0];
781
+ const admissibleRoots = firstSegment === '.planning'
782
+ ? [roots.realRoot, bestPlanningScopeForRel(rel, roots.planningScopes)?.real ?? null].filter((r) => r !== null)
783
+ : [roots.realRoot];
358
784
  const resolved = node_path_1.default.resolve(projectRoot, rel);
359
785
  let bytes;
360
786
  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.
787
+ // A regular file INSIDE a known root can still be a symlink whose
788
+ // TARGET escapes every known root — statSync/readFileSync follow
789
+ // symlinks, so the lexical confinement check above is not enough.
790
+ // realpathSync resolves the actual target; re-confining against the
791
+ // admissible roots closes that gap.
365
792
  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;
793
+ // Every operand is already realpath-resolved (this fn's own
794
+ // realpathSync calls above), so the shared containment comparison
795
+ // applies directly (ADR-4650) — no re-resolution through
796
+ // assertWithinRoot/tryWithinRoot, which would redo work this function
797
+ // already owns for its exists-vs-escaped tri-state.
798
+ if (!admissibleRoots.some((root) => (0, security_cjs_1.isContainedIn)(real, root))) {
799
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
372
800
  }
373
801
  const st = node_fs_1.default.statSync(real);
374
802
  if (!st.isFile())
375
- return null;
803
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
376
804
  // #4623 (v2+): a repo-wide planning document is VALIDATED exactly as
377
805
  // every other covered path — confined, present, a regular file; the
378
806
  // fail-closed contract above is unchanged — but its bytes contribute
@@ -385,7 +813,7 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
385
813
  bytes = node_fs_1.default.readFileSync(real);
386
814
  }
387
815
  catch {
388
- return null;
816
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
389
817
  }
390
818
  const fileHash = node_crypto_1.default.createHash('sha256').update(bytes).digest('hex');
391
819
  parts.push(`${rel}\n${fileHash}\n`);
@@ -395,13 +823,26 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
395
823
  // evidence in it at all — a constant digest over the header would satisfy
396
824
  // the fingerprint pair while grounding the verification in nothing. Fail
397
825
  // closed, the same way an empty declaration does.
398
- if (version >= 2 && hashed === 0)
399
- return null;
826
+ if (version >= 2 && hashed === 0) {
827
+ return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
828
+ }
400
829
  const aggregate = node_crypto_1.default
401
830
  .createHash('sha256')
402
831
  .update(`v${version}\n${parts.join('')}`, 'utf-8')
403
832
  .digest('hex');
404
- return `v${version}:sha256:${aggregate}`;
833
+ return { ok: true, digest: `v${version}:sha256:${aggregate}`, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
834
+ }
835
+ /**
836
+ * #4155/#5095: recompute the deterministic content fingerprint over a
837
+ * verifier's declared covered-input set, returning JUST the digest string
838
+ * (or `null` if unresolvable) — the signature every existing caller
839
+ * (`readVerificationStatus`, tests) already depends on. Thin wrapper over
840
+ * `deriveCoveredDigest`, collapsing its `{ ok: false, reason }` arm to `null`
841
+ * exactly as the pre-#5095 single-function shape did.
842
+ */
843
+ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
844
+ const result = deriveCoveredDigest(projectRoot, coveredFiles, version, opts);
845
+ return result.ok ? result.digest : null;
405
846
  }
406
847
  /**
407
848
  * #4155: the content fingerprint only recomputes digests for paths the
@@ -413,9 +854,9 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
413
854
  * each is represented in `coveredFiles` — matched by suffix (mirrors
414
855
  * `matchRequestedFile`'s convention) since `coveredFiles` holds
415
856
  * project-root-relative paths while the scan returns phase-relative
416
- * filenames. Returns `true` if every current plan/summary is covered,
417
- * `false` otherwise — callers only ever branch on this pass/fail, so no
418
- * caller needs which artifact was uncovered.
857
+ * filenames. Returns the current plans/summaries the report never declared
858
+ * (empty when every one is covered), so the stale diagnostic can name them (#4817); `null` when the
859
+ * scan itself was incomplete, which callers treat as stale.
419
860
  *
420
861
  * Fails CLOSED on an incomplete scan: `scanPhasePlans` never throws on a
421
862
  * readdir failure — it reports it via `scope` (`SCOPE.UNREADABLE` for the
@@ -424,18 +865,18 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
424
865
  * contract (`planning-scope.cts`): zero items under a non-`COMPLETE` scope
425
866
  * is a NON-answer, never "this phase has no plans." Branching on `scope`
426
867
  * here (rather than a try/catch, which this scan never triggers) is what
427
- * makes an unreadable `plans/` dir report `false` instead of silently
868
+ * makes an unreadable `plans/` dir report `null` (stale) instead of silently
428
869
  * treating its invisible contents as vacuously covered — the same
429
870
  * fail-open regression #3057 B3 fixed for the legacy path.
430
871
  */
431
- function allCurrentArtifactsCovered(phaseDir, coveredFiles) {
872
+ function uncoveredCurrentArtifacts(phaseDir, coveredFiles) {
432
873
  const scan = scanPhasePlans(phaseDir);
433
874
  if (scan.scope !== SCOPE.COMPLETE)
434
- return false;
875
+ return null;
435
876
  const coveredPosix = canonicalizeCoveredFiles(coveredFiles);
436
- return [...scan.allPlanFiles, ...scan.summaryFiles].every((artifact) => {
877
+ return [...scan.allPlanFiles, ...scan.summaryFiles].filter((artifact) => {
437
878
  const artifactPosix = toPosix(artifact);
438
- return coveredPosix.some((c) => c === artifactPosix || c.endsWith(`/${artifactPosix}`));
879
+ return !coveredPosix.some((c) => c === artifactPosix || c.endsWith(`/${artifactPosix}`));
439
880
  });
440
881
  }
441
882
  /**
@@ -508,18 +949,232 @@ function defaultPhaseCleanCommitTimesMs(phaseDir, files, execGitFn = shell_comma
508
949
  return commitTimes;
509
950
  }
510
951
  /**
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.
952
+ * #4857: which declared covered files changed since the report was written.
953
+ * `covered_digest` is one aggregate hash, so it can say THAT something moved
954
+ * but not what; this names the files by time instead — each covered file
955
+ * (report paths and, for v2+, shared planning documents excluded, exactly as
956
+ * the digest excludes them) whose clean commit time, or mtime when it is
957
+ * uncommitted or edited since, is newer than the report's own. A file that
958
+ * can no longer be statted counts as moved. Advisory only: it never decides
959
+ * staleness (the digest does), and an unreadable clock yields fewer names,
960
+ * never a wrong status. Timed through the same injectable resolver the
961
+ * legacy mtime check uses, rooted at the project so the covered paths
962
+ * (project-root-relative) resolve.
963
+ */
964
+ function coveredFilesMovedSinceReport(projectRoot, phaseDir, reportPath, coveredFiles, version, fsImpl, resolver) {
965
+ const sharedRoots = version >= 2 ? sharedRootsFor(projectRoot, phaseDir, null) : [];
966
+ const files = canonicalizeCoveredFiles(coveredFiles, { version })
967
+ .filter((f) => !isVerificationReportPath(f) && !isSharedPlanningDoc(f, sharedRoots));
968
+ if (files.length === 0)
969
+ return [];
970
+ const reportRel = toPosix(node_path_1.default.relative(projectRoot, reportPath));
971
+ let commitTimes;
972
+ try {
973
+ commitTimes = resolver(projectRoot, [reportRel, ...files]);
974
+ }
975
+ catch {
976
+ commitTimes = new Map();
977
+ }
978
+ const timeOf = (rel) => {
979
+ const committed = commitTimes.get(rel);
980
+ if (committed !== undefined)
981
+ return committed;
982
+ try {
983
+ return fsImpl.statSync(node_path_1.default.join(projectRoot, rel)).mtimeMs;
984
+ }
985
+ catch {
986
+ return null;
987
+ }
988
+ };
989
+ const reportTime = timeOf(reportRel);
990
+ if (reportTime === null)
991
+ return [];
992
+ return files.filter((rel) => {
993
+ const t = timeOf(rel);
994
+ return t === null || t > reportTime;
995
+ });
996
+ }
997
+ /** At most this many uncovered artifact names are spelled out in `next_action`; the rest are counted. */
998
+ const STALE_NAMED_ARTIFACT_LIMIT = 5;
999
+ function staleNextAction(detail) {
1000
+ const name = (items) => {
1001
+ const shown = items.slice(0, STALE_NAMED_ARTIFACT_LIMIT).map((a) => io.formatDiagnosticToken(a));
1002
+ const more = items.length - shown.length;
1003
+ return `${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`;
1004
+ };
1005
+ const reasons = [];
1006
+ if (detail.moved.length > 0) {
1007
+ reasons.push(`covered source files changed after the verifier last ran: ${name(detail.moved)}`);
1008
+ }
1009
+ else if (detail.digestMoved) {
1010
+ reasons.push('covered source files changed after the verifier last ran');
1011
+ }
1012
+ if (detail.uncovered.length > 0) {
1013
+ reasons.push(`the report never covered ${name(detail.uncovered)} (a current phase plan or summary it did not declare in covered_files)`);
1014
+ }
1015
+ const tail = detail.digestMoved ? '' : '; no covered file changed';
1016
+ return `Verification is stale — ${reasons.join(', and ')}${tail}. ${STALE_REMEDY}`;
1017
+ }
1018
+ /**
1019
+ * The one result builder (#5118): every `VerificationStatusResult` is
1020
+ * projected from `VERIFICATION_ROUTES[status]` here — `route` (the bare
1021
+ * command) and `next_command` (its runtime projection, #2617) come from the
1022
+ * same table entry, so they cannot disagree.
514
1023
  */
515
- function missingResult(runtime, phaseArg) {
516
- const route = VERIFICATION_ROUTING_TABLE['missing'];
1024
+ function routeResult(status, ctx) {
1025
+ const entry = VERIFICATION_ROUTES[status];
1026
+ const detail = status === VERIFICATION_STATUS.STALE && ctx.staleDetail
1027
+ && (ctx.staleDetail.uncovered.length > 0 || ctx.staleDetail.moved.length > 0)
1028
+ ? ctx.staleDetail
1029
+ : undefined;
517
1030
  return {
518
- status: route.status,
519
- next_action: route.next_action,
520
- next_command: projectNextCommand(route.next_command, runtime, phaseArg),
1031
+ status,
1032
+ next_action: detail ? staleNextAction(detail) : entry.next_action,
1033
+ next_command: projectNextCommand(entry.command, ctx.runtime, `${ctx.phaseArg}${entry.tail}`),
1034
+ route: entry.command,
1035
+ ...(ctx.message !== undefined ? { message: ctx.message } : {}),
1036
+ ...(detail && detail.uncovered.length > 0 ? { uncovered_artifacts: detail.uncovered } : {}),
1037
+ ...(detail && detail.moved.length > 0 ? { moved_files: detail.moved } : {}),
1038
+ ...(ctx.staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
521
1039
  };
522
1040
  }
1041
+ /**
1042
+ * ADR-5057 amendment 2 (#4987): true only when there is no phase DIRECTORY at
1043
+ * `phaseDir` — `stat` fails with ENOENT / ENOTDIR (a dangling symlink stats as
1044
+ * ENOENT), or it succeeds on something that is not a directory (a regular
1045
+ * file). Any other stat failure — EACCES, or a code-less containment error
1046
+ * from an injected FsLike (planning-inspect's seam) — is NOT "not found": the
1047
+ * caller falls through to the existing readdir path (`missing`).
1048
+ */
1049
+ function isPhaseDirNotFound(fsImpl, phaseDir) {
1050
+ let st;
1051
+ try {
1052
+ st = fsImpl.statSync(phaseDir);
1053
+ }
1054
+ catch (err) {
1055
+ const code = err?.code;
1056
+ return code === 'ENOENT' || code === 'ENOTDIR';
1057
+ }
1058
+ return typeof st?.isDirectory === 'function' && !st.isDirectory();
1059
+ }
1060
+ function phaseDirNotFoundMessage(phaseDir) {
1061
+ return `Usage error: phase directory not found at ${io.formatDiagnosticToken(phaseDir)}`;
1062
+ }
1063
+ /**
1064
+ * The PHASES ROOT a phase directory must live under: the parent of `phaseDir`
1065
+ * in its own (unresolved) spelling. It is the fixed anchor of the containment
1066
+ * check — a phase directory symlinked outside the project resolves outside, so
1067
+ * containing a report against that directory's OWN realpath alone would admit
1068
+ * the escape (both resolve outside). A phases root that is itself a symlinked
1069
+ * per-scope store resolves consistently on both sides of the comparison.
1070
+ */
1071
+ function planningContainmentRoot(phaseDir) {
1072
+ return node_path_1.default.dirname(node_path_1.default.resolve(phaseDir));
1073
+ }
1074
+ /**
1075
+ * True when the phase directory really lives under its phases root AND
1076
+ * `filePath` really lives inside that phase directory (symlinks followed on
1077
+ * both). Either escape reads `missing`. Unresolvable → false.
1078
+ */
1079
+ function isReportContained(phaseDir, filePath) {
1080
+ try {
1081
+ const realDir = node_fs_1.default.realpathSync(phaseDir);
1082
+ return (0, security_cjs_1.isContainedIn)(realDir, node_fs_1.default.realpathSync(planningContainmentRoot(phaseDir)))
1083
+ && (0, security_cjs_1.isContainedIn)(node_fs_1.default.realpathSync(filePath), realDir);
1084
+ }
1085
+ catch {
1086
+ return false;
1087
+ }
1088
+ }
1089
+ /**
1090
+ * #5118: steps 0-2 of `readVerificationStatus` — no phase directory
1091
+ * (`phase_dir_not_found`), no report or no `status` (`missing`), a report
1092
+ * whose frontmatter is not YAML (`unparseable`), or the report's writer-set
1093
+ * status. Reads only the report's frontmatter (no staleness check, no git).
1094
+ * THROWS `VerificationStatusError` for a status outside the writer set — the
1095
+ * one judgement (`reportStatusOf`) every reader shares.
1096
+ */
1097
+ function locatePhaseReport(phaseDir, fsImpl, convention) {
1098
+ if (isPhaseDirNotFound(fsImpl, phaseDir))
1099
+ return { kind: 'phase_dir_not_found' };
1100
+ const baseName = node_path_1.default.basename(phaseDir);
1101
+ let verificationFile = null;
1102
+ try {
1103
+ const entries = fsImpl.readdirSync(phaseDir);
1104
+ // #3492: pin selection to THIS phase's own token so a stray cross-phase
1105
+ // or sentinel-numbered canonically-shaped file cannot outrank this phase's
1106
+ // own report. #612: bracket directories resolve with the convention-aware
1107
+ // token. #4187: keep the bare report tier aligned with every other reader.
1108
+ const resolutionToken = convention === 'bracket'
1109
+ ? extractPhaseToken(baseName, convention)
1110
+ : extractPhaseToken(baseName);
1111
+ verificationFile = resolveVerificationFile(entries, {
1112
+ allowBare: true,
1113
+ phaseToken: resolutionToken,
1114
+ phaseDirName: baseName,
1115
+ convention,
1116
+ });
1117
+ }
1118
+ catch {
1119
+ // Directory unreadable → treat as missing
1120
+ verificationFile = null;
1121
+ }
1122
+ if (!verificationFile)
1123
+ return { kind: 'missing' };
1124
+ // extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
1125
+ const filePath = node_path_1.default.join(phaseDir, verificationFile);
1126
+ // #5118 security review (containment): a report whose real path escapes its
1127
+ // own phase directory (a symlink out of the project) is refused BEFORE a
1128
+ // byte of it is read — it reads `missing`, and no value from it can ever
1129
+ // reach a message (the out-of-set error echoes the report's `status`). An
1130
+ // injected FsLike (planning-inspect's containment seam) enforces its own
1131
+ // containment by throwing, which the read below folds to `missing` too.
1132
+ if (fsImpl === defaultFsImpl && !isReportContained(phaseDir, filePath))
1133
+ return { kind: 'missing' };
1134
+ let fm = {};
1135
+ try {
1136
+ // #3707-CR: normalize line endings at this read boundary so a lone-CR
1137
+ // report's `---\r…\r---` fence still matches extractFrontmatter's check.
1138
+ const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
1139
+ fm = extractFrontmatter(content, filePath);
1140
+ // #4806: an unparseable frontmatter block is NOT "missing" — the file
1141
+ // exists and verification ran; the caller is sent to fix the YAML.
1142
+ if (fm[FRONTMATTER_UNPARSEABLE] === true) {
1143
+ return { kind: 'unparseable' };
1144
+ }
1145
+ }
1146
+ catch {
1147
+ // An unreadable report reads as carrying no status (`missing`).
1148
+ fm = {};
1149
+ }
1150
+ // #5118: judged OUTSIDE the parse `try`, so an out-of-set value can never be
1151
+ // swallowed into `missing`.
1152
+ const status = reportStatusOf(fm, filePath);
1153
+ if (status === null)
1154
+ return { kind: 'missing' };
1155
+ return { kind: 'status', status, filePath, fm };
1156
+ }
1157
+ /**
1158
+ * #5118 (ADR-5057 Phase 4, "no write before the error"): the first report
1159
+ * among `phaseDirs` whose `status` is outside the closed set, or `null`.
1160
+ * Frontmatter-only (the same locator `readVerificationStatus` runs), so a
1161
+ * command that WRITES validates every report it will read BEFORE its first
1162
+ * write and fails having written nothing.
1163
+ */
1164
+ function findVerificationStatusError(phaseDirs, deps = {}) {
1165
+ const fsImpl = deps.fs ?? defaultFsImpl;
1166
+ for (const phaseDir of phaseDirs) {
1167
+ try {
1168
+ locatePhaseReport(phaseDir, fsImpl, deps.convention);
1169
+ }
1170
+ catch (err) {
1171
+ if (err instanceof VerificationStatusError)
1172
+ return err;
1173
+ throw err;
1174
+ }
1175
+ }
1176
+ return null;
1177
+ }
523
1178
  /**
524
1179
  * #3518: the shared phase-pinned artifact-selection core BOTH single-pick
525
1180
  * resolvers (`resolveVerificationFile` for `*-VERIFICATION.md`,
@@ -735,7 +1390,16 @@ function findStaleVerificationSummary(phaseDir, fsImpl = defaultFsImpl, phaseCle
735
1390
  * 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
736
1391
  * parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
737
1392
  * If no frontmatter block or no `status` key → status 'missing'.
738
- * 3. Map to routing table. Unknown non-empty value → status 'unknown'.
1393
+ * 3. Route through VERIFICATION_ROUTES (`routeResult`). A status outside the
1394
+ * writer set (`VERIFIER_STATUSES`) — any other string, case variant,
1395
+ * reader-only member, or non-string value — THROWS `VerificationStatusError`
1396
+ * (#5118). The throw sits outside the parse `try`, and before the
1397
+ * `gaps_found` short-circuit and the staleness check, so it can neither be
1398
+ * folded into `missing` nor masked by `stale` (#4817 Part 2).
1399
+ *
1400
+ * #5118 / ADR-5057 amendment 2: a path with no phase DIRECTORY behind it
1401
+ * (ENOENT, ENOTDIR, a non-directory) reads `phase_dir_not_found` — a usage
1402
+ * error with no next command — not `missing` (#4987).
739
1403
  *
740
1404
  * The internal staleness check can itself fail (fs / scanPhasePlans / clock
741
1405
  * error); when it does, `status` is routed as if nothing were stale (the
@@ -769,84 +1433,26 @@ function readVerificationStatus(phaseDir, opts = {}) {
769
1433
  // that already know the number (init) pass it explicitly and always get it.
770
1434
  const phaseArgSource = opts.phaseNumber ?? (/^\d+(\.\d+)*$/.test(derivedPhaseNumber) ? derivedPhaseNumber : '');
771
1435
  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);
1436
+ const route = (status, extra = {}) => routeResult(status, { runtime, phaseArg, ...extra });
1437
+ // Steps 0-2 (no phase dir → usage error; find the report; parse its
1438
+ // frontmatter and judge `status`) are the frontmatter-only locator shared
1439
+ // with the pre-write validator (`findVerificationStatusError`). An
1440
+ // out-of-set status THROWS VerificationStatusError out of the locator —
1441
+ // before the gaps_found short-circuit and the staleness check below, so
1442
+ // `stale` can never mask it (#4817 Part 2).
1443
+ const located = locatePhaseReport(phaseDir, fsImpl, opts.convention);
1444
+ if (located.kind === 'phase_dir_not_found') {
1445
+ return route(VERIFICATION_STATUS.PHASE_DIR_NOT_FOUND, { message: phaseDirNotFoundMessage(phaseDir) });
840
1446
  }
1447
+ if (located.kind === 'missing')
1448
+ return route(VERIFICATION_STATUS.MISSING);
1449
+ if (located.kind === 'unparseable')
1450
+ return route(VERIFICATION_STATUS.UNPARSEABLE);
1451
+ const { status: reportStatus, fm } = located;
841
1452
  // gaps_found takes priority over stale — gap closure is the correct next
842
1453
  // 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
- };
1454
+ if (reportStatus === VERIFICATION_STATUS.GAPS_FOUND) {
1455
+ return route(VERIFICATION_STATUS.GAPS_FOUND);
850
1456
  }
851
1457
  // #4155: a report that declares a covered-input fingerprint is checked by
852
1458
  // RECOMPUTING that fingerprint over current file content — strictly
@@ -868,30 +1474,57 @@ function readVerificationStatus(phaseDir, opts = {}) {
868
1474
  typeof coveredDigestVal === 'string' &&
869
1475
  coveredDigestVal.trim().length > 0;
870
1476
  let staleCheckIndeterminate = false;
1477
+ let staleDetail;
871
1478
  let isStale;
872
1479
  if (declaresFingerprint) {
873
1480
  // Stated directly rather than relying on `null !== coveredDigestVal`
874
1481
  // being true whenever the pair is malformed: `!hasWellFormedFingerprint`
875
1482
  // fails closed explicitly, and its `||` short-circuit means
876
- // computeCoveredDigest/allCurrentArtifactsCovered never run on a
1483
+ // computeCoveredDigest/uncoveredCurrentArtifacts never run on a
877
1484
  // malformed (wrong-shaped) `coveredFilesVal`. The two `||`s after it
878
1485
  // short-circuit in turn: the live-directory re-scan (for a plan/summary
879
1486
  // added AFTER verification and never declared in covered_files) only
880
1487
  // runs once the digest itself has already matched.
881
1488
  //
882
1489
  // #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
1490
+ // current constant — a v1/v2 report written before a later semantics
1491
+ // change keeps its own semantics rather than going stale on upgrade. An
885
1492
  // unknown version parses to `null`, which `computeCoveredDigest`
886
1493
  // refuses (returns `null`), so the compare below fails closed.
1494
+ const projectRoot = (0, project_root_cjs_1.resolveProjectRoot)(phaseDir);
887
1495
  const storedVersion = hasWellFormedFingerprint && typeof coveredDigestVal === 'string'
888
1496
  ? parseFingerprintVersion(coveredDigestVal)
889
1497
  : 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);
1498
+ if (!hasWellFormedFingerprint || storedVersion === null) {
1499
+ isStale = true;
1500
+ }
1501
+ else if (storedVersion >= 3) {
1502
+ // #5095 (R1): v3's digest is computed over the declared set UNIONED
1503
+ // with the phase's own live artifacts — a plan/summary added after
1504
+ // fingerprinting already moves the digest itself, so the separate
1505
+ // live-directory re-scan (`uncoveredCurrentArtifacts`) is redundant
1506
+ // for v3 and is not run.
1507
+ isStale =
1508
+ computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal;
1509
+ if (isStale) {
1510
+ const moved = coveredFilesMovedSinceReport(projectRoot, phaseDir, located.filePath, coveredFilesVal, storedVersion, fsImpl, phaseCleanCommitTimesMs);
1511
+ if (moved.length > 0)
1512
+ staleDetail = { uncovered: [], moved, digestMoved: true };
1513
+ }
1514
+ }
1515
+ else {
1516
+ // v1/v2: unchanged — the digest alone cannot see a plan/summary that
1517
+ // was never declared, so the live-directory re-scan still runs.
1518
+ const digestMoved = computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal;
1519
+ const uncovered = uncoveredCurrentArtifacts(phaseDir, coveredFilesVal);
1520
+ isStale = digestMoved || uncovered === null || uncovered.length > 0;
1521
+ const moved = digestMoved
1522
+ ? coveredFilesMovedSinceReport(projectRoot, phaseDir, located.filePath, coveredFilesVal, storedVersion, fsImpl, phaseCleanCommitTimesMs)
1523
+ : [];
1524
+ if ((uncovered !== null && uncovered.length > 0) || moved.length > 0) {
1525
+ staleDetail = { uncovered: uncovered ? [...uncovered].sort() : [], moved, digestMoved };
1526
+ }
1527
+ }
895
1528
  }
896
1529
  else {
897
1530
  const staleCheck = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs, opts.convention);
@@ -904,39 +1537,12 @@ function readVerificationStatus(phaseDir, opts = {}) {
904
1537
  staleCheckIndeterminate = !staleCheck.determined;
905
1538
  }
906
1539
  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
- };
1540
+ // #4682 / #5118: the one stale route — execute-phase's verify_phase_goal
1541
+ // step re-runs the verifier, regenerating VERIFICATION.md and its digest.
1542
+ return route(VERIFICATION_STATUS.STALE, staleDetail ? { staleDetail } : {});
1543
+ }
1544
+ // 3. Route the writer-set member through the one table.
1545
+ return route(reportStatus, { staleCheckIndeterminate });
940
1546
  }
941
1547
  /**
942
1548
  * isPhaseComplete — the single canonical owner of "is phase P complete?"
@@ -949,11 +1555,18 @@ function readVerificationStatus(phaseDir, opts = {}) {
949
1555
  * `*-VERIFICATION.md` is complete (#3168). A ROADMAP checkbox has no machine
950
1556
  * authority and is never consulted — this function never reads ROADMAP.md.
951
1557
  *
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.
1558
+ * `complete` is exactly `verification.status === VERIFICATION_STATUS.PASSED`.
1559
+ * `verification` carries the FULL routing result
1560
+ * (status/next_action/next_command/route), so a caller can distinguish a
1561
+ * failing verdict (`gaps_found`/`human_needed`/`stale`) from an absent one
1562
+ * (`missing`) — both are "not complete", but they are not the same
1563
+ * non-answer.
1564
+ *
1565
+ * #5118: a report whose `status` is outside the closed set does NOT throw
1566
+ * out of here (ADR-5057 :223 — the no-throw contract Phases 2–4 preserve): it
1567
+ * degrades to scope UNREADABLE, `verification.status` is `null` (route `''`,
1568
+ * the error's message as `next_action`), `complete` is false, and
1569
+ * `value.statusError` holds the typed error for the caller to carry.
957
1570
  *
958
1571
  * `scope` is UNREADABLE when `phaseDir` itself could not be listed — this is
959
1572
  * INDEPENDENT of readVerificationStatus's own no-throw fail-open contract for
@@ -976,17 +1589,29 @@ function isPhaseComplete(phaseDir, deps = {}) {
976
1589
  catch {
977
1590
  readable = false;
978
1591
  }
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
- });
1592
+ let verification;
1593
+ let statusError;
1594
+ try {
1595
+ verification = readVerificationStatus(phaseDir, {
1596
+ fs: deps.fs,
1597
+ phaseCleanCommitTimesMs: deps.phaseCleanCommitTimesMs,
1598
+ runtime: deps.runtime,
1599
+ phaseNumber: deps.phaseNumber,
1600
+ convention: deps.convention,
1601
+ });
1602
+ }
1603
+ catch (err) {
1604
+ if (!(err instanceof VerificationStatusError))
1605
+ throw err;
1606
+ statusError = err;
1607
+ readable = false;
1608
+ verification = { status: null, next_action: err.message, next_command: '', route: '' };
1609
+ }
986
1610
  return {
987
1611
  value: {
988
- complete: verification.status === 'passed',
1612
+ complete: verification.status === VERIFICATION_STATUS.PASSED,
989
1613
  verification,
1614
+ ...(statusError ? { statusError } : {}),
990
1615
  },
991
1616
  scope: readable ? SCOPE.COMPLETE : SCOPE.UNREADABLE,
992
1617
  };
@@ -995,6 +1620,13 @@ function isPhaseComplete(phaseDir, deps = {}) {
995
1620
  * CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
996
1621
  * emit via io.output().
997
1622
  *
1623
+ * #5118: an out-of-set report status throws `VerificationStatusError` out of
1624
+ * here with nothing on stdout; gsd-tools.cjs translates it (once, centrally)
1625
+ * into ERROR_REASON `verification_status_invalid`. A nonexistent phase
1626
+ * directory is an ANSWER (`phase_dir_not_found`, `route: ''`, a `message`,
1627
+ * no `error` field — so `--pick status` prints it and the run is not
1628
+ * DEGRADED), not a failure.
1629
+ *
998
1630
  * @param cwd - Current working directory (used to resolve phaseDirArg).
999
1631
  * @param phaseDirArg - Phase directory path (absolute or relative to cwd).
1000
1632
  * @param raw - Whether to emit raw (non-JSON) output.
@@ -1023,6 +1655,11 @@ function cmdVerificationStatus(cwd, phaseDirArg, raw) {
1023
1655
  * bare path string (possibly empty) so `VAR=$(gsd_run query
1024
1656
  * verification.resolve-file "$PHASE_DIR" --raw)` is directly assignable.
1025
1657
  *
1658
+ * #5118 (#4987 item 2): a path with no phase directory behind it adds the
1659
+ * marker `status: "phase_dir_not_found"` and a `message` — never an `error`
1660
+ * field (that would declare DEGRADED) — so it is distinguishable from an
1661
+ * existing directory with no report, where `""` alone keeps its meaning.
1662
+ *
1026
1663
  * @param cwd - Current working directory (used to resolve phaseDirArg).
1027
1664
  * @param phaseDirArg - Phase directory path (absolute or relative to cwd).
1028
1665
  * @param raw - Whether to emit raw (non-JSON) output.
@@ -1033,6 +1670,14 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
1033
1670
  return;
1034
1671
  }
1035
1672
  const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
1673
+ if (isPhaseDirNotFound(defaultFsImpl, phaseDir)) {
1674
+ output({
1675
+ verification_file: '',
1676
+ status: VERIFICATION_STATUS.PHASE_DIR_NOT_FOUND,
1677
+ message: phaseDirNotFoundMessage(phaseDir),
1678
+ }, raw, '');
1679
+ return;
1680
+ }
1036
1681
  let verificationPath = '';
1037
1682
  try {
1038
1683
  const entries = node_fs_1.default.readdirSync(phaseDir);
@@ -1136,9 +1781,16 @@ function parseFingerprintFileArgs(tokens) {
1136
1781
  * @param raw - Whether to emit raw (non-JSON) output: just the
1137
1782
  * `covered_digest` string, so `VAR=$(gsd_run query
1138
1783
  * 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.
1784
+ * directly assignable. #5095 (ADR-5057 Phase 2): `raw`
1785
+ * returning only the digest is safe even though the
1786
+ * emitted `covered_files` is a SUPERSET of the caller's
1787
+ * declared list — the v3 digest is computed over
1788
+ * `canonicalize(declared ∪ phaseArtifactPaths)` on BOTH
1789
+ * the emit side (here) and the check side
1790
+ * (`readVerificationStatus`), so a `--raw` caller that
1791
+ * writes its OWN declared list (without enumerating
1792
+ * plans/summaries) alongside the raw digest still
1793
+ * matches — the checker adds the identical union.
1142
1794
  */
1143
1795
  function cmdVerificationFingerprint(cwd, phaseDirArg, fileArgs, raw) {
1144
1796
  if (!phaseDirArg) {
@@ -1168,37 +1820,322 @@ function cmdVerificationFingerprint(cwd, phaseDirArg, fileArgs, raw) {
1168
1820
  return;
1169
1821
  }
1170
1822
  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 });
1823
+ // canonicalizeCoveredFiles here is for `deriveCoveredDigest`'s
1824
+ // `coveredFiles` argument — it canonicalizes internally too (it must, for
1825
+ // callers like readVerificationStatus that pass raw, un-canonicalized
1826
+ // frontmatter values), so passing an already-canonical list keeps that
1827
+ // internal pass a cheap no-op rather than a second meaningfully different
1828
+ // canonicalization.
1829
+ const declaredCanonical = canonicalizeCoveredFiles(files, { version: FINGERPRINT_VERSION });
1830
+ // #5095 (R1/R2/R7, ADR-5057 Phase 2): ONE call derives both the emitted
1831
+ // `covered_files` (`.files`, the declared set unioned with the phase's own
1832
+ // live artifacts) and the hashed digest (`.digest`) — the CLI no longer
1833
+ // runs `phaseArtifactPaths` itself and again inside the digest computation,
1834
+ // which used to leave a race window where the two calls could see a
1835
+ // different phase directory and disagree. An artifact-resolution failure
1836
+ // fails the WHOLE command (fail closed: the emitter cannot vouch for a set
1837
+ // it could not fully see).
1838
+ const derivation = deriveCoveredDigest(projectRoot, declaredCanonical, FINGERPRINT_VERSION, { phaseDir });
1839
+ if (!derivation.ok) {
1840
+ error(`could not compute fingerprint — ${derivation.reason}`);
1841
+ return;
1842
+ }
1843
+ const unionSorted = derivation.files;
1844
+ if (unionSorted.length === 0) {
1845
+ error('at least one covered file required for verification.fingerprint');
1846
+ return;
1847
+ }
1848
+ const digest = derivation.digest;
1179
1849
  if (digest === null) {
1180
1850
  // #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) {
1851
+ // only of shared planning documents (with no other evidence) hashes
1852
+ // nothing under v2+, and the generic message below would send the caller
1853
+ // looking for a missing file that is not missing. Discriminated AFTER
1854
+ // the versioned attempt, and only when a v1 pass over the same list
1855
+ // (which hashes, and therefore validates, every path) succeeds: an
1856
+ // all-shared list with a missing or directory member is a bad path
1857
+ // first, and gets the generic message. #5095 (R5): a phase WITH real
1858
+ // artifacts never reaches this branch — the union above already supplied
1859
+ // evidence — so this error is reachable only when every declared path is
1860
+ // a shared planning doc AND the phase has no plans/summaries of its own.
1861
+ const sharedRoots = sharedRootsFor(projectRoot, phaseDir, derivation.mappedPhaseDir);
1862
+ if (unionSorted.every((f) => isSharedPlanningDoc(f, sharedRoots)) &&
1863
+ computeCoveredDigest(projectRoot, unionSorted, 1) !== null) {
1190
1864
  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
1865
  return;
1192
1866
  }
1193
1867
  error('could not compute fingerprint — a covered file is missing, unreadable, or escapes the project root');
1194
1868
  return;
1195
1869
  }
1196
- output({ covered_files: uniqueSorted, covered_digest: digest }, raw, digest);
1870
+ output({ covered_files: unionSorted, covered_digest: digest }, raw, digest);
1871
+ }
1872
+ // ─── verification.append-audit (#5105 R3) ──────────────────────────────────
1873
+ /**
1874
+ * Render a single `## <heading> <date>` block followed by a `| Metric |
1875
+ * Count |` table over `rows` — the exact shape `secure-phase.md` /
1876
+ * `validate-phase.md` compose by hand today (#4887 Defect 2, #4981).
1877
+ */
1878
+ function renderAuditBlock(heading, date, rows) {
1879
+ const lines = [`## ${heading} ${date}`, '', '| Metric | Count |', '|---|---|'];
1880
+ for (const [k, v] of Object.entries(rows))
1881
+ lines.push(`| ${k} | ${String(v)} |`);
1882
+ return lines.join('\n') + '\n';
1883
+ }
1884
+ /**
1885
+ * #5105 (S4): parse a block body's `| Metric | Count |`-shaped table into a
1886
+ * plain metric→count string map, addressed by the table's ACTUAL first/second
1887
+ * column (never a hard-coded `Metric`/`Count` name) so a legacy block with
1888
+ * different header text still compares. Whitespace/separator-width tolerant
1889
+ * by construction — `parseMarkdownTable` trims every cell and accepts any
1890
+ * `-{1,}` delimiter width. Returns `null` when the body carries no parseable
1891
+ * 2+-column table (no prior block to compare against).
1892
+ */
1893
+ function parseAuditTableRows(bodyText) {
1894
+ const parsed = (0, markdown_table_cjs_1.parseMarkdownTable)(bodyText);
1895
+ if (!parsed.ok || parsed.value.columns.length < 2)
1896
+ return null;
1897
+ const [metricCol, countCol] = parsed.value.columns;
1898
+ const map = {};
1899
+ for (const row of parsed.value.rows) {
1900
+ map[row[metricCol]] = String(row[countCol]).trim();
1901
+ }
1902
+ return map;
1903
+ }
1904
+ /** Order-insensitive equality over two metric→count maps. */
1905
+ function auditRowsEqual(live, candidate) {
1906
+ if (!live)
1907
+ return false;
1908
+ const liveKeys = Object.keys(live);
1909
+ const candidateKeys = Object.keys(candidate);
1910
+ if (liveKeys.length !== candidateKeys.length)
1911
+ return false;
1912
+ return liveKeys.every((k) => Object.prototype.hasOwnProperty.call(candidate, k) && live[k] === candidate[k]);
1913
+ }
1914
+ /**
1915
+ * #5105 R3 — pure core of `verification.append-audit`.
1916
+ *
1917
+ * Finds the LAST `## <heading> <date>` block in `content` — a level-2
1918
+ * heading whose text matches `^<heading> (\d{4}-\d{2}-\d{2})\b` — via the
1919
+ * shared, fence-aware `tokenizeHeadings`/`collectSection` primitives (#5105
1920
+ * S6) instead of a hand-rolled `^## ` scan: a `## <heading> <date>`-looking
1921
+ * line inside a fenced code block is not a heading and cannot be selected,
1922
+ * and a heading whose trailing word ISN'T a date (e.g. the template's bare
1923
+ * `## Security Audit Trail`) is not matched either (#5105 S4 — the anchored
1924
+ * heading date shape, not `(\S+)`, is what excludes it).
1925
+ *
1926
+ * Comparison (#5105 S4) is over the block's PARSED table rows
1927
+ * (`parseAuditTableRows`/`auditRowsEqual`) — whitespace/separator-width
1928
+ * insensitive, order-insensitive, and tolerant of an optional blank line
1929
+ * after the heading — never a byte-for-byte body string compare. Identical
1930
+ * rows on the last block → `{ appended: false }`, no write. Different rows
1931
+ * (or no prior block) → appends the new block at the end and returns
1932
+ * `{ appended: true }`.
1933
+ *
1934
+ * Deliberately compares against the LAST block only, never any earlier one —
1935
+ * a re-audit that regresses back to an earlier count must still append.
1936
+ *
1937
+ * `date` defaults through the `clock` seam (default: the global `Date`
1938
+ * constructor) rather than a bare `new Date()` call, so a caller can pin the
1939
+ * date deterministically — directly (pass `clock`) or via `node:test`
1940
+ * `mock.timers` (which replaces global `Date`, picked up automatically since
1941
+ * the default is evaluated per call).
1942
+ */
1943
+ function planAuditAppend(content, { heading, rows, date, clock = Date }) {
1944
+ const resolvedDate = date ?? new clock().toISOString().slice(0, 10);
1945
+ const escapedHeading = (0, pattern_cjs_1.escapeRegex)(heading);
1946
+ const headingRe = new RegExp(`^${escapedHeading} (\\d{4}-\\d{2}-\\d{2})\\b`);
1947
+ const matchingHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).filter((h) => h.level === 2 && headingRe.test(h.text));
1948
+ const newBlock = renderAuditBlock(heading, resolvedDate, rows);
1949
+ const newRowsMap = {};
1950
+ for (const [k, v] of Object.entries(rows))
1951
+ newRowsMap[k] = String(v);
1952
+ if (matchingHeadings.length > 0) {
1953
+ const last = matchingHeadings[matchingHeadings.length - 1];
1954
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.offset === last.offset);
1955
+ const liveRows = section ? parseAuditTableRows(section.body) : null;
1956
+ if (auditRowsEqual(liveRows, newRowsMap)) {
1957
+ return { appended: false, content };
1958
+ }
1959
+ }
1960
+ const trimmed = content.replace(/\s+$/, '');
1961
+ const appendedContent = (trimmed.length > 0 ? trimmed + '\n\n' : '') + newBlock;
1962
+ return { appended: true, content: appendedContent };
1963
+ }
1964
+ /** Reject a `\r`, `\n`, or `|` — any of the three would corrupt the rendered heading/table shape. */
1965
+ function hasForbiddenAuditChar(s) {
1966
+ return /[\r\n|]/.test(s);
1967
+ }
1968
+ /** `rows` values must be a non-negative integer, as either a JSON number or an all-digit string. */
1969
+ function isNonNegativeIntegerValue(v) {
1970
+ if (typeof v === 'number')
1971
+ return Number.isInteger(v) && v >= 0;
1972
+ if (typeof v === 'string')
1973
+ return /^\d+$/.test(v);
1974
+ return false;
1975
+ }
1976
+ /** Reject a value carrying leading/trailing whitespace — a heading or row key
1977
+ * with padding would not match `parseMarkdownTable`'s trimmed reads on a
1978
+ * later append, so the same key would silently fail to be recognized as the
1979
+ * "already present" row (re-review finding 6). */
1980
+ function hasLeadingOrTrailingWhitespace(s) {
1981
+ return s !== s.trim();
1982
+ }
1983
+ /** True calendar-date check for `--date` (re-review finding 9): rejects an
1984
+ * out-of-range month/day (e.g. `2026-99-99`) or a day that does not exist in
1985
+ * that month (e.g. `2026-02-30`), which `/^\d{4}-\d{2}-\d{2}$/` alone lets
1986
+ * through — `Date.UTC` normalizes overflow instead of raising, so the parsed
1987
+ * fields must be compared back against the input. */
1988
+ function isRealCalendarDate(date) {
1989
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
1990
+ if (!m)
1991
+ return false;
1992
+ const year = Number(m[1]);
1993
+ const month = Number(m[2]);
1994
+ const day = Number(m[3]);
1995
+ const d = new Date(Date.UTC(year, month - 1, day));
1996
+ return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
1997
+ }
1998
+ /**
1999
+ * #5105 S3 — validate `verification.append-audit` input shape before it ever
2000
+ * reaches `planAuditAppend`/the file. `--rows` being valid JSON (checked by
2001
+ * the caller before this runs) is necessary but not sufficient: a newline,
2002
+ * `\r`, or `|` in the heading or any row key/value would corrupt the
2003
+ * rendered `## heading date` line or `| key | value |` row, and a non-integer
2004
+ * or negative count is not a countable metric.
2005
+ */
2006
+ function validateAuditAppendInput(heading, rows, date) {
2007
+ if (hasForbiddenAuditChar(heading)) {
2008
+ return { ok: false, reason: '--heading must not contain a newline, carriage return, or |' };
2009
+ }
2010
+ if (hasLeadingOrTrailingWhitespace(heading)) {
2011
+ return { ok: false, reason: '--heading must not have leading or trailing whitespace' };
2012
+ }
2013
+ for (const [key, value] of Object.entries(rows)) {
2014
+ if (hasForbiddenAuditChar(key)) {
2015
+ return { ok: false, reason: `--rows key ${JSON.stringify(key)} must not contain a newline, carriage return, or |` };
2016
+ }
2017
+ if (hasLeadingOrTrailingWhitespace(key)) {
2018
+ return { ok: false, reason: `--rows key ${JSON.stringify(key)} must not have leading or trailing whitespace` };
2019
+ }
2020
+ if (typeof value === 'string' && hasForbiddenAuditChar(value)) {
2021
+ return { ok: false, reason: `--rows value for ${JSON.stringify(key)} must not contain a newline, carriage return, or |` };
2022
+ }
2023
+ if (!isNonNegativeIntegerValue(value)) {
2024
+ return { ok: false, reason: `--rows value for ${JSON.stringify(key)} must be a non-negative integer` };
2025
+ }
2026
+ }
2027
+ if (date !== undefined && !isRealCalendarDate(date)) {
2028
+ return { ok: false, reason: '--date must be a real calendar date in YYYY-MM-DD form' };
2029
+ }
2030
+ return { ok: true };
2031
+ }
2032
+ /**
2033
+ * #5105 S2 — case-insensitive re-implementation of `isVerificationReportPath`'s
2034
+ * shape. That helper is deliberately case-SENSITIVE (matching
2035
+ * `resolveVerificationFile`'s own convention — see its doc comment), so it
2036
+ * cannot be reused directly for a containment refusal that must catch a
2037
+ * lowercase `07-verification.md` too.
2038
+ */
2039
+ function isVerificationReportBasenameCI(basename) {
2040
+ const lower = basename.toLowerCase();
2041
+ return lower === 'verification.md' || lower.endsWith('-verification.md');
2042
+ }
2043
+ /** #5105 S2 — the only two shapes `verification.append-audit` may target. */
2044
+ function isAllowedAuditTargetBasenameCI(basename) {
2045
+ const lower = basename.toLowerCase();
2046
+ return lower.endsWith('-security.md') || lower.endsWith('-validation.md');
2047
+ }
2048
+ /**
2049
+ * CLI command handler (#5105 R3): `verification.append-audit <file>
2050
+ * --heading <H> --rows '<json {metric:count}>' [--date <YYYY-MM-DD>]`.
2051
+ *
2052
+ * Never reads or writes `covered_files`/`covered_digest` (#4981 invariant
2053
+ * 5) — a genuinely changed count publishes and stales any report that covers
2054
+ * `file`; nothing here ever restamps it.
2055
+ *
2056
+ * #5105 S2: `file` is resolved through the same `requireSafePath(...,
2057
+ * PathAcceptance.AbsoluteInsideRoot)` seam `uat.complete-session` uses —
2058
+ * refusing an absolute-outside-root target or a `../` escape by throwing
2059
+ * before any read/write is attempted (uncaught here, matching every other
2060
+ * `requireSafePath` call site in this codebase — a top-level command
2061
+ * dispatcher turns the throw into a failed exit). The REAL (symlink-resolved)
2062
+ * basename is then checked twice, case-insensitively: it must not be a
2063
+ * verification report itself, and it must be a `*-SECURITY.md` or
2064
+ * `*-VALIDATION.md` file — the only two artifact kinds this command may
2065
+ * mutate.
2066
+ */
2067
+ function cmdVerificationAppendAudit(cwd, fileArg, argTokens, raw) {
2068
+ if (!fileArg) {
2069
+ error('file required for verification.append-audit');
2070
+ return;
2071
+ }
2072
+ const { heading, rows: rowsArg, date: dateArg } = (0, command_arg_projection_cjs_1.parseNamedArgsOrExit)(argTokens, { valueFlags: ['heading', 'rows', 'date'], positionals: 0 }, error);
2073
+ if (!heading) {
2074
+ error('--heading required for verification.append-audit');
2075
+ return;
2076
+ }
2077
+ if (!rowsArg) {
2078
+ error('--rows required for verification.append-audit');
2079
+ return;
2080
+ }
2081
+ let rows;
2082
+ try {
2083
+ const parsedRows = JSON.parse(rowsArg);
2084
+ if (!parsedRows || typeof parsedRows !== 'object' || Array.isArray(parsedRows)) {
2085
+ throw new Error('not an object');
2086
+ }
2087
+ rows = parsedRows;
2088
+ }
2089
+ catch {
2090
+ error('--rows must be a JSON object for verification.append-audit');
2091
+ return;
2092
+ }
2093
+ const date = dateArg ?? undefined;
2094
+ const validation = validateAuditAppendInput(heading, rows, date);
2095
+ if (!validation.ok) {
2096
+ error(validation.reason);
2097
+ return;
2098
+ }
2099
+ const resolvedPath = (0, security_cjs_1.requireSafePath)(fileArg, cwd, 'verification.append-audit file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
2100
+ const realBasename = node_path_1.default.basename(resolvedPath);
2101
+ if (isVerificationReportBasenameCI(realBasename)) {
2102
+ error('verification.append-audit refuses a verification report path');
2103
+ return;
2104
+ }
2105
+ if (!isAllowedAuditTargetBasenameCI(realBasename)) {
2106
+ error('verification.append-audit target must be a *-SECURITY.md or *-VALIDATION.md file');
2107
+ return;
2108
+ }
2109
+ let content;
2110
+ try {
2111
+ content = node_fs_1.default.readFileSync(resolvedPath, 'utf-8');
2112
+ }
2113
+ catch {
2114
+ error(`file not found: ${fileArg}`);
2115
+ return;
2116
+ }
2117
+ const result = planAuditAppend(content, { heading: heading, rows, date });
2118
+ if (result.appended) {
2119
+ node_fs_1.default.writeFileSync(resolvedPath, result.content);
2120
+ }
2121
+ output({ appended: result.appended }, raw);
1197
2122
  }
1198
- module.exports = {
2123
+ const verificationModule = {
2124
+ VERIFICATION_STATUS,
1199
2125
  VERIFIER_STATUSES,
1200
- VERIFICATION_ROUTING_TABLE,
2126
+ VERIFICATION_ROUTES,
2127
+ isVerificationStatus,
2128
+ assertVerificationStatus,
2129
+ VerificationStatusError,
2130
+ VERIFICATION_STATUS_ERROR_CODE,
2131
+ failOnVerificationStatusError,
2132
+ firstStatusError,
2133
+ reportStatusOf,
2134
+ isReportContained,
2135
+ routeResult,
2136
+ findVerificationStatusError,
1201
2137
  defaultPhaseCleanCommitTimesMs,
2138
+ resolvePhaseArtifactFile,
1202
2139
  resolveVerificationFile,
1203
2140
  resolveUatFile,
1204
2141
  findStaleVerificationSummary,
@@ -1209,7 +2146,11 @@ module.exports = {
1209
2146
  computeCoveredDigest,
1210
2147
  sharedPlanningRoots,
1211
2148
  isSharedPlanningDoc,
2149
+ isVerificationReportPath,
1212
2150
  parseFingerprintVersion,
1213
2151
  parseFingerprintFileArgs,
1214
2152
  cmdVerificationFingerprint,
2153
+ planAuditAppend,
2154
+ cmdVerificationAppendAudit,
1215
2155
  };
2156
+ module.exports = verificationModule;