@opengsd/gsd-core 1.9.1 → 1.11.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 (426) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +27 -3
  5. package/agents/gsd-debug-session-manager.md +11 -0
  6. package/agents/gsd-debugger.md +12 -246
  7. package/agents/gsd-doc-synthesizer.md +2 -4
  8. package/agents/gsd-executor.md +12 -10
  9. package/agents/gsd-integration-checker.md +3 -0
  10. package/agents/gsd-mempalace-curator.md +5 -2
  11. package/agents/gsd-phase-researcher.md +20 -1
  12. package/agents/gsd-plan-checker.md +46 -0
  13. package/agents/gsd-planner.md +49 -54
  14. package/agents/gsd-roadmapper.md +21 -3
  15. package/agents/gsd-user-profiler.md +3 -0
  16. package/agents/gsd-verifier.md +26 -73
  17. package/bin/install.js +1272 -1238
  18. package/bin/lib/ui-safety-gate.cjs +2 -0
  19. package/commands/gsd/code-review.md +1 -1
  20. package/commands/gsd/execute-phase.md +1 -1
  21. package/commands/gsd/map-codebase.md +1 -1
  22. package/commands/gsd/mempalace-capture.md +2 -2
  23. package/commands/gsd/mempalace-recall.md +1 -1
  24. package/commands/gsd/new-milestone.md +2 -2
  25. package/commands/gsd/plan-phase.md +1 -1
  26. package/commands/gsd/quick.md +1 -1
  27. package/commands/gsd/review-backlog.md +2 -1
  28. package/commands/gsd/verify-work.md +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +1009 -115
  30. package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
  31. package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
  32. package/gsd-core/bin/lib/api-coverage.cjs +123 -5
  33. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  35. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  36. package/gsd-core/bin/lib/audit.cjs +926 -202
  37. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  38. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  39. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  40. package/gsd-core/bin/lib/capability-registry.cjs +608 -148
  41. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  42. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  43. package/gsd-core/bin/lib/capability-validator.cjs +507 -24
  44. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  45. package/gsd-core/bin/lib/check-command-router.cjs +114 -38
  46. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  47. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  48. package/gsd-core/bin/lib/command-aliases.cjs +94 -0
  49. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  50. package/gsd-core/bin/lib/commands.cjs +665 -99
  51. package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
  52. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  53. package/gsd-core/bin/lib/config-loader.cjs +76 -0
  54. package/gsd-core/bin/lib/config.cjs +22 -2
  55. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  56. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  57. package/gsd-core/bin/lib/core-utils.cjs +217 -40
  58. package/gsd-core/bin/lib/decisions.cjs +23 -0
  59. package/gsd-core/bin/lib/docs.cjs +3 -2
  60. package/gsd-core/bin/lib/external-job.cjs +19 -4
  61. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  62. package/gsd-core/bin/lib/frontmatter.cjs +239 -32
  63. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  64. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  65. package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
  66. package/gsd-core/bin/lib/graphify.cjs +142 -27
  67. package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
  68. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  69. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  71. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  72. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  73. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  74. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  75. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  76. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  77. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  78. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  79. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  80. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  81. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  82. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  83. package/gsd-core/bin/lib/init.cjs +1325 -169
  84. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  85. package/gsd-core/bin/lib/install-engine.cjs +805 -264
  86. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  87. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  88. package/gsd-core/bin/lib/install-profiles.cjs +160 -57
  89. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  90. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  91. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  92. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  93. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  94. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  95. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  96. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  97. package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
  98. package/gsd-core/bin/lib/io.cjs +38 -3
  99. package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
  100. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  101. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  102. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  103. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  104. package/gsd-core/bin/lib/milestone.cjs +821 -109
  105. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  106. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  107. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  108. package/gsd-core/bin/lib/pattern.cjs +122 -0
  109. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  110. package/gsd-core/bin/lib/phase-id.cjs +507 -36
  111. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  112. package/gsd-core/bin/lib/phase-locator.cjs +258 -58
  113. package/gsd-core/bin/lib/phase.cjs +891 -156
  114. package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
  115. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  116. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  117. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  118. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  119. package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
  120. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  121. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  122. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  123. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  124. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
  125. package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
  126. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  127. package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
  128. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  129. package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
  130. package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
  131. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  132. package/gsd-core/bin/lib/roadmap.cjs +405 -84
  133. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
  134. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  135. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
  136. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  137. package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
  138. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
  139. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  140. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  141. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  142. package/gsd-core/bin/lib/security.cjs +104 -5
  143. package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
  144. package/gsd-core/bin/lib/smart-entry.cjs +154 -22
  145. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  146. package/gsd-core/bin/lib/state-document.cjs +152 -8
  147. package/gsd-core/bin/lib/state-transition.cjs +424 -105
  148. package/gsd-core/bin/lib/state.cjs +1927 -401
  149. package/gsd-core/bin/lib/surface.cjs +35 -10
  150. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  151. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  152. package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
  153. package/gsd-core/bin/lib/uat.cjs +706 -64
  154. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  155. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  156. package/gsd-core/bin/lib/unusable-input.cjs +33 -0
  157. package/gsd-core/bin/lib/update-context.cjs +8 -2
  158. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  159. package/gsd-core/bin/lib/validate.cjs +20 -6
  160. package/gsd-core/bin/lib/vendor/README.md +37 -0
  161. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  162. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  163. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  164. package/gsd-core/bin/lib/verification.cjs +287 -20
  165. package/gsd-core/bin/lib/verify.cjs +368 -880
  166. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  167. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
  168. package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
  169. package/gsd-core/bin/lib/workstream.cjs +8 -2
  170. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  171. package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
  172. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  173. package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
  174. package/gsd-core/references/agent-contracts.md +43 -26
  175. package/gsd-core/references/artifact-types.md +10 -3
  176. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  177. package/gsd-core/references/checkpoints.md +2 -2
  178. package/gsd-core/references/context-budget.md +1 -1
  179. package/gsd-core/references/debugger-techniques.md +255 -0
  180. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  181. package/gsd-core/references/doc-conflict-engine.md +1 -1
  182. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  183. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  184. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  185. package/gsd-core/references/execute-phase-response-language.md +1 -1
  186. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  187. package/gsd-core/references/gate-prompts.md +1 -1
  188. package/gsd-core/references/git-planning-commit.md +2 -1
  189. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  190. package/gsd-core/references/model-profiles.md +12 -4
  191. package/gsd-core/references/mvp-concepts.md +9 -9
  192. package/gsd-core/references/planner-guidance.md +3 -9
  193. package/gsd-core/references/planner-preconditions.md +1 -1
  194. package/gsd-core/references/planner-reviews.md +1 -1
  195. package/gsd-core/references/planning-config.md +8 -6
  196. package/gsd-core/references/research-documentation-lookup.md +5 -3
  197. package/gsd-core/references/revision-loop.md +1 -1
  198. package/gsd-core/references/specless-probe-fallback.md +8 -7
  199. package/gsd-core/references/universal-anti-patterns.md +3 -3
  200. package/gsd-core/references/verifier-phase-gates.md +192 -0
  201. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  202. package/gsd-core/references/verify-mvp-mode.md +1 -1
  203. package/gsd-core/references/workstream-flag.md +22 -6
  204. package/gsd-core/references/worktree-branch-check.md +2 -2
  205. package/gsd-core/templates/discussion-log.md +1 -1
  206. package/gsd-core/templates/phase-prompt.md +2 -4
  207. package/gsd-core/templates/state.md +4 -4
  208. package/gsd-core/templates/summary-complex.md +2 -0
  209. package/gsd-core/templates/summary-minimal.md +2 -0
  210. package/gsd-core/templates/summary-standard.md +2 -0
  211. package/gsd-core/templates/summary.md +2 -0
  212. package/gsd-core/templates/verification-report.md +9 -1
  213. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  214. package/gsd-core/workflows/audit-milestone.md +3 -0
  215. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  216. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  217. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  218. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  219. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  220. package/gsd-core/workflows/autonomous.md +33 -70
  221. package/gsd-core/workflows/cleanup.md +62 -3
  222. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  223. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
  224. package/gsd-core/workflows/code-review-fix.md +37 -10
  225. package/gsd-core/workflows/code-review.md +74 -166
  226. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  227. package/gsd-core/workflows/complete-milestone.md +160 -95
  228. package/gsd-core/workflows/debug.md +16 -17
  229. package/gsd-core/workflows/diagnose-issues.md +56 -8
  230. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  231. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  232. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  233. package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
  234. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  235. package/gsd-core/workflows/docs-update.md +8 -51
  236. package/gsd-core/workflows/edit-phase.md +26 -1
  237. package/gsd-core/workflows/eval-review.md +3 -5
  238. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
  239. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  240. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  241. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  242. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
  243. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  245. package/gsd-core/workflows/execute-phase.md +103 -187
  246. package/gsd-core/workflows/execute-plan.md +36 -4
  247. package/gsd-core/workflows/explore.md +131 -4
  248. package/gsd-core/workflows/fast.md +10 -2
  249. package/gsd-core/workflows/health.md +73 -4
  250. package/gsd-core/workflows/help/modes/full.md +6 -1
  251. package/gsd-core/workflows/import.md +4 -4
  252. package/gsd-core/workflows/ingest-docs.md +7 -6
  253. package/gsd-core/workflows/mvp-phase.md +6 -3
  254. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  255. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  256. package/gsd-core/workflows/new-milestone.md +35 -47
  257. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  258. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  259. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  260. package/gsd-core/workflows/new-project.md +27 -240
  261. package/gsd-core/workflows/next.md +12 -0
  262. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  263. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  264. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  265. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  266. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  267. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  268. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  269. package/gsd-core/workflows/plan-phase.md +89 -209
  270. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  271. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  272. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  273. package/gsd-core/workflows/progress.md +45 -159
  274. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  275. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  276. package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
  277. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  278. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  279. package/gsd-core/workflows/quick.md +55 -405
  280. package/gsd-core/workflows/resume-project.md +3 -0
  281. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  282. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  283. package/gsd-core/workflows/review.md +41 -13
  284. package/gsd-core/workflows/section-manifest.json +219 -0
  285. package/gsd-core/workflows/secure-phase.md +1 -1
  286. package/gsd-core/workflows/session-report.md +2 -1
  287. package/gsd-core/workflows/settings.md +66 -2
  288. package/gsd-core/workflows/ship.md +104 -44
  289. package/gsd-core/workflows/sketch.md +1 -1
  290. package/gsd-core/workflows/spec-phase.md +41 -20
  291. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  292. package/gsd-core/workflows/spike.md +50 -16
  293. package/gsd-core/workflows/sync-skills.md +106 -13
  294. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  295. package/gsd-core/workflows/transition.md +53 -31
  296. package/gsd-core/workflows/ui-phase.md +13 -12
  297. package/gsd-core/workflows/ui-review.md +2 -2
  298. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  299. package/gsd-core/workflows/update.md +19 -8
  300. package/gsd-core/workflows/validate-phase.md +1 -1
  301. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  302. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  303. package/gsd-core/workflows/verify-work.md +17 -65
  304. package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
  305. package/hooks/dist/gsd-check-update-worker.js +64 -12
  306. package/hooks/dist/gsd-check-update.js +19 -1
  307. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  308. package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
  309. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  310. package/hooks/dist/gsd-prompt-guard.js +21 -20
  311. package/hooks/dist/gsd-read-injection-scanner.js +45 -24
  312. package/hooks/dist/gsd-statusline.js +90 -6
  313. package/hooks/dist/gsd-update-banner.js +22 -1
  314. package/hooks/dist/gsd-workflow-guard.js +134 -36
  315. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  316. package/hooks/dist/gsd-write-guard.js +359 -0
  317. package/hooks/dist/lib/git-cmd.js +92 -59
  318. package/hooks/dist/lib/injection-patterns.js +45 -0
  319. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  320. package/hooks/dist/lib/isolation-sentinel.js +277 -0
  321. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  322. package/hooks/gsd-agent-isolation-guard.js +517 -0
  323. package/hooks/gsd-check-update-worker.js +64 -12
  324. package/hooks/gsd-check-update.js +19 -1
  325. package/hooks/gsd-cursor-pre-tool.js +0 -3
  326. package/hooks/gsd-cursor-subagent-start.js +607 -26
  327. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  328. package/hooks/gsd-prompt-guard.js +21 -20
  329. package/hooks/gsd-read-injection-scanner.js +45 -24
  330. package/hooks/gsd-statusline.js +90 -6
  331. package/hooks/gsd-update-banner.js +22 -1
  332. package/hooks/gsd-workflow-guard.js +134 -36
  333. package/hooks/gsd-worktree-path-guard.js +2 -1
  334. package/hooks/gsd-write-guard.js +359 -0
  335. package/hooks/hooks.json +12 -0
  336. package/hooks/lib/git-cmd.js +92 -59
  337. package/hooks/lib/injection-patterns.js +45 -0
  338. package/hooks/lib/isolation-deny-reason.js +39 -0
  339. package/hooks/lib/isolation-sentinel.js +277 -0
  340. package/hooks/managed-hooks-registry.cjs +2 -0
  341. package/package.json +31 -10
  342. package/pi/gsd.cjs +71 -12
  343. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  344. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  345. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  346. package/scripts/build-hooks.js +9 -0
  347. package/scripts/changeset/lint.cjs +68 -6
  348. package/scripts/changeset/serialize.cjs +5 -1
  349. package/scripts/check-alias-drift.cjs +7 -43
  350. package/scripts/check-contract-drift.cjs +297 -0
  351. package/scripts/ci-test-scope.cjs +19 -2
  352. package/scripts/command-contract-helpers.cjs +903 -1
  353. package/scripts/gen-adr-index.cjs +728 -38
  354. package/scripts/gen-capability-matrix.cjs +1 -1
  355. package/scripts/gen-capability-registry.cjs +3 -15
  356. package/scripts/gen-context-index.cjs +439 -0
  357. package/scripts/gen-health-docs.cjs +390 -0
  358. package/scripts/gen-inventory-manifest.cjs +150 -4
  359. package/scripts/gen-loop-host-contract.cjs +4 -24
  360. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  361. package/scripts/gen-registry.cjs +3 -14
  362. package/scripts/gen-section-manifest.cjs +638 -0
  363. package/scripts/generate-package-identity.cjs +4 -2
  364. package/scripts/lib/alias-drift-families.cjs +46 -0
  365. package/scripts/lib/drift-scan.cjs +278 -0
  366. package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
  367. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  368. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  369. package/scripts/lint-canary-version-leak.cjs +73 -0
  370. package/scripts/lint-command-contract.cjs +96 -13
  371. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  372. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  373. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  374. package/scripts/lint-default-flip-documentation.cjs +193 -0
  375. package/scripts/lint-docs-command-form.cjs +195 -0
  376. package/scripts/lint-docs-required.cjs +9 -1
  377. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  378. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  379. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  380. package/scripts/lint-example-parser-parity.cjs +395 -0
  381. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  382. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  383. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  384. package/scripts/lint-milestone-window-drift.cjs +468 -0
  385. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  386. package/scripts/lint-plan-count-drift.cjs +318 -0
  387. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  388. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  389. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  390. package/scripts/lint-regression-test-names.cjs +15 -13
  391. package/scripts/lint-removed-but-needed.cjs +320 -0
  392. package/scripts/lint-state-field-drift.cjs +805 -0
  393. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  394. package/scripts/lint-test-file-count.allowlist.json +40 -3
  395. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  396. package/scripts/lint-vendored-deps.cjs +124 -0
  397. package/scripts/mutation-matrix.cjs +13 -0
  398. package/scripts/pr-changed-files.cjs +63 -0
  399. package/scripts/pr-template-policy.cjs +14 -4
  400. package/scripts/prompt-injection-scan.sh +52 -6
  401. package/scripts/require-issue-link-policy.cjs +192 -0
  402. package/scripts/state-write-path-drift-baseline.json +19 -0
  403. package/scripts/sync-runtime-launcher.cjs +2 -4
  404. package/skills/gsd-autonomous/SKILL.md +0 -1
  405. package/skills/gsd-code-review/SKILL.md +1 -1
  406. package/skills/gsd-execute-phase/SKILL.md +1 -2
  407. package/skills/gsd-map-codebase/SKILL.md +1 -1
  408. package/skills/gsd-mempalace-capture/SKILL.md +2 -2
  409. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  410. package/skills/gsd-new-milestone/SKILL.md +2 -2
  411. package/skills/gsd-next/SKILL.md +0 -1
  412. package/skills/gsd-plan-phase/SKILL.md +1 -2
  413. package/skills/gsd-progress/SKILL.md +0 -1
  414. package/skills/gsd-quick/SKILL.md +1 -1
  415. package/skills/gsd-review-backlog/SKILL.md +2 -1
  416. package/skills/gsd-stats/SKILL.md +0 -1
  417. package/skills/gsd-verify-work/SKILL.md +1 -1
  418. package/vscode/package.json +1 -1
  419. package/gsd-core/workflows/discovery-phase.md +0 -298
  420. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  421. package/gsd-core/workflows/verify-phase.md +0 -577
  422. package/scripts/affected-tests-lib.cjs +0 -554
  423. package/scripts/gen-emitted-baseline.cjs +0 -145
  424. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  425. package/scripts/run-affected-tests.cjs +0 -7
  426. package/scripts/run-tests.cjs +0 -1050
@@ -14,6 +14,7 @@ const node_path_1 = __importDefault(require("node:path"));
14
14
  const node_os_1 = __importDefault(require("node:os"));
15
15
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
16
16
  const clock_cjs_1 = require("./clock.cjs");
17
+ const pattern_cjs_1 = require("./pattern.cjs");
17
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
18
19
  const io = require("./io.cjs");
19
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
@@ -38,6 +39,7 @@ const secrets_cjs_1 = require("./secrets.cjs");
38
39
  const scanPhasePlans = require("./plan-scan.cjs");
39
40
  const state_document_cjs_1 = require("./state-document.cjs");
40
41
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
42
+ const host_runtime_detection_cjs_1 = require("./host-runtime-detection.cjs");
41
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
42
44
  const commandsMod = require("./commands.cjs");
43
45
  const security_cjs_1 = require("./security.cjs");
@@ -50,6 +52,20 @@ const verificationMod = require("./verification.cjs");
50
52
  const uatPredicateMod = require("./uat-predicate.cjs");
51
53
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
52
54
  const agentInstallCheck = require("./agent-install-check.cjs");
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- section-manifest.cjs is compiled from section-manifest.cts's named exports; imported as a namespace to read selectSections/SelectableSection/InvocationFacts off module.exports directly (#2932).
56
+ const sectionManifest = require("./section-manifest.cjs");
57
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- loop-resolver.cjs is an export= CommonJS module
58
+ const loopResolverMod = require("./loop-resolver.cjs");
59
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- capability-loader.cjs is compiled from capability-loader.cts's named exports; imported as a namespace to read loadRegistry off module.exports directly.
60
+ const capabilityLoaderMod = require("./capability-loader.cjs");
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- capability-state.cjs is an export= CommonJS module
62
+ const capabilityStateMod = require("./capability-state.cjs");
63
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- docs.cjs is an export= CommonJS module
64
+ const docsMod = require("./docs.cjs");
65
+ const { detectMonorepoWorkspaces } = docsMod;
66
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- workstream-inventory.cjs is an export= CommonJS module
67
+ const workstreamInventoryMod = require("./workstream-inventory.cjs");
68
+ const { getOtherActiveWorkstreamInventories } = workstreamInventoryMod;
53
69
  const { checkAgentsInstalled } = agentInstallCheck;
54
70
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- git-base-branch.cjs is an export= CommonJS module
55
71
  const gitBaseBranch = require("./git-base-branch.cjs");
@@ -58,19 +74,22 @@ const resolution_cjs_1 = require("./resolution.cjs");
58
74
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- onboard-projection.cjs is an export= CommonJS module
59
75
  const onboardProjection = require("./onboard-projection.cjs");
60
76
  const { REQUIRED_CODEBASE_MAP_FILES, buildOnboardProjection, hasCodeFilesInternal, hasPackageFileInternal, listCodebaseMapFiles, } = onboardProjection;
61
- const { output, error } = io;
77
+ const { output, error, ERROR_REASON } = io;
62
78
  const { loadConfig, loadConfigResolved } = configLoader;
63
79
  const { resolveModelInternal, resolveGranularityInternal, assertValidGranularityOverride } = modelResolver;
64
- const { findPhaseInternal } = phaseLocator;
65
- const { getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, } = roadmapParser;
80
+ const { findPhaseInternal, listMilestonePhaseDirs } = phaseLocator;
81
+ const { getRoadmapPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, } = roadmapParser;
66
82
  const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
67
- const { escapeRegex, normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery } = phaseId;
83
+ const { normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase } = phaseId;
68
84
  const { pruneOrphanedWorktrees } = worktreeSafety;
69
- const { planningPaths, planningDir, planningRoot, listAvailableWorkstreams, getActiveWorkstream, findContextMdIn, } = planningWorkspace;
85
+ const { planningPaths, planningDir, planningRoot, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, findContextMdIn, } = planningWorkspace;
70
86
  const { determinePhaseStatus } = commandsMod;
71
87
  const { extractFrontmatter } = frontmatterMod;
72
- const { readVerificationStatus } = verificationMod;
88
+ const { isPhaseComplete, resolveVerificationFile, resolveUatFile } = verificationMod;
73
89
  const { evaluateUatPassed } = uatPredicateMod;
90
+ const { resolveLoopHooks } = loopResolverMod;
91
+ const { loadRegistry } = capabilityLoaderMod;
92
+ const { resolveCapabilityRuntimeState } = capabilityStateMod;
74
93
  // Unused but imported for structural parity
75
94
  void stripShippedMilestones;
76
95
  // Accept all bold/colon variants of the Requirements header (#2769)
@@ -87,7 +106,7 @@ function phaseInfoMatchesExactPrefix(phaseInfo, phase) {
87
106
  function roadmapPhaseMatchesExactPrefix(roadmapPhase, phase) {
88
107
  const sectionRaw = roadmapPhase?.['section'];
89
108
  const section = typeof sectionRaw === 'string' ? sectionRaw : '';
90
- return new RegExp(`^#{2,4}\\s*Phase\\s+${escapeRegex(phase)}(?:\\b|\\s|:)`, 'i').test(section);
109
+ return new RegExp(`^#{2,4}\\s*Phase\\s+${(0, pattern_cjs_1.escapeRegex)(phase)}(?:\\b|\\s|:)`, 'i').test(section);
91
110
  }
92
111
  // #2104: shared helpers that wrap findPhaseInternal / getRoadmapPhaseInternal
93
112
  // with the #2056 foreign-prefix guard, so every init command gets the same
@@ -106,43 +125,98 @@ function guardedGetRoadmapPhase(cwd, phase, projectCode) {
106
125
  }
107
126
  return roadmapPhase;
108
127
  }
128
+ // #2994: `phase_slug` is re-derived from a roadmap-only `phase_name` (no disk
129
+ // directory exists yet) identically at every synthetic-fallback call site
130
+ // below — factored out once so the slugification formula itself cannot drift.
131
+ function slugifyPhaseName(phaseName) {
132
+ return phaseName
133
+ ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
134
+ : null;
135
+ }
136
+ /**
137
+ * #2994 (review finding, DEFECT.GENERATIVE-FIX): shared archived/not-found
138
+ * fallback applied identically by `cmdInitExecutePhase`, `cmdInitPlanPhase`,
139
+ * `cmdInitVerifyWork`, `cmdInitCodeReview`, `cmdInitReview`, and
140
+ * `cmdInitDiscussPhaseAssumptions` — 6 call sites previously reproducing the
141
+ * exact same two-branch control flow verbatim (only the synthetic
142
+ * replacement object's field set differs per caller, supplied here via
143
+ * `buildFallback`). `cmdInitPhaseOp` is deliberately left untouched (CRITICAL
144
+ * blast radius, 179 dependents) even though it follows the same shape, since
145
+ * its own fallback object differs by one field (`has_reviews` absent) and is
146
+ * not a byte-identical copy.
147
+ *
148
+ * Behavior-preserving by construction: every original call site either (a)
149
+ * unconditionally computed `roadmapPhase` once up front and then applied
150
+ * `phaseInfo?.archived && roadmapPhase?.found -> null` followed by
151
+ * `!phaseInfo && roadmapPhase?.found -> fallback`, or (b) computed
152
+ * `roadmapPhase` lazily inside each of those same two conditions. Because
153
+ * `guardedGetRoadmapPhase` is a pure, side-effect-free read for a given
154
+ * `(cwd, phase, projectCode)` within one command invocation, both shapes
155
+ * return identical results for identical inputs — so passing one
156
+ * unconditionally-resolved `roadmapPhase` in here (mirroring shape (a))
157
+ * reproduces shape (b)'s output exactly, just without the redundant second
158
+ * disk read shape (b) performed when the first branch already resolved it.
159
+ */
160
+ function applyRoadmapFallback(phaseInfo, roadmapPhase, buildFallback) {
161
+ if (phaseInfo?.['archived'] && roadmapPhase?.['found']) {
162
+ phaseInfo = null;
163
+ }
164
+ if (!phaseInfo && roadmapPhase?.['found']) {
165
+ phaseInfo = buildFallback(roadmapPhase);
166
+ }
167
+ return phaseInfo;
168
+ }
109
169
  function listPhaseSummaryFiles(phaseDir) {
110
170
  return scanPhasePlans(phaseDir)['summaryFiles'];
111
171
  }
112
172
  function listPhasePlanFiles(phaseDir) {
113
173
  return scanPhasePlans(phaseDir)['planFiles'];
114
174
  }
115
- function projectCompletionStatus(implementationComplete, verificationPassed) {
116
- if (implementationComplete && verificationPassed)
175
+ function projectCompletionStatus(implementationComplete, phaseComplete) {
176
+ if (phaseComplete)
117
177
  return 'complete';
118
178
  if (implementationComplete)
119
179
  return 'executed';
120
180
  return 'incomplete';
121
181
  }
122
182
  function buildPhaseCompletionProjection(cwd, phaseNumber, phaseDir, planCount, summaryCount, slashRuntime) {
183
+ // ADR-3180 §7.4 (issue #3186) / DO-NOT-MIGRATE exemption
184
+ // (scripts/lint-completion-predicate-drift.cjs FUNCTION_SCOPED_EXEMPTIONS,
185
+ // declared deviation): `implementation_complete` answers "are the plans
186
+ // done" (a `scanPhasePlans`-shaped different question, per the design's
187
+ // 0.x-split), NOT "is the phase complete" — it is kept for the
188
+ // 'executed'-vs-'planned' disk_status distinction downstream consumers
189
+ // still rely on, which `isPhaseComplete`'s locked `{ complete, verification
190
+ // }` return shape does not carry.
123
191
  const implementationComplete = planCount > 0 && summaryCount >= planCount;
124
192
  const phaseFullDir = phaseDir ? node_path_1.default.join(cwd, phaseDir) : '';
125
- // #2617: ONE verification-routing seam. init used to re-derive next_command
126
- // from the status with its own projector, which had drifted from the router's
127
- // table — it appended the phase number and answered `human_needed`; the table
128
- // did neither. The router now owns both the content and the runtime
129
- // projection, and init passes the phase number it already knows (its phaseDir
193
+ // #3168 / ADR-3180 §7.4 (disk-strict, #2957): route through the canonical
194
+ // owner (`src/verification.cts` · `isPhaseComplete`), which calls
195
+ // readVerificationStatus UNCONDITIONALLY — plan count is NOT a
196
+ // precondition. A zero-plan phase with a passing `*-VERIFICATION.md` is
197
+ // complete; init used to gate the read on `implementationComplete` and
198
+ // synthesize a `not_required` sentinel instead, which is the #3168 defect.
199
+ // #2617: the router still owns both the message content and the runtime
200
+ // projection; init passes the phase number it already knows (its phaseDir
130
201
  // is unresolved in some branches, where the router could not derive one).
131
- const verificationStatus = implementationComplete
132
- ? readVerificationStatus(phaseFullDir, { runtime: slashRuntime, phaseNumber })
133
- : { status: 'not_required', next_action: '', next_command: '' };
202
+ const completionResult = isPhaseComplete(phaseFullDir, { runtime: slashRuntime, phaseNumber });
203
+ const verificationStatus = completionResult.value.verification;
134
204
  const projectedVerificationStatus = verificationStatus.status;
135
205
  const projectedVerificationAction = verificationStatus.next_action;
136
206
  const verificationPassed = projectedVerificationStatus === 'passed';
137
- const phaseComplete = implementationComplete && verificationPassed;
207
+ const phaseComplete = completionResult.value.complete;
138
208
  return {
139
209
  implementation_complete: implementationComplete,
140
210
  verification_status: projectedVerificationStatus,
141
211
  verification_passed: verificationPassed,
142
212
  phase_complete: phaseComplete,
143
- completion_status: projectCompletionStatus(implementationComplete, verificationPassed),
213
+ completion_status: projectCompletionStatus(implementationComplete, phaseComplete),
144
214
  verification_next_action: projectedVerificationAction,
145
215
  verification_next_command: verificationStatus.next_command,
216
+ // #3057 B3: readVerificationStatus's result carries this flag when its
217
+ // internal staleness check could not run to completion.
218
+ verification_stale_check_indeterminate: 'staleCheckIndeterminate' in verificationStatus
219
+ && verificationStatus.staleCheckIndeterminate === true,
146
220
  };
147
221
  }
148
222
  function getLatestCompletedMilestone(cwd) {
@@ -160,8 +234,9 @@ function getLatestCompletedMilestone(cwd) {
160
234
  }
161
235
  function withProjectRoot(cwd, result) {
162
236
  result['project_root'] = cwd;
163
- const activeRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
164
- const agentStatus = checkAgentsInstalled(activeRuntime);
237
+ // #3245: the reported agent_runtime gets a host-detection rung below the two explicit sources; every other resolveRuntime caller keeps the old ladder (ADR-2313 scope boundary).
238
+ const activeRuntime = (0, host_runtime_detection_cjs_1.resolveReportedRuntime)(cwd);
239
+ const agentStatus = checkAgentsInstalled(activeRuntime, cwd);
165
240
  result['agents_installed'] = agentStatus.agents_installed;
166
241
  result['missing_agents'] = agentStatus.missing_agents;
167
242
  result['agents_dir'] = agentStatus.agents_dir;
@@ -250,42 +325,454 @@ function getInitGitState(cwd) {
250
325
  in_nested_subdir: inNestedSubdir,
251
326
  };
252
327
  }
328
+ // #2932 (Phase 5, ADR-1671): shipped, generated artifact — see
329
+ // scripts/gen-section-manifest.cjs and gsd-core/workflows/section-manifest.json.
330
+ // Resolved the same way model-catalog.cts resolves model-catalog.json: relative
331
+ // to the compiled module's own directory (gsd-core/bin/lib -> gsd-core/workflows),
332
+ // with a GSD_SECTION_MANIFEST env override so tests can point at a temp fixture
333
+ // (missing/malformed-JSON degraded-path coverage) without mutating the shipped
334
+ // artifact — the shipped file is a shared, concurrently-read resource across
335
+ // parallel test runs and must never be moved/corrupted in place.
336
+ const _sectionManifestCandidatePath = () => process.env['GSD_SECTION_MANIFEST']
337
+ ? node_path_1.default.resolve(process.env['GSD_SECTION_MANIFEST'])
338
+ : node_path_1.default.resolve(__dirname, '..', '..', 'workflows', 'section-manifest.json');
339
+ /**
340
+ * Defense-in-depth shape check for a manifest entry's `read` field, which is
341
+ * documented as a POSIX-normalized, repo-root-RELATIVE path (never a
342
+ * filesystem escape). Rejects any absolute path (POSIX leading `/`, a
343
+ * Windows drive prefix like `C:\`/`C:/`, or a Windows UNC/rooted path
344
+ * starting with `\`) and any path containing a `..` segment (checked on
345
+ * BOTH separators — the artifact is generated as POSIX-normalized, but this
346
+ * validates the raw field defensively rather than trusting that invariant).
347
+ * `false` here is the only accept path in {@link loadSectionManifestSections};
348
+ * a `true` degrades the WHOLE load to `null`, same as every other shape
349
+ * violation — never throws, never partially loads.
350
+ */
351
+ function isUnsafeManifestReadPath(readPath) {
352
+ if (readPath.startsWith('/') || readPath.startsWith('\\'))
353
+ return true;
354
+ if (/^[a-zA-Z]:[\\/]/.test(readPath))
355
+ return true;
356
+ return readPath.split(/[\\/]/).includes('..');
357
+ }
358
+ /**
359
+ * Loads and shape-validates the generated section manifest, then returns the
360
+ * document-order section array for exactly one named `workflow` (#2992 Phase
361
+ * 6.1: the artifact is now `{ workflows: { <name>: [...] } }`, keyed by
362
+ * `.md` basename — see `scripts/gen-section-manifest.cjs`). Returns `null`
363
+ * — never throws — when the artifact is missing, unreadable, malformed
364
+ * JSON, valid JSON of the wrong shape (INCLUDING the pre-6.1 flat
365
+ * `{sections:[...]}` shape, which must never be mis-attributed to any
366
+ * workflow — design row C4), or when `workflow` has no key in `workflows`.
367
+ * `Object.hasOwn` guards the key lookup so a hostile workflow name
368
+ * (`constructor`, `toString`, `__proto__`) can never resolve via the
369
+ * prototype chain instead of a genuine own key. Each entry's `read` field is
370
+ * additionally validated by {@link isUnsafeManifestReadPath} (rejects an
371
+ * absolute path or a `..` segment) — a single unsafe entry degrades the
372
+ * WHOLE load to `null`, all-or-nothing like every other shape violation.
373
+ */
374
+ function loadSectionManifestSections(workflow) {
375
+ try {
376
+ const raw = node_fs_1.default.readFileSync(_sectionManifestCandidatePath(), 'utf8');
377
+ const parsed = JSON.parse(raw);
378
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
379
+ return null;
380
+ const workflows = parsed['workflows'];
381
+ if (workflows === null || typeof workflows !== 'object' || Array.isArray(workflows))
382
+ return null;
383
+ if (!Object.hasOwn(workflows, workflow))
384
+ return null;
385
+ const sections = workflows[workflow];
386
+ if (!Array.isArray(sections))
387
+ return null;
388
+ for (const section of sections) {
389
+ const readValue = section?.['read'];
390
+ if (!section ||
391
+ typeof section !== 'object' ||
392
+ typeof section['id'] !== 'string' ||
393
+ typeof section['when'] !== 'string' ||
394
+ typeof readValue !== 'string' ||
395
+ isUnsafeManifestReadPath(readValue)) {
396
+ return null;
397
+ }
398
+ }
399
+ return sections;
400
+ }
401
+ catch {
402
+ return null;
403
+ }
404
+ }
405
+ /**
406
+ * `state:has-prior-phases` ground truth (design doc §Behavior table, regression-gate
407
+ * body: "Skip if: this is the first phase (no prior phases)"): TRUE when at least
408
+ * one OTHER phase directory under `.planning/phases/` contains a `*-VERIFICATION.md`
409
+ * file. Bounded, non-throwing — an unreadable phases directory degrades to `false`
410
+ * rather than surfacing an error from an init query.
411
+ */
412
+ function detectHasPriorPhases(cwd, phaseInfo) {
413
+ const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
414
+ const currentDirName = phaseInfo?.['directory']
415
+ ? node_path_1.default.basename(phaseInfo['directory'])
416
+ : null;
417
+ try {
418
+ if (!node_fs_1.default.existsSync(phasesDir))
419
+ return false;
420
+ const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
421
+ for (const entry of entries) {
422
+ if (!entry.isDirectory() || entry.name === currentDirName)
423
+ continue;
424
+ let files;
425
+ try {
426
+ files = node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, entry.name));
427
+ }
428
+ catch {
429
+ continue;
430
+ }
431
+ // #3511-class: scope the raw listing to THIS entry's own phase artifacts
432
+ // before the bare `.some()` predicate runs, so a stray `07-VERIFICATION.md`
433
+ // physically sitting in another phase's directory cannot make that
434
+ // directory appear to have its own verification report.
435
+ const scopedFiles = scopeToPhase(files, entry.name);
436
+ if (scopedFiles.some((f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md')) {
437
+ return true;
438
+ }
439
+ }
440
+ return false;
441
+ }
442
+ catch {
443
+ return false;
444
+ }
445
+ }
446
+ /**
447
+ * Strict-boolean, bounded, non-throwing read of a dotted key path from
448
+ * `.planning/config.json` (design rows D7-D10): absent file, unreadable
449
+ * file (fs error), malformed JSON, a non-object intermediate segment, or a
450
+ * present-but-non-boolean value (e.g. the string `"true"`) all degrade to
451
+ * `false` — strict `=== true`, never coerced, mirrors `detectHasPriorPhases`'s
452
+ * degrade-to-false discipline. `keyPath` is always a fixed literal supplied
453
+ * by this module, never attacker/user input, so a plain bracket traversal
454
+ * carries no prototype hazard here.
455
+ */
456
+ function readConfigJsonBoolean(cwd, keyPath) {
457
+ try {
458
+ const raw = node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'config.json'), 'utf8');
459
+ let cursor = JSON.parse(raw);
460
+ for (const segment of keyPath) {
461
+ if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor))
462
+ return false;
463
+ cursor = cursor[segment];
464
+ }
465
+ return cursor === true;
466
+ }
467
+ catch {
468
+ return false;
469
+ }
470
+ }
471
+ /**
472
+ * Bounded, non-throwing read of a dotted key path from `.planning/config.json`,
473
+ * returning the raw resolved value (any JSON type) or `undefined` on any
474
+ * degraded condition (absent file, unreadable file, malformed JSON, or a
475
+ * non-object intermediate segment) — the generic sibling of
476
+ * {@link readConfigJsonBoolean} for callers that need the actual value
477
+ * (a string like `code_quality.fallow.profile`) rather than a strict
478
+ * boolean coercion. `keyPath` is always a fixed literal supplied by this
479
+ * module, never attacker/user input, so a plain bracket traversal carries
480
+ * no prototype hazard here (same discipline as `readConfigJsonBoolean`).
481
+ */
482
+ function readConfigJsonValue(cwd, keyPath) {
483
+ try {
484
+ const raw = node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'config.json'), 'utf8');
485
+ let cursor = JSON.parse(raw);
486
+ for (const segment of keyPath) {
487
+ if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor))
488
+ return undefined;
489
+ cursor = cursor[segment];
490
+ }
491
+ return cursor;
492
+ }
493
+ catch {
494
+ return undefined;
495
+ }
496
+ }
497
+ /**
498
+ * `state:fallow-enabled` ground truth (#2994): resolves `code-review.md`'s
499
+ * `structural_pre_pass` fallow config gate — previously re-derived INSIDE the
500
+ * gated section body itself (`gsd_run query config-get code_quality.fallow.*`),
501
+ * which is circular/self-disabling the moment a section is gated on a fact
502
+ * its own body computes (the same hazard `state:chunked-mode` /
503
+ * `state:ui-phase-active` document for a compound condition). Fail-closed
504
+ * default `false` for `enabled`/`mcp`, matching the pre-hoist bash resolver's
505
+ * `2>/dev/null || echo "false"` fallback; `scope`/`profile` default to
506
+ * `"phase"`/`"standard"` matching that same resolver's `|| echo` fallbacks.
507
+ * `maxCrap` mirrors the step body's profile->threshold mapping (minimal=50,
508
+ * strict=15, else standard=30) so the step file never has to re-derive it.
509
+ */
510
+ function detectFallowConfig(cwd) {
511
+ const enabled = readConfigJsonValue(cwd, ['code_quality', 'fallow', 'enabled']) === true;
512
+ const rawScope = readConfigJsonValue(cwd, ['code_quality', 'fallow', 'scope']);
513
+ const scope = typeof rawScope === 'string' && rawScope ? rawScope : 'phase';
514
+ const rawProfile = readConfigJsonValue(cwd, ['code_quality', 'fallow', 'profile']);
515
+ const profile = typeof rawProfile === 'string' && rawProfile ? rawProfile : 'standard';
516
+ const mcp = readConfigJsonValue(cwd, ['code_quality', 'fallow', 'mcp']) === true;
517
+ const maxCrap = profile === 'minimal' ? 50 : profile === 'strict' ? 15 : 30;
518
+ return { enabled, scope, profile, mcp, maxCrap };
519
+ }
520
+ /**
521
+ * `state:git-create-tag` ground truth (#2994): resolves `complete-milestone.md`'s
522
+ * `git_tag` step config gate — previously re-derived INSIDE a `<config-check>`
523
+ * sub-tag at the top of the step itself (`gsd-tools.cjs query config-get
524
+ * git.create_tag 2>/dev/null || echo "true"`), gating the step's OWN inclusion
525
+ * on a fact only that same step computed. Fail-OPEN default `true` (an unset
526
+ * or missing `git.create_tag` key means "create the tag"), matching the
527
+ * pre-hoist resolver's `|| echo "true"` fallback exactly — this is
528
+ * deliberately the inverse polarity of `detectFallowConfig`'s fail-closed
529
+ * default, mirroring the two source resolvers' own opposite defaults.
530
+ */
531
+ function detectGitCreateTag(cwd) {
532
+ return readConfigJsonValue(cwd, ['git', 'create_tag']) !== false;
533
+ }
534
+ /**
535
+ * `state:phase-mvp-mode` ground truth (design doc §Behavior table: ROADMAP.md
536
+ * `**Mode:** mvp` for the CURRENT phase). Bounded, non-throwing — an absent
537
+ * `phaseNumber`, an absent ROADMAP.md, an absent phase heading, or a phase
538
+ * section with no `**Mode:**` line (or a `**Mode:**` value other than the
539
+ * literal `mvp` token, case-insensitively) all degrade to `false` (D11; "a
540
+ * phase with no `**Mode:**` line and an absent ROADMAP are both false, but
541
+ * neither may throw"). Self-contained rather than reusing `phase.cts`'s
542
+ * private `getRoadmapModeForPhase` (unexported, and importing it here would
543
+ * be a cross-module surface change outside this task's scope) — but derived
544
+ * from the SAME extraction primitives (`extractCurrentMilestone`,
545
+ * `PHASE_NUMBER_TOKEN_SOURCE`-adjacent `escapeRegex`) already used by this
546
+ * file's own `cmdInitProgress` MVP-heading scan, so it is not a second
547
+ * ROADMAP-heading parser invented from scratch.
548
+ */
549
+ function detectPhaseMvpMode(cwd, phaseNumber) {
550
+ if (!phaseNumber)
551
+ return false;
552
+ try {
553
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
554
+ if (!node_fs_1.default.existsSync(roadmapPath))
555
+ return false;
556
+ const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
557
+ const content = extractCurrentMilestone(rawContent, cwd);
558
+ const escapedPhase = (0, pattern_cjs_1.escapeRegex)(phaseNumber);
559
+ const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
560
+ const headerMatch = content.match(phaseHeader);
561
+ if (!headerMatch || headerMatch.index === undefined)
562
+ return false;
563
+ const sectionStart = headerMatch.index;
564
+ const rest = content.slice(sectionStart + headerMatch[0].length);
565
+ const nextHeaderMatch = rest.match(/\n#{2,4}\s+Phase\s+\S/i);
566
+ const sectionEnd = nextHeaderMatch
567
+ ? sectionStart + headerMatch[0].length + nextHeaderMatch.index
568
+ : content.length;
569
+ const section = content.slice(sectionStart, sectionEnd);
570
+ const modeMatch = section.match(/\*\*Mode:\*\*\s*([^\n]+)/i);
571
+ return modeMatch ? modeMatch[1].trim().toLowerCase() === 'mvp' : false;
572
+ }
573
+ catch {
574
+ return false;
575
+ }
576
+ }
577
+ /**
578
+ * `state:ui-phase-active` ground truth (#2994): whether the phase's active
579
+ * `plan:pre` loop hooks include the `ui-phase` step (`capabilities/ui/
580
+ * capability.json`'s `plan:pre` step, `ref.skill: "ui-phase"`, gated on
581
+ * config `workflow.ui_phase`), OR the phase directory already contains a
582
+ * `*-UI-SPEC.md` file. The disjunction is resolved to ONE boolean here —
583
+ * same discipline as `chunkedMode` above — so the `when=` grammar never
584
+ * sees an OR. Mirrors `cmdLoopRenderHooks`'s own registry/capability-state
585
+ * setup (`src/loop-resolver.cts`) rather than reinventing a second loop-hook
586
+ * resolution path. Bounded, non-throwing: any failure in loop-hook /
587
+ * registry / capability-state resolution degrades that half of the OR to
588
+ * `false`, never throws; the UI-SPEC file check is independently bounded.
589
+ */
590
+ function detectUiPhaseActive(cwd, phaseInfo) {
591
+ let hasActiveUiStep = false;
592
+ try {
593
+ const config = loadConfig(cwd);
594
+ const state = resolveCapabilityRuntimeState(cwd, undefined, config);
595
+ const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] });
596
+ const capabilityStatesById = new Map();
597
+ for (const cap of state.capabilities || []) {
598
+ capabilityStatesById.set(cap.id, cap);
599
+ }
600
+ const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config, cwd, capabilityStatesById });
601
+ hasActiveUiStep = resolved.activeHooks.some((h) => h.kind === 'step' && h.ref?.skill === 'ui-phase');
602
+ }
603
+ catch {
604
+ hasActiveUiStep = false;
605
+ }
606
+ let hasUiSpecFile = false;
607
+ const rawDir = phaseInfo?.['directory'];
608
+ if (typeof rawDir === 'string' && rawDir) {
609
+ try {
610
+ // Re-derive under planningDir(cwd)/phases/<basename> rather than trusting
611
+ // rawDir's own absolute/relative-ness (callers mix both — see the #2376
612
+ // comments elsewhere in this file), same technique as detectHasPriorPhases above.
613
+ const dirName = node_path_1.default.basename(rawDir);
614
+ const files = node_fs_1.default.readdirSync(node_path_1.default.join(planningDir(cwd), 'phases', dirName));
615
+ // #3511-class: scope the raw listing to this phase dir before the
616
+ // phase-numbered -UI-SPEC.md predicate, so a stray cross-phase
617
+ // UI-SPEC file cannot flip this phase's ui-phase-active flag.
618
+ const scopedFiles = scopeToPhase(files, dirName);
619
+ hasUiSpecFile = scopedFiles.some((f) => f.endsWith('-UI-SPEC.md') || f === 'UI-SPEC.md');
620
+ }
621
+ catch {
622
+ hasUiSpecFile = false;
623
+ }
624
+ }
625
+ return hasActiveUiStep || hasUiSpecFile;
626
+ }
627
+ /**
628
+ * Builds the `section_manifest` init-bundle field (#2932 Deliverable 2): resolves
629
+ * {@link sectionManifest.InvocationFacts} from this invocation, loads the generated
630
+ * manifest, and partitions it via the pure {@link sectionManifest.selectSections}
631
+ * evaluator. Returns `null` on any degraded condition (missing/malformed artifact,
632
+ * or an unexpected throw from the evaluator itself) — this field is additive and
633
+ * optional, never load-bearing for dispatch (Hyrum's Law: 22 direct init-bundle
634
+ * dependents must be unaffected by its absence).
635
+ *
636
+ * `flags` (D1-D5): built from `options`'s OWN keys, gated on VALUE TRUTHINESS
637
+ * — not merely `!== undefined`. `parseNamedArgs` (src/command-arg-projection.cts)
638
+ * never yields `undefined` for an absent flag of either kind: a value-flag's
639
+ * absence is `null`, a booleanFlag's absence is `false`. An `undefined`-only
640
+ * absence check therefore lets BOTH kinds of absent flag leak into `flags` as
641
+ * present. A present value-flag is always a non-empty string, and a present
642
+ * booleanFlag is always `true` — so skipping any falsy value (`undefined`,
643
+ * `null`, `false`, `''`, `0`) is a safe, single-rule absence test for both
644
+ * flag kinds; `--wave 0` still resolves to `true` via `booleanFlags`, so
645
+ * truthiness never misclassifies a real invocation as absent. `Object.keys`
646
+ * + a plain `new Set()` so a hostile option key (e.g. `constructor`) can
647
+ * never leak via the prototype chain.
648
+ *
649
+ * `needsCodebaseMap` is not computed in this shared facts-assembly scope —
650
+ * `isBrownfield && !hasCodebaseMap` is only meaningful for `new-project`
651
+ * (`cmdInitNewProject` already computes both operands for its own result
652
+ * object). Rather than recomputing it here (a second, divergence-prone
653
+ * codebase-map scan) or widening every call site's positional signature,
654
+ * callers that HAVE the fact pass it via the optional `overrides` param;
655
+ * every other caller passes nothing and gets `undefined` (falsy per
656
+ * `WHEN_PREDICATES`, never invented, never throws).
657
+ */
658
+ function buildSectionManifestField(cwd, phaseInfo, options, workflow, overrides = {}) {
659
+ const sections = loadSectionManifestSections(workflow);
660
+ if (!sections)
661
+ return null;
662
+ const rawPhaseNumber = phaseInfo?.['phase_number'];
663
+ const phaseNumber = typeof rawPhaseNumber === 'string'
664
+ ? rawPhaseNumber
665
+ : typeof rawPhaseNumber === 'number'
666
+ ? String(rawPhaseNumber)
667
+ : null;
668
+ const flags = new Set();
669
+ for (const key of Object.keys(options)) {
670
+ if (!options[key])
671
+ continue;
672
+ flags.add(`--${key}`);
673
+ }
674
+ // `state:chunked-mode` (#2993) is a disjunction — `--chunked` flag OR
675
+ // `.planning/config.json` `workflow.plan_chunked` — resolved to ONE
676
+ // boolean HERE, in fact computation, never in the `when=` grammar itself
677
+ // (WHEN_PREDICATES['state:chunked-mode'] reads only `facts.chunkedMode`).
678
+ // That separation is what keeps ADR-1671:69's Greenspun guard intact: the
679
+ // grammar still sees exactly one atom with no operator.
680
+ const chunkedMode = flags.has('--chunked') || readConfigJsonBoolean(cwd, ['workflow', 'plan_chunked']);
681
+ const facts = {
682
+ flags,
683
+ phaseNumber,
684
+ hasPriorPhases: detectHasPriorPhases(cwd, phaseInfo),
685
+ worktreesEnabled: readConfigJsonBoolean(cwd, ['workflow', 'use_worktrees']),
686
+ phaseMvpMode: detectPhaseMvpMode(cwd, phaseNumber),
687
+ needsCodebaseMap: overrides.needsCodebaseMap,
688
+ chunkedMode,
689
+ uiPhaseActive: overrides.uiPhaseActive,
690
+ fallowEnabled: overrides.fallowEnabled,
691
+ gitCreateTag: overrides.gitCreateTag,
692
+ planStrategyConverge: overrides.planStrategyConverge,
693
+ reviewerInstancesConfigured: overrides.reviewerInstancesConfigured,
694
+ autoAdvanceActive: overrides.autoAdvanceActive,
695
+ isMonorepo: overrides.isMonorepo,
696
+ nextChannel: overrides.nextChannel,
697
+ workstreamActive: overrides.workstreamActive,
698
+ flatMode: overrides.flatMode,
699
+ };
700
+ try {
701
+ const selection = sectionManifest.selectSections(sections, facts);
702
+ const readById = new Map(sections.map((s) => [s.id, s.read]));
703
+ return {
704
+ workflow,
705
+ included: selection.included,
706
+ excluded: selection.excluded,
707
+ read: selection.included
708
+ .map((id) => readById.get(id))
709
+ .filter((p) => typeof p === 'string'),
710
+ };
711
+ }
712
+ catch {
713
+ return null;
714
+ }
715
+ }
716
+ /**
717
+ * #3216 review Finding 1: `getMilestoneInfo(cwd).value` unwrap-and-cast was
718
+ * repeated identically (comment included) at five init call sites — factored
719
+ * out once so the cast and its `?? {}` "no milestone resolved" fallback live
720
+ * in exactly one place. Behavior-preserving: same call, same fallback, same
721
+ * cast, for every caller.
722
+ */
723
+ function milestoneRecord(cwd) {
724
+ return (getMilestoneInfo(cwd).value ?? {});
725
+ }
253
726
  function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
254
727
  if (!phase) {
255
728
  error('phase required for init execute-phase');
256
729
  }
257
730
  const config = loadConfig(cwd);
258
731
  let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
259
- const milestone = getMilestoneInfo(cwd);
732
+ // #3216: getMilestoneInfo now returns a ScopedResult — `.value` carries the
733
+ // MilestoneInfo (or null on any non-COMPLETE scope). NOT display-only: when
734
+ // `branching_strategy === 'milestone'`, `milestone['version']`/`['name']`
735
+ // below feed `branch_name` construction (see the milestone_branch_template
736
+ // branch below), so an unresolved milestone changes the constructed branch
737
+ // name, not merely what gets printed. bracket-access below naturally reads
738
+ // `undefined` when unresolved; the `milestone_version`/`milestone_name`
739
+ // output fields below coerce that to an explicit `null` (#3216 review
740
+ // Finding 2) so the key is never silently omitted from the JSON bundle.
741
+ const milestone = milestoneRecord(cwd);
260
742
  const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
261
- if (phaseInfo?.['archived'] && roadmapPhase?.['found']) {
262
- phaseInfo = null;
263
- }
264
- if (!phaseInfo && roadmapPhase?.['found']) {
265
- const phaseName = roadmapPhase['phase_name'];
266
- phaseInfo = {
743
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
744
+ const phaseName = rp['phase_name'];
745
+ return {
267
746
  found: true,
268
747
  directory: null,
269
- phase_number: roadmapPhase['phase_number'],
748
+ phase_number: rp['phase_number'],
270
749
  phase_name: phaseName,
271
- phase_slug: phaseName
272
- ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
273
- : null,
750
+ phase_slug: slugifyPhaseName(phaseName),
274
751
  plans: [],
275
752
  summaries: [],
276
753
  incomplete_plans: [],
754
+ halted_plans: [],
755
+ blocked_by: {},
756
+ runnable_plans: [],
277
757
  has_research: false,
278
758
  has_context: false,
279
759
  has_verification: false,
280
760
  has_reviews: false,
281
761
  };
282
- }
762
+ });
283
763
  const reqMatch = roadmapPhase?.['section']?.match(REQUIREMENTS_HEADER_RE);
284
764
  const reqExtracted = reqMatch
285
765
  ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
286
766
  : null;
287
767
  const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null;
288
768
  const wf = (config.workflow ?? {});
769
+ // #3188: these paths are null when the file is absent, matching the contract
770
+ // the conditional sibling fields (context_path, patterns_path, ...) already
771
+ // honour and that ultraplan-phase.md / execute-phase.md gate on. Hoisted so
772
+ // the existence check and the emitted path share one source of truth.
773
+ const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
774
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
775
+ const requirementsPath = node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md');
289
776
  const result = {
290
777
  executor_model: resolveModelInternal(cwd, 'gsd-executor'),
291
778
  verifier_model: resolveModelInternal(cwd, 'gsd-verifier'),
@@ -305,7 +792,17 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
305
792
  ? toPosixPath(node_path_1.default.join(cwd, phaseInfo['directory']))
306
793
  : null,
307
794
  phase_number: phaseInfo?.['phase_number'] || null,
308
- phase_name: phaseInfo?.['phase_name'] || null,
795
+ // #3171: prefer the ROADMAP's curated display name for `phase_name`. When
796
+ // the phase directory already exists on disk, the disk-lookup path
797
+ // (searchPhaseInDir) derives phase_name from the directory-name remainder
798
+ // — itself an already-slugified value (`phase.add` writes `${num}-${slug}`
799
+ // dirs), so phase_name and phase_slug come out byte-identical. An
800
+ // orchestrator wiring this field into `state begin-phase --name` then
801
+ // lands a raw slug in STATE.md's current_phase_name. The ROADMAP carries
802
+ // the human-curated display name (`### Phase N: <Name>`); prefer it,
803
+ // matching the no-disk fallback above. phase_slug stays disk-derived — it
804
+ // correctly feeds branch-name construction below and is unchanged here.
805
+ phase_name: (roadmapPhase?.['phase_name']) || (phaseInfo?.['phase_name']) || null,
309
806
  phase_slug: phaseInfo?.['phase_slug'] || null,
310
807
  phase_req_ids,
311
808
  plans: phaseInfo?.['plans'] || [],
@@ -313,6 +810,15 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
313
810
  incomplete_plans: phaseInfo?.['incomplete_plans'] || [],
314
811
  plan_count: phaseInfo?.['plans']?.length || 0,
315
812
  incomplete_count: phaseInfo?.['incomplete_plans']?.length || 0,
813
+ // #2830: the halt-aware view, forwarded from the shared computation in
814
+ // phase-locator. Additive — `incomplete_plans`/`incomplete_count` above keep
815
+ // their exact name, type and semantics. Without this passthrough the shared
816
+ // truth is computed and then dropped at this consumer, which is the path the
817
+ // issue reports as regressed.
818
+ halted_plans: phaseInfo?.['halted_plans'] || [],
819
+ blocked_by: phaseInfo?.['blocked_by'] || {},
820
+ runnable_plans: phaseInfo?.['runnable_plans'] || [],
821
+ runnable_count: phaseInfo?.['runnable_plans']?.length || 0,
316
822
  branch_name: config.branching_strategy === 'phase' && phaseInfo
317
823
  ? config.phase_branch_template
318
824
  .replace('{project}', config.project_code || '')
@@ -320,22 +826,23 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
320
826
  .replace('{slug}', phaseInfo['phase_slug'] || 'phase')
321
827
  : config.branching_strategy === 'milestone'
322
828
  ? config.milestone_branch_template
323
- .replace('{milestone}', milestone['version'])
829
+ .replace('{milestone}', milestone['version'] ?? '')
324
830
  .replace('{slug}', generateSlugInternal(milestone['name']) || 'milestone')
325
831
  : null,
326
- milestone_version: milestone['version'],
327
- milestone_name: milestone['name'],
832
+ milestone_version: milestone['version'] ?? null,
833
+ milestone_name: milestone['name'] ?? null,
328
834
  milestone_slug: generateSlugInternal(milestone['name']),
329
835
  state_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'STATE.md')),
330
836
  roadmap_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
331
837
  config_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'config.json')),
332
838
  // #2376: emit absolute paths — see comment above on phase_dir.
333
- state_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'STATE.md')),
334
- roadmap_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
839
+ // #3188: null when the file is absent (parity with patterns_path/context_path).
840
+ state_path: node_fs_1.default.existsSync(statePath) ? toPosixPath(statePath) : null,
841
+ roadmap_path: node_fs_1.default.existsSync(roadmapPath) ? toPosixPath(roadmapPath) : null,
335
842
  config_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'config.json')),
336
843
  // #2376: execute-phase.md's verify_phase_goal step reads this instead of
337
844
  // hardcoding '.planning/REQUIREMENTS.md' into the gsd-verifier spawn prompt.
338
- requirements_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md')),
845
+ requirements_path: node_fs_1.default.existsSync(requirementsPath) ? toPosixPath(requirementsPath) : null,
339
846
  };
340
847
  if (options['validate']) {
341
848
  try {
@@ -359,6 +866,8 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
359
866
  /* intentionally empty */
360
867
  }
361
868
  }
869
+ // #2932/#2992 (Phase 5/6.1): additive, optional field — degrades to null, never throws.
870
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options, 'execute-phase');
362
871
  output(withProjectRoot(cwd, result), raw);
363
872
  }
364
873
  function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
@@ -369,19 +878,14 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
369
878
  // #2056/#2104: foreign-prefixed queries must not collapse to numeric phases.
370
879
  let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
371
880
  const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
372
- if (phaseInfo?.['archived'] && roadmapPhase?.['found']) {
373
- phaseInfo = null;
374
- }
375
- if (!phaseInfo && roadmapPhase?.['found']) {
376
- const phaseName = roadmapPhase['phase_name'];
377
- phaseInfo = {
881
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
882
+ const phaseName = rp['phase_name'];
883
+ return {
378
884
  found: true,
379
885
  directory: null,
380
- phase_number: roadmapPhase['phase_number'],
886
+ phase_number: rp['phase_number'],
381
887
  phase_name: phaseName,
382
- phase_slug: phaseName
383
- ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
384
- : null,
888
+ phase_slug: slugifyPhaseName(phaseName),
385
889
  plans: [],
386
890
  summaries: [],
387
891
  incomplete_plans: [],
@@ -390,7 +894,7 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
390
894
  has_verification: false,
391
895
  has_reviews: false,
392
896
  };
393
- }
897
+ });
394
898
  const reqMatch = roadmapPhase?.['section']?.match(REQUIREMENTS_HEADER_RE);
395
899
  const reqExtracted = reqMatch
396
900
  ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
@@ -415,6 +919,11 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
415
919
  assertValidGranularityOverride(granularityOverride, error);
416
920
  const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined);
417
921
  const wf = (config.workflow ?? {});
922
+ // #3188: see cmdInitExecutePhase — null when absent, parity with the
923
+ // conditional sibling fields in this same result object.
924
+ const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
925
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
926
+ const requirementsPath = node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md');
418
927
  const result = {
419
928
  researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
420
929
  planner_model: resolveModelInternal(cwd, 'gsd-planner'),
@@ -451,36 +960,70 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
451
960
  planning_exists: node_fs_1.default.existsSync(planningDir(cwd)),
452
961
  roadmap_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
453
962
  // #2376: absolute — see comment on phase_dir above.
454
- state_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'STATE.md')),
455
- roadmap_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
456
- requirements_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md')),
963
+ // #3188: null when the file is absent (parity with patterns_path below).
964
+ state_path: node_fs_1.default.existsSync(statePath) ? toPosixPath(statePath) : null,
965
+ roadmap_path: node_fs_1.default.existsSync(roadmapPath) ? toPosixPath(roadmapPath) : null,
966
+ requirements_path: node_fs_1.default.existsSync(requirementsPath) ? toPosixPath(requirementsPath) : null,
457
967
  patterns_path: null,
458
968
  };
459
969
  if (phaseInfo?.['directory']) {
460
970
  const phaseDirFull = node_path_1.default.join(cwd, phaseInfo['directory']);
461
971
  try {
462
972
  const files = node_fs_1.default.readdirSync(phaseDirFull);
463
- const contextFile = findContextMdIn(phaseDirFull);
973
+ const phaseDirName = node_path_1.default.basename(phaseDirFull);
974
+ // #3511 BLOCKER-3: scope the raw listing to THIS phase's own artifacts
975
+ // before any bare `.find()` predicate runs, so a `04-UAT.md` (or
976
+ // `04-RESEARCH.md`/`04-REVIEWS.md`/`04-PATTERNS.md`) sitting in phase
977
+ // 03's directory cannot win a phase-03 lookup — the same
978
+ // `isPhaseArtifact` membership rule `resolveVerificationFile` already
979
+ // applies via `phaseDirName` below. `findContextMdIn` is passed the
980
+ // scoped array (rather than the raw directory path) so this call site
981
+ // alone is scoped; its other call sites are unaffected.
982
+ const scopedFiles = scopeToPhase(files, phaseDirName);
983
+ const contextFile = findContextMdIn(scopedFiles);
464
984
  if (contextFile) {
465
985
  result['context_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, contextFile));
466
986
  }
467
- const researchFile = files.find((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
987
+ const researchFile = scopedFiles.find((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
468
988
  if (researchFile) {
469
989
  result['research_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, researchFile));
470
990
  }
471
- const verificationFile = files.find((f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
991
+ // #3473 F2: routed through the shared resolver — readdir order is
992
+ // filesystem-dependent, so the prior hand-rolled `.find()` could pick
993
+ // either file when a phase held both a canonical report and an ad-hoc
994
+ // `-CORRECTION-VERIFICATION.md` worksheet (#3357).
995
+ // #3492: pin selection to THIS phase's own token so a stray cross-phase
996
+ // or sentinel-numbered canonically-shaped file cannot outrank this
997
+ // phase's own (possibly non-canonical) report.
998
+ const phaseToken = extractPhaseToken(phaseDirName);
999
+ const verificationFile = resolveVerificationFile(files, {
1000
+ allowBare: true,
1001
+ phaseToken,
1002
+ phaseDirName,
1003
+ });
472
1004
  if (verificationFile) {
473
1005
  result['verification_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, verificationFile));
474
1006
  }
475
- const uatFile = files.find((f) => f.endsWith('-UAT.md') || f === 'UAT.md');
1007
+ // #3518: routed through the shared UAT resolver — the prior hand-rolled
1008
+ // `.find()` over unsorted readdir order had no phase check and no
1009
+ // ordering, so a stray cross-phase 02-UAT.md could become this phase's
1010
+ // uat_path, filesystem-dependently. Pinned to this phase's own token
1011
+ // (same rule as verification_path above), and phase-scoped via
1012
+ // phaseDirName (#3511) so the alphabetically-first fallback tier also
1013
+ // excludes cross-phase strays.
1014
+ const uatFile = resolveUatFile(files, {
1015
+ allowBare: true,
1016
+ phaseToken,
1017
+ phaseDirName,
1018
+ });
476
1019
  if (uatFile) {
477
1020
  result['uat_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, uatFile));
478
1021
  }
479
- const reviewsFile = files.find((f) => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md');
1022
+ const reviewsFile = scopedFiles.find((f) => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md');
480
1023
  if (reviewsFile) {
481
1024
  result['reviews_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, reviewsFile));
482
1025
  }
483
- const patternsFile = files.find((f) => f.endsWith('-PATTERNS.md') || f === 'PATTERNS.md');
1026
+ const patternsFile = scopedFiles.find((f) => f.endsWith('-PATTERNS.md') || f === 'PATTERNS.md');
484
1027
  if (patternsFile) {
485
1028
  result['patterns_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, patternsFile));
486
1029
  }
@@ -511,9 +1054,11 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
511
1054
  /* intentionally empty */
512
1055
  }
513
1056
  }
1057
+ // #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
1058
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options, 'plan-phase');
514
1059
  output(withProjectRoot(cwd, result), raw);
515
1060
  }
516
- function cmdInitNewProject(cwd, raw) {
1061
+ function cmdInitNewProject(cwd, raw, options = {}) {
517
1062
  const config = loadConfig(cwd);
518
1063
  const homedir = node_os_1.default.homedir();
519
1064
  const braveKeyFile = node_path_1.default.join(homedir, '.gsd', 'brave_api_key');
@@ -552,26 +1097,25 @@ function cmdInitNewProject(cwd, raw) {
552
1097
  config_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'config.json')),
553
1098
  research_dir: toPosixPath(node_path_1.default.join(planningRoot(cwd), 'research')),
554
1099
  };
1100
+ // #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
1101
+ // needsCodebaseMap is threaded from this scope's own isBrownfield/hasCodebaseMap
1102
+ // computation (see `needs_codebase_map` above) so `state:needs-codebase-map` is
1103
+ // genuinely computed for this workflow, not left permanently false.
1104
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'new-project', {
1105
+ needsCodebaseMap: isBrownfield && !hasCodebaseMap,
1106
+ });
555
1107
  output(withProjectRoot(cwd, result), raw);
556
1108
  }
557
- function cmdInitNewMilestone(cwd, raw) {
1109
+ function cmdInitNewMilestone(cwd, raw, options = {}) {
558
1110
  const config = loadConfig(cwd);
559
- const milestone = getMilestoneInfo(cwd);
1111
+ const milestone = milestoneRecord(cwd);
560
1112
  const latestCompleted = getLatestCompletedMilestone(cwd);
561
1113
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
562
- let phaseDirCount = 0;
563
- try {
564
- if (node_fs_1.default.existsSync(phasesDir)) {
565
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
566
- phaseDirCount = node_fs_1.default
567
- .readdirSync(phasesDir, { withFileTypes: true })
568
- .filter((entry) => entry.isDirectory() && isDirInMilestone(entry.name))
569
- .length;
570
- }
571
- }
572
- catch {
573
- /* intentionally empty */
574
- }
1114
+ // #3185 (ADR-3180 Decision 1): "how many phase directories belong to the
1115
+ // CURRENT milestone" is exactly the scoped question listMilestonePhaseDirs
1116
+ // owns — routed through it instead of a local readdirSync + hand-rolled
1117
+ // window filter (which also never excluded sentinels, unlike the owner).
1118
+ const phaseDirCount = listMilestonePhaseDirs(phasesDir, { cwd }).value.length;
575
1119
  const wf = (config.workflow ?? {});
576
1120
  const result = {
577
1121
  researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'),
@@ -579,8 +1123,12 @@ function cmdInitNewMilestone(cwd, raw) {
579
1123
  roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'),
580
1124
  commit_docs: config.commit_docs,
581
1125
  research_enabled: wf['research'],
582
- current_milestone: milestone['version'],
583
- current_milestone_name: milestone['name'],
1126
+ // #3216 review Finding 2: `?? null` so an unresolved milestone still emits
1127
+ // the key with an explicit `null` rather than letting JSON.stringify drop
1128
+ // it — an omitted key reaches the prompt layer's `{current_milestone}`
1129
+ // placeholder as literal, un-substituted text.
1130
+ current_milestone: milestone['version'] ?? null,
1131
+ current_milestone_name: milestone['name'] ?? null,
584
1132
  latest_completed_milestone: latestCompleted?.version || null,
585
1133
  latest_completed_milestone_name: latestCompleted?.name || null,
586
1134
  phase_dir_count: phaseDirCount,
@@ -601,11 +1149,40 @@ function cmdInitNewMilestone(cwd, raw) {
601
1149
  research_dir: toPosixPath(node_path_1.default.join(planningRoot(cwd), 'research')),
602
1150
  milestones_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'MILESTONES.md')),
603
1151
  };
1152
+ // `state:flat-mode` (#2994): whether NO workstream is active — the inverse
1153
+ // of `state:workstream-active` (introduced for `cmdInitTransition` below).
1154
+ // `new-milestone.md`'s Step 4 Part A (milestone-state write) runs ONLY in
1155
+ // flat mode; a workstream's own `.planning/workstreams/<name>/STATE.md`/
1156
+ // `ROADMAP.md`/`REQUIREMENTS.md` already carry the milestone state, so
1157
+ // writing the shared `## Current Milestone` heading here would clobber it
1158
+ // (#2308). The `when=` grammar has no negation operator (ADR-1671:69), so
1159
+ // Part A's condition — "skip when a workstream IS active" — cannot be
1160
+ // expressed by negating `state:workstream-active` in the marker; a
1161
+ // SEPARATE, positively-phrased atom whose fact is the inverse is the
1162
+ // sanctioned resolution (same discipline as `state:chunked-mode` folding
1163
+ // an OR — never an operator in the grammar itself). Same authoritative
1164
+ // source as `cmdInitTransition`: `GSD_WORKSTREAM` env, falling back to the
1165
+ // stored active-workstream pointer (mirrors `cmdInitProgress`'s own
1166
+ // resolution above).
1167
+ //
1168
+ // #3579 root-cause fix: this is a read-only informational field (no write
1169
+ // follows), so use the non-mutating peek — getActiveWorkstream's self-heal
1170
+ // would otherwise silently delete a stale/invalid pointer as a side effect
1171
+ // of building a JSON report field, and (per #3579) could change what a
1172
+ // LATER resolution in the same process observes.
1173
+ const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
1174
+ const workstreamActive = !!resolvedWorkstream;
1175
+ const flatMode = !workstreamActive;
1176
+ // #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
1177
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'new-milestone', {
1178
+ workstreamActive,
1179
+ flatMode,
1180
+ });
604
1181
  output(withProjectRoot(cwd, result), raw);
605
1182
  }
606
- function cmdInitQuick(cwd, description, raw) {
1183
+ function cmdInitQuick(cwd, description, raw, options = {}) {
607
1184
  const config = loadConfig(cwd);
608
- const now = new Date();
1185
+ const now = new Date(clock_cjs_1.realClock.now());
609
1186
  const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null;
610
1187
  const yy = String(now.getFullYear()).slice(-2);
611
1188
  const mm = String(now.getMonth() + 1).padStart(2, '0');
@@ -645,6 +1222,21 @@ function cmdInitQuick(cwd, description, raw) {
645
1222
  roadmap_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
646
1223
  planning_exists: node_fs_1.default.existsSync(planningRoot(cwd)),
647
1224
  };
1225
+ // #2994: `--full` IMPLIES `--discuss`/`--research`/`--validate` — resolved to
1226
+ // ONE set of facts HERE, in fact computation, never in the `when=` grammar
1227
+ // itself (mirrors `state:chunked-mode`'s disjunction fold at
1228
+ // `buildSectionManifestField`'s `chunkedMode` computation above). The three
1229
+ // implied tokens are folded into the flags BEFORE `buildSectionManifestField`
1230
+ // builds its `InvocationFacts.flags` Set, so `discussion-phase`/`research-phase`/
1231
+ // `plan-checker-loop`/`quick-verification` (all gated on their own single
1232
+ // `flag:--discuss`/`flag:--research`/`flag:--validate` atom) include correctly
1233
+ // for a bare `/gsd:quick --full` invocation that never passed the individual
1234
+ // tokens — the grammar still sees exactly one atom per marker, no OR.
1235
+ const sectionManifestOptions = options['full']
1236
+ ? { ...options, discuss: true, research: true, validate: true }
1237
+ : options;
1238
+ // #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
1239
+ result['section_manifest'] = buildSectionManifestField(cwd, null, sectionManifestOptions, 'quick');
648
1240
  output(withProjectRoot(cwd, result), raw);
649
1241
  }
650
1242
  function cmdInitIngestDocs(cwd, raw) {
@@ -710,33 +1302,23 @@ function cmdInitVerifyWork(cwd, phase, raw) {
710
1302
  const config = loadConfig(cwd);
711
1303
  const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
712
1304
  let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
713
- if (phaseInfo?.['archived']) {
714
- const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
715
- if (roadmapPhase?.['found']) {
716
- phaseInfo = null;
717
- }
718
- }
719
- if (!phaseInfo) {
720
- const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
721
- if (roadmapPhase?.['found']) {
722
- const phaseName = roadmapPhase['phase_name'];
723
- phaseInfo = {
724
- found: true,
725
- directory: null,
726
- phase_number: roadmapPhase['phase_number'],
727
- phase_name: phaseName,
728
- phase_slug: phaseName
729
- ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
730
- : null,
731
- plans: [],
732
- summaries: [],
733
- incomplete_plans: [],
734
- has_research: false,
735
- has_context: false,
736
- has_verification: false,
737
- };
738
- }
739
- }
1305
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1306
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
1307
+ const phaseName = rp['phase_name'];
1308
+ return {
1309
+ found: true,
1310
+ directory: null,
1311
+ phase_number: rp['phase_number'],
1312
+ phase_name: phaseName,
1313
+ phase_slug: slugifyPhaseName(phaseName),
1314
+ plans: [],
1315
+ summaries: [],
1316
+ incomplete_plans: [],
1317
+ has_research: false,
1318
+ has_context: false,
1319
+ has_verification: false,
1320
+ };
1321
+ });
740
1322
  const phaseDir = phaseInfo?.['directory'] || null;
741
1323
  const planCount = phaseInfo?.['plans']?.length || 0;
742
1324
  const summaryCount = phaseInfo?.['summaries']?.length || 0;
@@ -746,6 +1328,7 @@ function cmdInitVerifyWork(cwd, phase, raw) {
746
1328
  policy: { requireVerification: true },
747
1329
  })
748
1330
  : null;
1331
+ const uiPhaseActive = detectUiPhaseActive(cwd, phaseInfo);
749
1332
  const result = {
750
1333
  planner_model: resolveModelInternal(cwd, 'gsd-planner'),
751
1334
  checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
@@ -767,7 +1350,227 @@ function cmdInitVerifyWork(cwd, phase, raw) {
767
1350
  uat_blockers: uatReport?.blockers ?? [],
768
1351
  ready_to_transition: completion.phase_complete && (uatReport?.passed ?? false),
769
1352
  },
1353
+ // #2994 (resolver-hoist-guard G5): hoisted `state:ui-phase-active` ground
1354
+ // truth (previously re-derived inline inside the automated_ui_verification
1355
+ // step body via its own `gsd_run loop render-hooks plan:pre --raw` call —
1356
+ // a circular, self-disabling resolver, since the section is only read
1357
+ // when this same fact is already true). Resolved once here, exposed so
1358
+ // the step body can consume it directly instead of recomputing it.
1359
+ ui_phase_active: uiPhaseActive,
1360
+ };
1361
+ // #2994 (Phase 6.3): additive, optional field — degrades to null, never throws.
1362
+ // phaseInfo is passed through directly (mirrors cmdInitExecutePhase / cmdInitPlanPhase)
1363
+ // so buildSectionManifestField's internal detectPhaseMvpMode call gets a real
1364
+ // phase_number/directory rather than permanently-false facts. uiPhaseActive is
1365
+ // computed once above (not re-derived here) and threaded through via overrides,
1366
+ // mirroring the fallow/git-create-tag hoist pattern.
1367
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, {}, 'verify-work', {
1368
+ uiPhaseActive,
1369
+ });
1370
+ output(withProjectRoot(cwd, result), raw);
1371
+ }
1372
+ /**
1373
+ * `code-review.md`'s dedicated init entry point (#2994, epic #1671 Phase
1374
+ * 6.3). `code-review.md` previously routed through the shared, 20+-caller
1375
+ * `init.phase-op` (`cmdInitPhaseOp` below), reading only 6 of its fields
1376
+ * (`phase_found`, `phase_dir`, `phase_number`, `phase_name`, `padded_phase`,
1377
+ * `commit_docs` — verified against the workflow's own "Parse from init
1378
+ * JSON" line). `cmdInitPhaseOp` is CRITICAL blast radius (179 dependents
1379
+ * across 24 processes per the #2994 dispatch) and is never modified for
1380
+ * this — this function resolves phase info itself via the SAME shared
1381
+ * primitives `cmdInitPhaseOp` calls (`guardedFindPhase`/
1382
+ * `guardedGetRoadmapPhase`, plus the shared `applyRoadmapFallback` archived/
1383
+ * not-found fallback also used by execute-phase, plan-phase, verify-work
1384
+ * and review — see `applyRoadmapFallback`'s own doc comment; `cmdInitPhaseOp`
1385
+ * is deliberately excluded from that shared helper), producing the identical
1386
+ * 6-field shape rather than a second, hand-maintained copy of
1387
+ * `cmdInitPhaseOp`'s full ~60-field bundle. See
1388
+ * `tests/init-code-review-parity.test.cjs` for the DEFECT.GENERATIVE-FIX
1389
+ * parity guard between the two.
1390
+ *
1391
+ * Two further facts are resolved and exposed here that `init.phase-op`
1392
+ * never carried: the fallow structural-pre-pass config gate
1393
+ * (`detectFallowConfig`, `state:fallow-enabled`) and the `--fix` flag
1394
+ * (folded into `options` so `buildSectionManifestField` picks it up as
1395
+ * `flag:--fix`).
1396
+ */
1397
+ function cmdInitCodeReview(cwd, phase, raw, options = {}) {
1398
+ const config = loadConfig(cwd);
1399
+ let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
1400
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1401
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
1402
+ const rpName = rp['phase_name'];
1403
+ return {
1404
+ found: true,
1405
+ directory: null,
1406
+ phase_number: rp['phase_number'],
1407
+ phase_name: rpName,
1408
+ phase_slug: slugifyPhaseName(rpName),
1409
+ };
1410
+ });
1411
+ const phaseDir = phaseInfo?.['directory'] || null;
1412
+ const phaseNumber = phaseInfo?.['phase_number'] || null;
1413
+ const phaseName = phaseInfo?.['phase_name'] || null;
1414
+ const fallow = detectFallowConfig(cwd);
1415
+ const result = {
1416
+ commit_docs: config.commit_docs,
1417
+ phase_found: !!phaseInfo,
1418
+ // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
1419
+ phase_dir: phaseDir ? toPosixPath(node_path_1.default.join(cwd, phaseDir)) : null,
1420
+ phase_number: phaseNumber,
1421
+ phase_name: phaseName,
1422
+ padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
1423
+ // #2994: hoisted fallow config-gate resolution (previously re-derived
1424
+ // inline inside code-review.md's structural_pre_pass step body — a
1425
+ // circular self-disabling gate now resolved once here at init time).
1426
+ fallow_enabled: fallow.enabled,
1427
+ fallow_scope: fallow.scope,
1428
+ fallow_profile: fallow.profile,
1429
+ fallow_mcp: fallow.mcp,
1430
+ fallow_max_crap: fallow.maxCrap,
770
1431
  };
1432
+ // #2994 (Phase 6.3): additive, optional field — degrades to null, never throws.
1433
+ const sectionManifestOptions = {
1434
+ ...options,
1435
+ fix: options['fix'] || undefined,
1436
+ };
1437
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, sectionManifestOptions, 'code-review', {
1438
+ fallowEnabled: fallow.enabled,
1439
+ });
1440
+ output(withProjectRoot(cwd, result), raw);
1441
+ }
1442
+ /**
1443
+ * `review.md`'s dedicated init entry point (#2994, epic #1671 Phase 6.3
1444
+ * amendment). `review.md` previously routed through the shared, 20+-caller
1445
+ * `init.phase-op` (`cmdInitPhaseOp` below), reading only 3 of its fields
1446
+ * (`phase_dir`, `phase_number`, `padded_phase` — verified against the
1447
+ * workflow's own "Read from init" line in `gather_context`). `cmdInitPhaseOp`
1448
+ * is CRITICAL blast radius (179 dependents across 24 processes) and is never
1449
+ * modified for this — this function resolves phase info itself via the SAME
1450
+ * shared primitives `cmdInitPhaseOp` calls (`guardedFindPhase`/
1451
+ * `guardedGetRoadmapPhase`), plus the shared `applyRoadmapFallback`
1452
+ * archived/not-found fallback (see its own doc comment), producing the
1453
+ * identical 3-field shape rather than a second, hand-maintained copy of
1454
+ * `cmdInitPhaseOp`'s full ~60-field bundle.
1455
+ *
1456
+ * One further fact is resolved and exposed here that `init.phase-op` never
1457
+ * carried: whether reviewer instances are configured
1458
+ * (`.planning/config.json`'s `review.reviewer_instances`, present AND
1459
+ * non-empty — `state:reviewer-instances-configured`), reusing
1460
+ * `readConfigJsonValue` (added for `detectFallowConfig`) rather than a
1461
+ * second, divergence-prone config reader (DEFECT.GENERATIVE-FIX).
1462
+ */
1463
+ function cmdInitReview(cwd, phase, raw, options = {}) {
1464
+ const config = loadConfig(cwd);
1465
+ let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
1466
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1467
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => ({
1468
+ found: true,
1469
+ directory: null,
1470
+ phase_number: rp['phase_number'],
1471
+ phase_name: rp['phase_name'],
1472
+ }));
1473
+ const phaseDir = phaseInfo?.['directory'] || null;
1474
+ const phaseNumber = phaseInfo?.['phase_number'] || null;
1475
+ // #2994: `state:reviewer-instances-configured` ground truth — present AND
1476
+ // non-empty `review.reviewer_instances` object. A missing key, a non-object
1477
+ // value, or an empty object all resolve to `false` (fail-closed, matching
1478
+ // the workflow's own pre-hoist prose gate — "Unconfigured -> default path
1479
+ // unchanged").
1480
+ const rawReviewerInstances = readConfigJsonValue(cwd, ['review', 'reviewer_instances']);
1481
+ const reviewerInstancesConfigured = rawReviewerInstances !== null &&
1482
+ typeof rawReviewerInstances === 'object' &&
1483
+ !Array.isArray(rawReviewerInstances) &&
1484
+ Object.keys(rawReviewerInstances).length > 0;
1485
+ const result = {
1486
+ // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
1487
+ phase_dir: phaseDir ? toPosixPath(node_path_1.default.join(cwd, phaseDir)) : null,
1488
+ phase_number: phaseNumber,
1489
+ padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
1490
+ };
1491
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options, 'review', {
1492
+ reviewerInstancesConfigured,
1493
+ });
1494
+ output(withProjectRoot(cwd, result), raw);
1495
+ }
1496
+ /**
1497
+ * `discuss-phase-assumptions.md`'s dedicated init entry point (#2994, epic
1498
+ * #1671 Phase 6.3 amendment). Previously routed through the shared,
1499
+ * 20+-caller `init.phase-op` (`cmdInitPhaseOp` below), reading 14 of its
1500
+ * fields (`commit_docs`, `phase_found`, `phase_dir`, `phase_number`,
1501
+ * `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`,
1502
+ * `has_plans`, `has_verification`, `plan_count`, `roadmap_exists`,
1503
+ * `planning_exists` — verified against the workflow's own "Parse JSON for"
1504
+ * line). `cmdInitPhaseOp` is CRITICAL blast radius (179 dependents across 24
1505
+ * processes) and is never modified for this — this function resolves phase
1506
+ * info itself via the SAME shared primitives `cmdInitPhaseOp` calls
1507
+ * (`guardedFindPhase`/`guardedGetRoadmapPhase`), plus the shared
1508
+ * `applyRoadmapFallback` archived/not-found fallback (see its own doc
1509
+ * comment) producing the identical fallback shape (`plans: []`,
1510
+ * `has_research: false`, `has_context: false`, `has_verification: false`)
1511
+ * rather than a second, hand-maintained copy of `cmdInitPhaseOp`'s full
1512
+ * ~60-field bundle.
1513
+ *
1514
+ * One further fact is resolved and exposed here that `init.phase-op` never
1515
+ * carried: `state:auto-advance-active` — the workflow's own `auto_advance`
1516
+ * step resolves `--auto` OR a consolidated `check auto-mode --pick active`
1517
+ * fact (itself `workflow._auto_chain_active` OR `workflow.auto_advance`) via
1518
+ * a runtime `gsd_run` call; that identical disjunction is folded into ONE
1519
+ * boolean FACT here (same discipline as `state:chunked-mode` /
1520
+ * `state:plan-strategy-converge`), exposed as `auto_advance_active`.
1521
+ */
1522
+ function cmdInitDiscussPhaseAssumptions(cwd, phase, raw, options = {}) {
1523
+ const config = loadConfig(cwd);
1524
+ let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
1525
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1526
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
1527
+ const rpName = rp['phase_name'];
1528
+ return {
1529
+ found: true,
1530
+ directory: null,
1531
+ phase_number: rp['phase_number'],
1532
+ phase_name: rpName,
1533
+ phase_slug: slugifyPhaseName(rpName),
1534
+ plans: [],
1535
+ has_research: false,
1536
+ has_context: false,
1537
+ has_verification: false,
1538
+ };
1539
+ });
1540
+ const phaseDir = phaseInfo?.['directory'] || null;
1541
+ const phaseNumber = phaseInfo?.['phase_number'] || null;
1542
+ const phaseName = phaseInfo?.['phase_name'] || null;
1543
+ // #2994: mirrors discuss-phase-assumptions.md's own auto_advance step
1544
+ // resolver — `--auto` flag OR the consolidated `check auto-mode --pick
1545
+ // active` fact (workflow._auto_chain_active OR workflow.auto_advance).
1546
+ const autoAdvanceActive = options['auto'] === true ||
1547
+ readConfigJsonBoolean(cwd, ['workflow', '_auto_chain_active']) ||
1548
+ readConfigJsonBoolean(cwd, ['workflow', 'auto_advance']);
1549
+ const result = {
1550
+ commit_docs: config.commit_docs,
1551
+ phase_found: !!phaseInfo,
1552
+ // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
1553
+ phase_dir: phaseDir ? toPosixPath(node_path_1.default.join(cwd, phaseDir)) : null,
1554
+ phase_number: phaseNumber,
1555
+ phase_name: phaseName,
1556
+ phase_slug: phaseInfo?.['phase_slug'] || null,
1557
+ padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
1558
+ has_research: phaseInfo?.['has_research'] || false,
1559
+ has_context: phaseInfo?.['has_context'] || false,
1560
+ has_plans: (phaseInfo?.['plans']?.length || 0) > 0,
1561
+ has_verification: phaseInfo?.['has_verification'] || false,
1562
+ plan_count: phaseInfo?.['plans']?.length || 0,
1563
+ roadmap_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
1564
+ planning_exists: node_fs_1.default.existsSync(planningDir(cwd)),
1565
+ };
1566
+ // #2994 (Phase 6.3): additive, optional field — degrades to null, never throws.
1567
+ const sectionManifestOptions = {
1568
+ ...options,
1569
+ auto: options['auto'] || undefined,
1570
+ };
1571
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, sectionManifestOptions, 'discuss-phase-assumptions', {
1572
+ autoAdvanceActive,
1573
+ });
771
1574
  output(withProjectRoot(cwd, result), raw);
772
1575
  }
773
1576
  function cmdInitPhaseOp(cwd, phase, raw) {
@@ -845,6 +1648,11 @@ function cmdInitPhaseOp(cwd, phase, raw) {
845
1648
  expectedPhaseDir = toPosixPath(node_path_1.default.join(planningPaths(cwd).phases, dirName));
846
1649
  }
847
1650
  }
1651
+ // #3188: see cmdInitExecutePhase — null when absent, parity with the
1652
+ // conditional sibling fields in this same result object.
1653
+ const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1654
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1655
+ const requirementsPath = node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md');
848
1656
  const result = {
849
1657
  commit_docs: config.commit_docs,
850
1658
  brave_search: typeof config.brave_search === 'string'
@@ -873,31 +1681,59 @@ function cmdInitPhaseOp(cwd, phase, raw) {
873
1681
  roadmap_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
874
1682
  planning_exists: node_fs_1.default.existsSync(planningDir(cwd)),
875
1683
  // #2376: absolute — see comment on phase_dir above.
876
- state_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'STATE.md')),
877
- roadmap_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
878
- requirements_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md')),
1684
+ // #3188: null when the file is absent (parity with context_path/research_path).
1685
+ state_path: node_fs_1.default.existsSync(statePath) ? toPosixPath(statePath) : null,
1686
+ roadmap_path: node_fs_1.default.existsSync(roadmapPath) ? toPosixPath(roadmapPath) : null,
1687
+ requirements_path: node_fs_1.default.existsSync(requirementsPath) ? toPosixPath(requirementsPath) : null,
879
1688
  };
880
1689
  if (phaseInfo?.['directory']) {
881
1690
  const phaseDirFull = node_path_1.default.join(cwd, phaseInfo['directory']);
882
1691
  try {
883
1692
  const files = node_fs_1.default.readdirSync(phaseDirFull);
884
- const contextFile = findContextMdIn(phaseDirFull);
1693
+ const phaseDirName = node_path_1.default.basename(phaseDirFull);
1694
+ // #3511 BLOCKER-3: see the parallel site above — scope before any bare
1695
+ // `.find()` predicate so a misfiled cross-phase artifact cannot win.
1696
+ const scopedFiles = scopeToPhase(files, phaseDirName);
1697
+ const contextFile = findContextMdIn(scopedFiles);
885
1698
  if (contextFile) {
886
1699
  result['context_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, contextFile));
887
1700
  }
888
- const researchFile = files.find((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
1701
+ const researchFile = scopedFiles.find((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
889
1702
  if (researchFile) {
890
1703
  result['research_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, researchFile));
891
1704
  }
892
- const verificationFile = files.find((f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
1705
+ // #3473 F2: routed through the shared resolver — readdir order is
1706
+ // filesystem-dependent, so the prior hand-rolled `.find()` could pick
1707
+ // either file when a phase held both a canonical report and an ad-hoc
1708
+ // `-CORRECTION-VERIFICATION.md` worksheet (#3357).
1709
+ // #3492: pin selection to THIS phase's own token so a stray cross-phase
1710
+ // or sentinel-numbered canonically-shaped file cannot outrank this
1711
+ // phase's own (possibly non-canonical) report.
1712
+ const phaseToken = extractPhaseToken(phaseDirName);
1713
+ const verificationFile = resolveVerificationFile(files, {
1714
+ allowBare: true,
1715
+ phaseToken,
1716
+ phaseDirName,
1717
+ });
893
1718
  if (verificationFile) {
894
1719
  result['verification_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, verificationFile));
895
1720
  }
896
- const uatFile = files.find((f) => f.endsWith('-UAT.md') || f === 'UAT.md');
1721
+ // #3518: routed through the shared UAT resolver — the prior hand-rolled
1722
+ // `.find()` over unsorted readdir order had no phase check and no
1723
+ // ordering, so a stray cross-phase 02-UAT.md could become this phase's
1724
+ // uat_path, filesystem-dependently. Pinned to this phase's own token
1725
+ // (same rule as verification_path above), and phase-scoped via
1726
+ // phaseDirName (#3511) so the alphabetically-first fallback tier also
1727
+ // excludes cross-phase strays.
1728
+ const uatFile = resolveUatFile(files, {
1729
+ allowBare: true,
1730
+ phaseToken,
1731
+ phaseDirName,
1732
+ });
897
1733
  if (uatFile) {
898
1734
  result['uat_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, uatFile));
899
1735
  }
900
- const reviewsFile = files.find((f) => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md');
1736
+ const reviewsFile = scopedFiles.find((f) => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md');
901
1737
  if (reviewsFile) {
902
1738
  result['reviews_path'] = toPosixPath(node_path_1.default.join(phaseDirFull, reviewsFile));
903
1739
  }
@@ -966,7 +1802,7 @@ function cmdInitTodos(cwd, area, raw) {
966
1802
  }
967
1803
  function cmdInitMilestoneOp(cwd, raw) {
968
1804
  const config = loadConfig(cwd);
969
- const milestone = getMilestoneInfo(cwd);
1805
+ const milestone = milestoneRecord(cwd);
970
1806
  let phaseCount = 0;
971
1807
  let completedPhases = 0;
972
1808
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
@@ -979,7 +1815,8 @@ function cmdInitMilestoneOp(cwd, raw) {
979
1815
  const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
980
1816
  let m;
981
1817
  while ((m = phasePattern.exec(currentSection)) !== null) {
982
- if (/^999(?:\.|$)/.test(m[1]))
1818
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1819
+ if (isSentinelPhaseId(m[1]))
983
1820
  continue;
984
1821
  roadmapPhaseNumbers.push(m[1]);
985
1822
  }
@@ -1024,8 +1861,12 @@ function cmdInitMilestoneOp(cwd, raw) {
1024
1861
  }
1025
1862
  else {
1026
1863
  try {
1027
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
1028
- const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
1864
+ // #3185 (ADR-3180 Decision 1): the ROADMAP heading scan above found no
1865
+ // current-milestone phase headings — fall back to asking the canonical
1866
+ // owner "which phase directories belong to the current milestone"
1867
+ // directly, instead of a hand-rolled readdirSync over every directory
1868
+ // on disk (which also never excluded sentinels, unlike the owner).
1869
+ const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
1029
1870
  phaseCount = dirs.length;
1030
1871
  for (const dir of dirs) {
1031
1872
  try {
@@ -1055,8 +1896,12 @@ function cmdInitMilestoneOp(cwd, raw) {
1055
1896
  }
1056
1897
  const result = {
1057
1898
  commit_docs: config.commit_docs,
1058
- milestone_version: milestone['version'],
1059
- milestone_name: milestone['name'],
1899
+ // #3216 review Finding 2: `?? null` so an unresolved milestone still emits
1900
+ // the key with an explicit `null` rather than letting JSON.stringify drop
1901
+ // it — an omitted key reaches the prompt layer's `{milestone_version}`
1902
+ // placeholder as literal, un-substituted text.
1903
+ milestone_version: milestone['version'] ?? null,
1904
+ milestone_name: milestone['name'] ?? null,
1060
1905
  milestone_slug: generateSlugInternal(milestone['name']),
1061
1906
  phase_count: phaseCount,
1062
1907
  completed_phases: completedPhases,
@@ -1100,7 +1945,7 @@ function cmdInitMapCodebase(cwd, raw) {
1100
1945
  }
1101
1946
  function cmdInitManager(cwd, raw) {
1102
1947
  const config = loadConfig(cwd);
1103
- const milestone = getMilestoneInfo(cwd);
1948
+ const milestone = milestoneRecord(cwd);
1104
1949
  const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1105
1950
  const paths = planningPaths(cwd);
1106
1951
  if (!node_fs_1.default.existsSync(paths.roadmap)) {
@@ -1112,18 +1957,12 @@ function cmdInitManager(cwd, raw) {
1112
1957
  const rawContent = node_fs_1.default.readFileSync(paths.roadmap, 'utf-8');
1113
1958
  const content = extractCurrentMilestone(rawContent, cwd);
1114
1959
  const phasesDir = paths.phases;
1115
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
1116
- const _phaseDirEntries = (() => {
1117
- try {
1118
- return node_fs_1.default
1119
- .readdirSync(phasesDir, { withFileTypes: true })
1120
- .filter((e) => e.isDirectory())
1121
- .map((e) => e.name);
1122
- }
1123
- catch {
1124
- return [];
1125
- }
1126
- })();
1960
+ // #3185 (ADR-3180 Decision 1): "which phase directories belong to the
1961
+ // CURRENT milestone" is the scoped question listMilestonePhaseDirs owns —
1962
+ // routed through it instead of a hand-rolled readdirSync + a separate
1963
+ // getMilestonePhaseFilter window check (which also never excluded
1964
+ // sentinels, unlike the owner).
1965
+ const _phaseDirEntries = listMilestonePhaseDirs(phasesDir, { cwd }).value;
1127
1966
  const _checkboxStates = new Map();
1128
1967
  const _cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
1129
1968
  let _cbMatch;
@@ -1158,16 +1997,24 @@ function cmdInitManager(cwd, raw) {
1158
1997
  let isActive = false;
1159
1998
  let completion = buildPhaseCompletionProjection(cwd, phaseNum, null, planCount, summaryCount, _slashRuntime);
1160
1999
  try {
1161
- const dirs = _phaseDirEntries.filter(isDirInMilestone);
1162
- const dirMatch = dirs.find((d) => phaseTokenMatches(d, normalized));
2000
+ // #3185 (ADR-3180 Decision 2) moved this lookup off the
2001
+ // milestone-scoped set and onto the physical one; that scope choice is
2002
+ // kept. Only the matcher is this PR's: matchPhaseDirs resolves
2003
+ // digit-leading directory names the token predicate cannot (#2528).
2004
+ const dirMatch = matchPhaseDirs(_phaseDirEntries, normalized).matches[0];
1163
2005
  if (dirMatch) {
1164
2006
  const fullDir = node_path_1.default.join(phasesDir, dirMatch);
1165
2007
  const phaseDirRel = toPosixPath(node_path_1.default.relative(cwd, fullDir));
1166
2008
  const phaseFiles = node_fs_1.default.readdirSync(fullDir);
1167
2009
  planCount = listPhasePlanFiles(fullDir).length;
1168
2010
  summaryCount = listPhaseSummaryFiles(fullDir).length;
1169
- hasContext = findContextMdIn(fullDir) !== null;
1170
- hasResearch = phaseFiles.some((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
2011
+ // #3511-class: scope the raw listing to THIS phase's own artifacts
2012
+ // before the hasContext/hasResearch predicates run, so a stray
2013
+ // cross-phase `-CONTEXT.md`/`-RESEARCH.md` sitting in this directory
2014
+ // cannot win this phase's lookup.
2015
+ const scopedFiles = scopeToPhase(phaseFiles, dirMatch);
2016
+ hasContext = findContextMdIn(scopedFiles) !== null;
2017
+ hasResearch = scopedFiles.some((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
1171
2018
  completion = buildPhaseCompletionProjection(cwd, phaseNum, phaseDirRel, planCount, summaryCount, _slashRuntime);
1172
2019
  if (completion.phase_complete)
1173
2020
  diskStatus = 'complete';
@@ -1183,7 +2030,7 @@ function cmdInitManager(cwd, raw) {
1183
2030
  diskStatus = 'discussed';
1184
2031
  else
1185
2032
  diskStatus = 'empty';
1186
- const nowMs = Date.now();
2033
+ const nowMs = clock_cjs_1.realClock.now();
1187
2034
  let newestMtime = 0;
1188
2035
  for (const f of phaseFiles) {
1189
2036
  try {
@@ -1204,10 +2051,20 @@ function cmdInitManager(cwd, raw) {
1204
2051
  catch {
1205
2052
  /* intentionally empty */
1206
2053
  }
2054
+ // ADR-3180 §7.4 (disk-strict, #2957, maintainer decision 2026-08-08):
2055
+ // `roadmapComplete` is reported below as metadata only — it carries NO
2056
+ // machine authority over `diskStatus`. The #3033 checkbox override that
2057
+ // used to live here (treating a zero-plan phase as complete whenever the
2058
+ // ROADMAP checkbox was ticked, layered on top of
2059
+ // buildPhaseCompletionProjection's own output) is DELETED, not
2060
+ // generalized: `diskStatus` now comes entirely from `completion`, which
2061
+ // already routes through the canonical owner (`isPhaseComplete`) and
2062
+ // itself resolves a zero-plan phase as complete whenever a passing
2063
+ // `*-VERIFICATION.md` exists (#3168) — with no dependency on the
2064
+ // checkbox. A zero-plan phase whose completion previously relied SOLELY
2065
+ // on a ticked checkbox (no passing verification) now reports incomplete;
2066
+ // this is the deliberate Tier-2 break (ADR-3180 §7.4 Decision 3).
1207
2067
  const roadmapComplete = _checkboxStates.get(phaseNum) || false;
1208
- if (roadmapComplete && completion.phase_complete && diskStatus !== 'complete') {
1209
- diskStatus = 'complete';
1210
- }
1211
2068
  phases.push({
1212
2069
  number: phaseNum,
1213
2070
  name: phaseName,
@@ -1312,7 +2169,8 @@ function cmdInitManager(cwd, raw) {
1312
2169
  for (const phase of phases) {
1313
2170
  if (phase['disk_status'] === 'complete')
1314
2171
  continue;
1315
- if (/^999(?:\.|$)/.test(phase['number']))
2172
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2173
+ if (isSentinelPhaseId(phase['number']))
1316
2174
  continue;
1317
2175
  if (phase['disk_status'] === 'executed') {
1318
2176
  recommendedActions.push({
@@ -1366,7 +2224,8 @@ function cmdInitManager(cwd, raw) {
1366
2224
  }
1367
2225
  return true;
1368
2226
  });
1369
- const nonBacklogPhases = phases.filter((p) => !/^999(?:\.|$)/.test(p['number']));
2227
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2228
+ const nonBacklogPhases = phases.filter((p) => !isSentinelPhaseId(p['number']));
1370
2229
  const completedCount = nonBacklogPhases.filter((p) => p['phase_complete'] === true).length;
1371
2230
  const sanitizeFlags = (rawVal) => {
1372
2231
  const val = typeof rawVal === 'string' ? rawVal : '';
@@ -1389,8 +2248,12 @@ function cmdInitManager(cwd, raw) {
1389
2248
  execute: sanitizeFlags(mgrFlags?.['execute']),
1390
2249
  };
1391
2250
  const result = {
1392
- milestone_version: milestone['version'],
1393
- milestone_name: milestone['name'],
2251
+ // #3216 review Finding 2: `?? null` so an unresolved milestone still emits
2252
+ // the key with an explicit `null` rather than letting JSON.stringify drop
2253
+ // it — an omitted key reaches the prompt layer's `{milestone_version}`
2254
+ // placeholder as literal, un-substituted text.
2255
+ milestone_version: milestone['version'] ?? null,
2256
+ milestone_name: milestone['name'] ?? null,
1394
2257
  phases,
1395
2258
  phase_count: phases.length,
1396
2259
  completed_count: completedCount,
@@ -1405,7 +2268,224 @@ function cmdInitManager(cwd, raw) {
1405
2268
  };
1406
2269
  output(withProjectRoot(cwd, result), raw);
1407
2270
  }
1408
- function cmdInitProgress(cwd, raw) {
2271
+ /**
2272
+ * `complete-milestone.md`'s dedicated init entry point (#2994, epic #1671
2273
+ * Phase 6.3). Additive alongside the workflow's existing `init.manager`
2274
+ * (readiness/phase-projection, `cmdInitManager` above — CRITICAL blast
2275
+ * radius, never modified) and `init.execute-phase` (branching-strategy
2276
+ * fields) calls; `cmdInitCompleteMilestone` carries NO phase-listing logic
2277
+ * of its own to delegate — its only job is the `git.create_tag` config-gate
2278
+ * fact the `git_tag` step's `<config-check>` sub-tag used to re-derive
2279
+ * inline (gating the step's own inclusion on a fact only that step
2280
+ * computed), now hoisted here and exposed as `git_create_tag`, plus the
2281
+ * `section_manifest` field neither `init.manager` nor `init.execute-phase`
2282
+ * carries.
2283
+ */
2284
+ function cmdInitCompleteMilestone(cwd, raw, options = {}) {
2285
+ const gitCreateTag = detectGitCreateTag(cwd);
2286
+ const result = {
2287
+ // #2994: hoisted from complete-milestone.md's git_tag step
2288
+ // <config-check> resolver (git.create_tag, fail-open default true).
2289
+ git_create_tag: gitCreateTag,
2290
+ };
2291
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'complete-milestone', {
2292
+ gitCreateTag,
2293
+ });
2294
+ output(withProjectRoot(cwd, result), raw);
2295
+ }
2296
+ /**
2297
+ * `autonomous.md`'s dedicated init entry point (#2994, epic #1671 Phase
2298
+ * 6.3). Additive alongside the workflow's existing `init.milestone-op`
2299
+ * (`cmdInitMilestoneOp`), `init.manager` (`cmdInitManager`), and
2300
+ * `init.phase-op` (`cmdInitPhaseOp`) calls — all three are CRITICAL blast
2301
+ * radius (179 dependents across 24 processes) and are never modified for
2302
+ * this; `autonomous.md` keeps every one of those calls exactly as it had
2303
+ * them. `cmdInitAutonomous` carries NO phase-listing logic of its own to
2304
+ * delegate — its only job is the `PLAN_STRATEGY` disjunction the workflow's
2305
+ * own bash resolver (`PLAN_STRATEGY="converge"` on `--converge` OR
2306
+ * `--cross-ai`) already computes at the top of the `initialize` step, now
2307
+ * mirrored here as a single boolean FACT (same discipline as
2308
+ * `state:chunked-mode`/`state:ui-phase-active`: the disjunction is resolved
2309
+ * ONCE, in fact computation, never in the `when=` grammar), exposed as
2310
+ * `plan_strategy_converge`, plus the `section_manifest` field none of the
2311
+ * three existing calls carries.
2312
+ */
2313
+ function cmdInitAutonomous(cwd, raw, options = {}) {
2314
+ const planStrategyConverge = options['converge'] === true || options['cross-ai'] === true;
2315
+ const result = {
2316
+ // #2994: mirrors autonomous.md's own PLAN_STRATEGY resolver
2317
+ // (--converge OR its documented alias --cross-ai).
2318
+ plan_strategy_converge: planStrategyConverge,
2319
+ };
2320
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'autonomous', {
2321
+ planStrategyConverge,
2322
+ });
2323
+ output(withProjectRoot(cwd, result), raw);
2324
+ }
2325
+ /**
2326
+ * `docs-update.md`'s dedicated init entry point (#2994, epic #1671 Phase
2327
+ * 6.3 — final slice). `docs-update.md` previously carried NO `gsd_run query
2328
+ * init.*` call at all: its own `docs-init` command (`cmdDocsInit`,
2329
+ * src/docs.cts) is a SEPARATE, pre-existing entry point outside this
2330
+ * `init.*` family and is left untouched here. This function's only job is
2331
+ * the `section_manifest` field neither `docs-init` nor any other call
2332
+ * carries, gating `docs-update.md`'s `dispatch-monorepo-packages` section.
2333
+ *
2334
+ * `state:is-monorepo` ground truth: the project's monorepo workspaces list
2335
+ * is non-empty — reuses `detectMonorepoWorkspaces` (src/docs.cts, exported
2336
+ * for this purpose) rather than a second, divergence-prone workspace-glob
2337
+ * scan (DEFECT.GENERATIVE-FIX dual surface); this is the SAME detector that
2338
+ * already backs `docs-init`'s own `monorepo_workspaces` field.
2339
+ */
2340
+ function cmdInitDocsUpdate(cwd, raw, options = {}) {
2341
+ const isMonorepo = detectMonorepoWorkspaces(cwd).length > 0;
2342
+ const result = {};
2343
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'docs-update', {
2344
+ isMonorepo,
2345
+ });
2346
+ output(withProjectRoot(cwd, result), raw);
2347
+ }
2348
+ /**
2349
+ * `update.md`'s dedicated init entry point (#2994, epic #1671 Phase 6.3 —
2350
+ * final slice). `update.md` previously carried NO `gsd_run query init.*`
2351
+ * call at all: it resolves `gsd-tools.cjs` itself (its own bespoke
2352
+ * `PREFERRED_CONFIG_DIR`/`PREFERRED_RUNTIME`-aware `$GSD_TOOLS` cascade,
2353
+ * `update.md` ~lines 13-45) because the update workflow must run BEFORE any
2354
+ * install can be assumed resolvable — the canonical launcher preamble's
2355
+ * fixed candidate list is not a substitute for that cascade, and both
2356
+ * resolutions assign the identical `$GSD_TOOLS` shell variable, so copying
2357
+ * the canonical preamble in ADDITION to the existing cascade would silently
2358
+ * clobber the value `backup_custom_files`/`restore_custom_files` (later
2359
+ * steps) still depend on. This function is invoked via that ALREADY
2360
+ * resolved `$GSD_TOOLS`, not a redundant `gsd_run()` shell function.
2361
+ *
2362
+ * `state:next-channel` ground truth: `--next` OR its documented alias
2363
+ * `--rc` (same disjunction-to-one-boolean discipline as
2364
+ * `state:chunked-mode`/`state:plan-strategy-converge`). This DELIBERATELY
2365
+ * does not replace `update.md`'s own `parse_update_channel` case-statement
2366
+ * (`TAG="next"`/`TAG="latest"`) — issue #815's regression test
2367
+ * (`tests/update-workflow.test.cjs`) asserts that literal
2368
+ * case-statement text stays in `update.md` verbatim (the npm dist-tag
2369
+ * selection has to run in the workflow's own shell before any `gsd_run`
2370
+ * round-trip), so `next_channel` exists purely to gate the `channel-banner`
2371
+ * section's admission — a parallel, consistent-but-not-replacing
2372
+ * resolution of the same flags.
2373
+ */
2374
+ function cmdInitUpdate(cwd, raw, options = {}) {
2375
+ const nextChannel = options['next'] === true || options['rc'] === true;
2376
+ const result = {
2377
+ next_channel: nextChannel,
2378
+ };
2379
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'update', {
2380
+ nextChannel,
2381
+ });
2382
+ output(withProjectRoot(cwd, result), raw);
2383
+ }
2384
+ /**
2385
+ * `transition.md`'s dedicated init entry point (#2994, epic #1671 Phase
2386
+ * 6.3 — final slice). `transition.md` is an internal workflow (no
2387
+ * user-facing `/gsd-transition` command) that previously carried NO
2388
+ * `gsd_run query init.*` call at all; it already establishes `gsd_run()`
2389
+ * via the canonical launcher preamble in its `update_roadmap_and_state`
2390
+ * step (before this call's insertion point in `offer_next_phase`), so no
2391
+ * second preamble copy is needed in the host file.
2392
+ *
2393
+ * `state:workstream-active` ground truth: a workstream is active — resolved
2394
+ * via `GSD_WORKSTREAM` env, falling back to the stored active-workstream
2395
+ * pointer (mirrors `cmdInitProgress`'s own `_resolvedWorkstream` resolution
2396
+ * above, the established authoritative source for "is a workstream active"
2397
+ * in this file).
2398
+ *
2399
+ * `other_active_workstreams` hoists the resolver-in-body hazard out of
2400
+ * `transition.md`'s `workstream-collision-check` step: that step's body
2401
+ * previously re-derived this via an inline `gsd_run query workstream.list
2402
+ * --raw` call gated on the identical `if [ -n "$GSD_WORKSTREAM" ]`
2403
+ * condition that now backs this section's OWN admission — resolving it here
2404
+ * instead reuses `getOtherActiveWorkstreamInventories` (src/workstream-
2405
+ * inventory.cts), the SAME primitive `workstream.list` itself calls
2406
+ * (`cmdWorkstreamList`, src/workstream.cts), pre-filtered exactly as the
2407
+ * step's own prose described (excludes the current workstream and any
2408
+ * workstream whose status contains "milestone complete" or "archived",
2409
+ * case-insensitively — `isCompletedInventory`), so the step body becomes a
2410
+ * pure JSON consumer with no `gsd_run` call of its own.
2411
+ */
2412
+ function cmdInitTransition(cwd, raw, options = {}) {
2413
+ // #3579 root-cause fix: read-only informational field — peek, don't
2414
+ // self-heal (see cmdInitNewMilestone's identical rationale above).
2415
+ const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
2416
+ const workstreamActive = !!resolvedWorkstream;
2417
+ const result = {
2418
+ other_active_workstreams: workstreamActive
2419
+ ? getOtherActiveWorkstreamInventories(cwd, resolvedWorkstream).map((inv) => ({
2420
+ name: inv.name,
2421
+ status: inv.status,
2422
+ }))
2423
+ : [],
2424
+ };
2425
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'transition', {
2426
+ workstreamActive,
2427
+ });
2428
+ output(withProjectRoot(cwd, result), raw);
2429
+ }
2430
+ /**
2431
+ * `debug.md`'s dedicated init entry point (#3149; prerequisite for #3128).
2432
+ * `debug.md` previously carried NO `gsd_run query init.*` call at all — it made
2433
+ * THREE separate round-trips instead: `state.load` (for `commit_docs`,
2434
+ * `config.response_language` and `debug_dir`), `resolve-model gsd-debugger
2435
+ * --pick model`, and `config-get workflow.tdd_mode --raw`. Because no
2436
+ * debug-scoped fact was computed at any entry point, a `when=` atom naming one
2437
+ * would have evaluated FALSE forever — ADR-1671's admission gate (2) and the
2438
+ * silent-exclusion bug it exists to prevent (`docs/adr/1671-…:122-131`).
2439
+ *
2440
+ * Every field is resolved through the SAME primitive the call it replaces used,
2441
+ * never a second hand-maintained copy (DEFECT.GENERATIVE-FIX):
2442
+ *
2443
+ * - `commit_docs` — `loadConfig`, the same loader `cmdStateLoad` calls.
2444
+ * - `response_language` — NOT read here: `withProjectRoot` already injects it
2445
+ * when configured (#2402), which is also the shape sibling init bundles use.
2446
+ * It is absent, not null, when unset.
2447
+ * - `debug_dir` — `planningPaths(cwd).debug`, the SAME expression `cmdStateLoad`
2448
+ * now uses; the `debug` field was added to `PlanningPaths` (#3149) so the
2449
+ * location has one source instead of two kept in sync by hand.
2450
+ * - `debugger_model` — `resolveModelInternal`, which IS what `query
2451
+ * resolve-model --pick model` returns (`cmdResolveModel`, src/commands.cts).
2452
+ * - `tdd_mode` — the `Boolean(wf['tdd_mode'])` idiom `cmdInitExecutePhase` and
2453
+ * `cmdInitPlanPhase` already use. `/gsd:debug` has no `--tdd` flag, so the
2454
+ * sibling handlers' `options['tdd'] ||` disjunct is deliberately omitted
2455
+ * rather than carried as a phantom.
2456
+ *
2457
+ * `state.load` is deliberately NOT narrowed — see the note beside its own
2458
+ * `debug_dir` field. This handler is purely additive alongside it.
2459
+ *
2460
+ * `diagnose` is the one flag `/gsd:debug` already documents. Exposing it as a
2461
+ * top-level fact follows `cmdInitUpdate`'s `next_channel` and
2462
+ * `cmdInitAutonomous`'s `plan_strategy_converge` precedent, and is what makes
2463
+ * the router's flag forwarding observable. No `when=` atom consumes it yet:
2464
+ * admission gate (1) — a consuming section of at least 400 bytes — is #3128's
2465
+ * to satisfy, and shipping the atom before its section is the same
2466
+ * silent-exclusion bug from the other direction.
2467
+ */
2468
+ function cmdInitDebug(cwd, raw, options = {}) {
2469
+ const config = loadConfig(cwd);
2470
+ const wf = (config.workflow ?? {});
2471
+ const result = {
2472
+ commit_docs: config.commit_docs,
2473
+ // #2376: absolute — debug.md builds `debug_file_path` as
2474
+ // `{debug_dir}/{slug}.md` for its gsd-debug-session-manager spawns, whose
2475
+ // own cwd may differ from the orchestrator's.
2476
+ debug_dir: toPosixPath(planningPaths(cwd).debug),
2477
+ debugger_model: resolveModelInternal(cwd, 'gsd-debugger'),
2478
+ tdd_mode: Boolean(wf['tdd_mode']),
2479
+ diagnose: options['diagnose'] === true,
2480
+ };
2481
+ // Additive, optional field — degrades to null while `debug` has no key in
2482
+ // `gsd-core/workflows/section-manifest.json` (it has no `gsd:section` markers
2483
+ // until #3128). null means "read everything", which is NOT the same as a
2484
+ // computed empty selection.
2485
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'debug', {});
2486
+ output(withProjectRoot(cwd, result), raw);
2487
+ }
2488
+ function cmdInitProgress(cwd, raw, options = {}) {
1409
2489
  try {
1410
2490
  pruneOrphanedWorktrees(cwd);
1411
2491
  }
@@ -1413,7 +2493,7 @@ function cmdInitProgress(cwd, raw) {
1413
2493
  /* intentionally empty */
1414
2494
  }
1415
2495
  const config = loadConfig(cwd);
1416
- const milestone = getMilestoneInfo(cwd);
2496
+ const milestone = milestoneRecord(cwd);
1417
2497
  const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1418
2498
  // #1912: fail safe in workstream mode with no active workstream. With no active
1419
2499
  // workstream and no --ws, planningDir(cwd) resolves to root .planning — silently
@@ -1421,11 +2501,27 @@ function cmdInitProgress(cwd, raw) {
1421
2501
  // Mirror planningDir's resolution (GSD_WORKSTREAM env > stored active pointer) so
1422
2502
  // an explicit --ws (which sets GSD_WORKSTREAM) satisfies the check.
1423
2503
  const _availableWorkstreams = listAvailableWorkstreams(cwd);
1424
- const _resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
2504
+ // #3579 root-cause fix: this is a check, not a consuming read — use the
2505
+ // non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
2506
+ // here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
2507
+ // which would misreport a present-but-bad marker as no marker at all.
2508
+ const _resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
1425
2509
  if (_availableWorkstreams.length > 0 && !_resolvedWorkstream) {
2510
+ // #3579: getActiveWorkstream now inherits a pointer-less session's read
2511
+ // from the shared .planning/active-workstream marker, so reaching this
2512
+ // branch with a marker actually present means the marker EXISTED but
2513
+ // didn't resolve (invalid name, or its workstream dir is gone) — a
2514
+ // materially different situation from "nothing was ever set" and one
2515
+ // that deserves its own diagnostic instead of the generic message below.
2516
+ const _diagnosis = diagnoseUnresolvedActiveWorkstream(cwd);
2517
+ if (_diagnosis.present) {
2518
+ error(`init.progress requires a workstream in workstream mode — the active-workstream marker names '${_diagnosis.value}', but it did not resolve: ${describeUnresolvedWorkstreamReason(_diagnosis.reason)}. Root STATE.md (likely stale) would be reported otherwise. ` +
2519
+ `Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', _slashRuntime)} to point it at an existing workstream. ` +
2520
+ `Available workstreams: ${_availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_MARKER_UNRESOLVED, { marker_value: _diagnosis.value, marker_reason: _diagnosis.reason });
2521
+ }
1426
2522
  error(`init.progress requires a workstream in workstream mode — no active workstream is set, so root STATE.md (likely stale) would be reported. ` +
1427
2523
  `Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', _slashRuntime)} first. ` +
1428
- `Available workstreams: ${_availableWorkstreams.join(', ')}`);
2524
+ `Available workstreams: ${_availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_NONE_ACTIVE);
1429
2525
  }
1430
2526
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1431
2527
  const phases = [];
@@ -1452,21 +2548,15 @@ function cmdInitProgress(cwd, raw) {
1452
2548
  catch {
1453
2549
  /* intentionally empty */
1454
2550
  }
1455
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
1456
2551
  const seenPhaseNums = new Set();
1457
2552
  try {
1458
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
1459
- const dirs = entries
1460
- .filter((e) => e.isDirectory())
1461
- .map((e) => e.name)
1462
- .filter(isDirInMilestone)
1463
- .sort((a, b) => {
1464
- const pa = a.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
1465
- const pb = b.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
1466
- if (!pa || !pb)
1467
- return a.localeCompare(b);
1468
- return parseInt(pa[1], 10) - parseInt(pb[1], 10);
1469
- });
2553
+ // #3185 (ADR-3180 Decision 1): "which phase directories belong to the
2554
+ // CURRENT milestone" — routed through the canonical owner instead of a
2555
+ // hand-rolled readdirSync + isDirInMilestone filter + local sort (which
2556
+ // also never excluded sentinels, unlike the owner; the final `phases`
2557
+ // array is re-sorted below anyway, so dropping the local sort here is
2558
+ // behavior-preserving).
2559
+ const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
1470
2560
  for (const dir of dirs) {
1471
2561
  const dirMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
1472
2562
  const phaseNumber = dirMatch ? dirMatch[1] : dir;
@@ -1476,7 +2566,12 @@ function cmdInitProgress(cwd, raw) {
1476
2566
  const phaseFiles = node_fs_1.default.readdirSync(phasePath);
1477
2567
  const plans = listPhasePlanFiles(phasePath);
1478
2568
  const summaries = listPhaseSummaryFiles(phasePath);
1479
- const hasResearch = phaseFiles.some((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
2569
+ // #3511-class: scope the raw listing to THIS phase's own artifacts
2570
+ // before the hasResearch predicate runs, so a stray cross-phase
2571
+ // `-RESEARCH.md` sitting in this directory cannot win this phase's
2572
+ // lookup.
2573
+ const scopedPhaseFiles = scopeToPhase(phaseFiles, dir);
2574
+ const hasResearch = scopedPhaseFiles.some((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
1480
2575
  const phaseDirRel = toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningDir(cwd), 'phases', dir)));
1481
2576
  const completion = buildPhaseCompletionProjection(cwd, phaseNumber, phaseDirRel, plans.length, summaries.length, _slashRuntime);
1482
2577
  const status = completion.phase_complete
@@ -1538,6 +2633,25 @@ function cmdInitProgress(cwd, raw) {
1538
2633
  }
1539
2634
  }
1540
2635
  phases.sort((a, b) => parseInt(a['number'], 10) - parseInt(b['number'], 10));
2636
+ // #3581: the frontier is ROADMAP ORDER, not artifact presence. The disk loop
2637
+ // above could claim nextPhase from a stray out-of-order artifact directory
2638
+ // (a phase-9 UAT evidence dir while roadmap phase 8 was pending and
2639
+ // unscaffolded), silently skipping 8 — and init.progress then disagreed with
2640
+ // roadmap.analyze on the same tree. Re-derive from the sorted union: the
2641
+ // first phase that has not begun ('pending' | 'not_started') and is not
2642
+ // roadmap-complete wins; artifacts still feed each entry's status and
2643
+ // completion (corroborating evidence) but no longer outrank the ordering.
2644
+ // Aligned trees derive the identical frontier as the loops above; an
2645
+ // all-complete milestone finds none and keeps nextPhase null for the
2646
+ // completion flow.
2647
+ {
2648
+ const frontier = phases.find((p) => {
2649
+ const st = p['status'];
2650
+ return (st === 'pending' || st === 'not_started') && p['roadmap_complete'] !== true;
2651
+ });
2652
+ if (frontier)
2653
+ nextPhase = frontier;
2654
+ }
1541
2655
  let pausedAt = null;
1542
2656
  const state = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'STATE.md'));
1543
2657
  if (state !== null) {
@@ -1545,12 +2659,29 @@ function cmdInitProgress(cwd, raw) {
1545
2659
  if (pauseMatch)
1546
2660
  pausedAt = pauseMatch[1].trim();
1547
2661
  }
2662
+ // #2994: the CURRENT phase's number, used both to expose `phase_mvp_mode`
2663
+ // at the top level (so the `mvp-display` step body can consume an
2664
+ // already-resolved fact instead of re-invoking `gsd_run query
2665
+ // phase.mvp-mode` itself — that inline resolver would otherwise gate a
2666
+ // section on a fact the section's own body recomputes, which is circular
2667
+ // and self-disabling) and to thread a real `phase_number` into
2668
+ // `buildSectionManifestField` below so `state:phase-mvp-mode` is genuinely
2669
+ // computed for this workflow rather than permanently false (the previous
2670
+ // `buildSectionManifestField(cwd, null, ...)` call passed no phase info at
2671
+ // all, so `detectPhaseMvpMode` always short-circuited on the `!phaseNumber`
2672
+ // guard).
2673
+ const currentPhaseNumber = currentPhase?.['number'] ?? null;
2674
+ const phaseMvpMode = detectPhaseMvpMode(cwd, currentPhaseNumber);
1548
2675
  const result = {
1549
2676
  executor_model: resolveModelInternal(cwd, 'gsd-executor'),
1550
2677
  planner_model: resolveModelInternal(cwd, 'gsd-planner'),
1551
2678
  commit_docs: config.commit_docs,
1552
- milestone_version: milestone['version'],
1553
- milestone_name: milestone['name'],
2679
+ // #3216 review Finding 2: `?? null` so an unresolved milestone still emits
2680
+ // the key with an explicit `null` rather than letting JSON.stringify drop
2681
+ // it — an omitted key reaches the prompt layer's `{milestone_version}`
2682
+ // placeholder as literal, un-substituted text.
2683
+ milestone_version: milestone['version'] ?? null,
2684
+ milestone_name: milestone['name'] ?? null,
1554
2685
  phases,
1555
2686
  phase_count: phases.length,
1556
2687
  completed_count: phases.filter((p) => p['status'] === 'complete').length,
@@ -1559,6 +2690,7 @@ function cmdInitProgress(cwd, raw) {
1559
2690
  next_phase: nextPhase,
1560
2691
  paused_at: pausedAt,
1561
2692
  has_work_in_progress: !!currentPhase,
2693
+ phase_mvp_mode: phaseMvpMode,
1562
2694
  project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
1563
2695
  roadmap_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
1564
2696
  state_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'STATE.md')),
@@ -1568,6 +2700,8 @@ function cmdInitProgress(cwd, raw) {
1568
2700
  project_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'PROJECT.md')),
1569
2701
  config_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'config.json')),
1570
2702
  };
2703
+ // #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
2704
+ result['section_manifest'] = buildSectionManifestField(cwd, currentPhaseNumber ? { phase_number: currentPhaseNumber } : null, options, 'progress');
1571
2705
  output(withProjectRoot(cwd, result), raw);
1572
2706
  }
1573
2707
  function detectChildRepos(dir) {
@@ -1822,7 +2956,20 @@ function buildAgentSkillsBlock(config, agentType, projectRoot, diagnostics) {
1822
2956
  }
1823
2957
  const skillMdPath = node_path_1.default.join(projectRoot, skillPath, 'SKILL.md');
1824
2958
  if (!node_fs_1.default.existsSync(skillMdPath)) {
1825
- warn(`[agent-skills] WARNING: Skill not found at "${skillPath}/SKILL.md" — skipping\n`);
2959
+ // #2941: if the bare name matches a global skill, hint at the global: prefix.
2960
+ // The bare name resolves as project-relative (which doesn't exist), but the
2961
+ // user likely meant to reference a global skill. getGlobalSkillDir is already
2962
+ // imported for the global: branch above; guard on globalSkillsBase being non-null
2963
+ // since runtimes without a skills directory don't support the prefix.
2964
+ let hint = '';
2965
+ if (globalSkillsBase !== null) {
2966
+ const baseName = node_path_1.default.basename(skillPath);
2967
+ const globalDir = (0, runtime_homes_cjs_1.getGlobalSkillDir)(runtime, baseName);
2968
+ if (globalDir && node_fs_1.default.existsSync(node_path_1.default.join(globalDir, 'SKILL.md'))) {
2969
+ hint = ` — a global skill named "${baseName}" exists; use "global:${baseName}" to reference it`;
2970
+ }
2971
+ }
2972
+ warn(`[agent-skills] WARNING: Skill not found at "${skillPath}/SKILL.md"${hint} — skipping\n`);
1826
2973
  continue;
1827
2974
  }
1828
2975
  validEntries.push({ kind: 'include', ref: `${skillPath}/SKILL.md`, display: skillPath });
@@ -1866,7 +3013,7 @@ function cmdAgentSkills(cwd, agentType, raw, jsonMode) {
1866
3013
  if (!block) {
1867
3014
  const runtime = (config && config['runtime']) || process.env['GSD_RUNTIME'] || 'claude';
1868
3015
  if (runtime !== 'claude') {
1869
- const agentCheck = checkAgentsInstalled(runtime);
3016
+ const agentCheck = checkAgentsInstalled(runtime, projectRoot);
1870
3017
  const agentsDir = agentCheck?.agents_dir;
1871
3018
  if (typeof agentsDir === 'string' && agentsDir.length > 0) {
1872
3019
  const agentFile = node_path_1.default.join(agentsDir, `${agentType}.md`);
@@ -2088,7 +3235,7 @@ function buildSkillManifest(cwd, skillsDir = null) {
2088
3235
  seenNamesInRoot.add(name);
2089
3236
  const description = frontmatter['description'] || '';
2090
3237
  const triggers = [];
2091
- const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/);
3238
+ const bodyMatch = content.match(/^---[\s\S]*?---\s*\r?\n([\s\S]*)$/);
2092
3239
  if (bodyMatch) {
2093
3240
  const body = bodyMatch[1];
2094
3241
  const triggerLines = body.match(/^TRIGGER\s+when:\s*(.+)$/gmi);
@@ -2198,11 +3345,20 @@ module.exports = {
2198
3345
  cmdInitResume,
2199
3346
  cmdInitVerifyWork,
2200
3347
  cmdInitPhaseOp,
3348
+ cmdInitCodeReview,
3349
+ cmdInitReview,
3350
+ cmdInitDiscussPhaseAssumptions,
2201
3351
  cmdInitTodos,
2202
3352
  cmdInitMilestoneOp,
2203
3353
  cmdInitMapCodebase,
2204
3354
  cmdInitProgress,
2205
3355
  cmdInitManager,
3356
+ cmdInitCompleteMilestone,
3357
+ cmdInitAutonomous,
3358
+ cmdInitDocsUpdate,
3359
+ cmdInitUpdate,
3360
+ cmdInitTransition,
3361
+ cmdInitDebug,
2206
3362
  cmdInitNewWorkspace,
2207
3363
  cmdInitListWorkspaces,
2208
3364
  cmdInitRemoveWorkspace,