@opengsd/gsd-core 1.9.0 → 1.10.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 (223) 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 +131 -34
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +503 -341
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +607 -63
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +106 -51
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -25
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/project-root.cjs +48 -0
  62. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  63. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  64. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  65. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  66. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  67. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  68. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  70. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  71. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  73. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  75. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  76. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  77. package/gsd-core/bin/lib/state.cjs +151 -62
  78. package/gsd-core/bin/lib/surface.cjs +12 -1
  79. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  80. package/gsd-core/bin/lib/uat.cjs +320 -21
  81. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  82. package/gsd-core/bin/lib/verification.cjs +29 -12
  83. package/gsd-core/bin/lib/verify.cjs +29 -5
  84. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  85. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  86. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  87. package/gsd-core/bin/lib/workstream.cjs +6 -0
  88. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  89. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  90. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  91. package/gsd-core/references/artifact-types.md +10 -3
  92. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  93. package/gsd-core/references/debugger-techniques.md +255 -0
  94. package/gsd-core/references/research-documentation-lookup.md +5 -3
  95. package/gsd-core/references/specless-probe-fallback.md +7 -6
  96. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  97. package/gsd-core/references/worktree-branch-check.md +2 -2
  98. package/gsd-core/templates/summary-complex.md +2 -0
  99. package/gsd-core/templates/summary-minimal.md +2 -0
  100. package/gsd-core/templates/summary-standard.md +2 -0
  101. package/gsd-core/templates/summary.md +2 -0
  102. package/gsd-core/workflows/audit-milestone.md +3 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  107. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  108. package/gsd-core/workflows/autonomous.md +32 -69
  109. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  110. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  111. package/gsd-core/workflows/code-review.md +42 -145
  112. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  113. package/gsd-core/workflows/complete-milestone.md +23 -81
  114. package/gsd-core/workflows/debug.md +9 -12
  115. package/gsd-core/workflows/diagnose-issues.md +22 -0
  116. package/gsd-core/workflows/discovery-phase.md +4 -4
  117. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  118. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  119. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  120. package/gsd-core/workflows/docs-update.md +8 -51
  121. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  122. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  123. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  124. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  127. package/gsd-core/workflows/execute-phase.md +65 -137
  128. package/gsd-core/workflows/execute-plan.md +1 -1
  129. package/gsd-core/workflows/help/modes/full.md +6 -1
  130. package/gsd-core/workflows/ingest-docs.md +2 -1
  131. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  132. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  133. package/gsd-core/workflows/new-milestone.md +21 -38
  134. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  135. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  136. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  137. package/gsd-core/workflows/new-project.md +13 -226
  138. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  139. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  142. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  143. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  144. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  145. package/gsd-core/workflows/plan-phase.md +49 -193
  146. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  147. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  148. package/gsd-core/workflows/progress.md +11 -153
  149. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  150. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  151. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  152. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  153. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  154. package/gsd-core/workflows/quick.md +20 -390
  155. package/gsd-core/workflows/resume-project.md +3 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  157. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  158. package/gsd-core/workflows/review.md +15 -8
  159. package/gsd-core/workflows/section-manifest.json +219 -0
  160. package/gsd-core/workflows/sketch.md +1 -1
  161. package/gsd-core/workflows/spec-phase.md +17 -14
  162. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  163. package/gsd-core/workflows/spike.md +50 -16
  164. package/gsd-core/workflows/sync-skills.md +49 -11
  165. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  166. package/gsd-core/workflows/transition.md +8 -21
  167. package/gsd-core/workflows/ui-phase.md +8 -7
  168. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  169. package/gsd-core/workflows/update.md +18 -7
  170. package/gsd-core/workflows/verify-phase.md +4 -7
  171. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  172. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  173. package/gsd-core/workflows/verify-work.md +8 -58
  174. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  175. package/hooks/dist/gsd-check-update-worker.js +14 -5
  176. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  177. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  178. package/hooks/dist/gsd-statusline.js +72 -6
  179. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  180. package/hooks/dist/gsd-write-guard.js +359 -0
  181. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  182. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  183. package/hooks/gsd-agent-isolation-guard.js +428 -0
  184. package/hooks/gsd-check-update-worker.js +14 -5
  185. package/hooks/gsd-cursor-subagent-start.js +532 -26
  186. package/hooks/gsd-read-injection-scanner.js +7 -0
  187. package/hooks/gsd-statusline.js +72 -6
  188. package/hooks/gsd-worktree-path-guard.js +2 -1
  189. package/hooks/gsd-write-guard.js +359 -0
  190. package/hooks/hooks.json +12 -0
  191. package/hooks/lib/isolation-sentinel.js +268 -0
  192. package/hooks/managed-hooks-registry.cjs +2 -0
  193. package/package.json +14 -5
  194. package/pi/gsd.cjs +57 -12
  195. package/scripts/build-hooks.js +9 -0
  196. package/scripts/changeset/lint.cjs +9 -2
  197. package/scripts/changeset/serialize.cjs +5 -1
  198. package/scripts/gen-capability-matrix.cjs +1 -1
  199. package/scripts/gen-context-index.cjs +448 -0
  200. package/scripts/gen-inventory-manifest.cjs +101 -1
  201. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  202. package/scripts/gen-registry.cjs +39 -15
  203. package/scripts/gen-section-manifest.cjs +638 -0
  204. package/scripts/generate-package-identity.cjs +4 -2
  205. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  206. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  207. package/scripts/lint-docs-command-form.cjs +195 -0
  208. package/scripts/lint-docs-required.cjs +9 -1
  209. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  210. package/scripts/lint-example-parser-parity.cjs +395 -0
  211. package/scripts/lint-test-file-count.allowlist.json +27 -1
  212. package/scripts/mutation-matrix.cjs +13 -0
  213. package/scripts/prompt-injection-scan.sh +27 -6
  214. package/scripts/registry-schema.cjs +323 -94
  215. package/scripts/run-tests.cjs +3 -2
  216. package/scripts/validate-registry.cjs +10 -6
  217. package/skills/gsd-autonomous/SKILL.md +1 -1
  218. package/skills/gsd-execute-phase/SKILL.md +1 -1
  219. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  220. package/skills/gsd-new-milestone/SKILL.md +1 -1
  221. package/skills/gsd-plan-phase/SKILL.md +2 -2
  222. package/vscode/package.json +1 -1
  223. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -50,6 +50,20 @@ const verificationMod = require("./verification.cjs");
50
50
  const uatPredicateMod = require("./uat-predicate.cjs");
51
51
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
52
52
  const agentInstallCheck = require("./agent-install-check.cjs");
53
+ // 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).
54
+ const sectionManifest = require("./section-manifest.cjs");
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- loop-resolver.cjs is an export= CommonJS module
56
+ const loopResolverMod = require("./loop-resolver.cjs");
57
+ // 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.
58
+ const capabilityLoaderMod = require("./capability-loader.cjs");
59
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- capability-state.cjs is an export= CommonJS module
60
+ const capabilityStateMod = require("./capability-state.cjs");
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- docs.cjs is an export= CommonJS module
62
+ const docsMod = require("./docs.cjs");
63
+ const { detectMonorepoWorkspaces } = docsMod;
64
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- workstream-inventory.cjs is an export= CommonJS module
65
+ const workstreamInventoryMod = require("./workstream-inventory.cjs");
66
+ const { getOtherActiveWorkstreamInventories } = workstreamInventoryMod;
53
67
  const { checkAgentsInstalled } = agentInstallCheck;
54
68
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- git-base-branch.cjs is an export= CommonJS module
55
69
  const gitBaseBranch = require("./git-base-branch.cjs");
@@ -71,6 +85,9 @@ const { determinePhaseStatus } = commandsMod;
71
85
  const { extractFrontmatter } = frontmatterMod;
72
86
  const { readVerificationStatus } = verificationMod;
73
87
  const { evaluateUatPassed } = uatPredicateMod;
88
+ const { resolveLoopHooks } = loopResolverMod;
89
+ const { loadRegistry } = capabilityLoaderMod;
90
+ const { resolveCapabilityRuntimeState } = capabilityStateMod;
74
91
  // Unused but imported for structural parity
75
92
  void stripShippedMilestones;
76
93
  // Accept all bold/colon variants of the Requirements header (#2769)
@@ -106,6 +123,47 @@ function guardedGetRoadmapPhase(cwd, phase, projectCode) {
106
123
  }
107
124
  return roadmapPhase;
108
125
  }
126
+ // #2994: `phase_slug` is re-derived from a roadmap-only `phase_name` (no disk
127
+ // directory exists yet) identically at every synthetic-fallback call site
128
+ // below — factored out once so the slugification formula itself cannot drift.
129
+ function slugifyPhaseName(phaseName) {
130
+ return phaseName
131
+ ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
132
+ : null;
133
+ }
134
+ /**
135
+ * #2994 (review finding, DEFECT.GENERATIVE-FIX): shared archived/not-found
136
+ * fallback applied identically by `cmdInitExecutePhase`, `cmdInitPlanPhase`,
137
+ * `cmdInitVerifyWork`, `cmdInitCodeReview`, `cmdInitReview`, and
138
+ * `cmdInitDiscussPhaseAssumptions` — 6 call sites previously reproducing the
139
+ * exact same two-branch control flow verbatim (only the synthetic
140
+ * replacement object's field set differs per caller, supplied here via
141
+ * `buildFallback`). `cmdInitPhaseOp` is deliberately left untouched (CRITICAL
142
+ * blast radius, 179 dependents) even though it follows the same shape, since
143
+ * its own fallback object differs by one field (`has_reviews` absent) and is
144
+ * not a byte-identical copy.
145
+ *
146
+ * Behavior-preserving by construction: every original call site either (a)
147
+ * unconditionally computed `roadmapPhase` once up front and then applied
148
+ * `phaseInfo?.archived && roadmapPhase?.found -> null` followed by
149
+ * `!phaseInfo && roadmapPhase?.found -> fallback`, or (b) computed
150
+ * `roadmapPhase` lazily inside each of those same two conditions. Because
151
+ * `guardedGetRoadmapPhase` is a pure, side-effect-free read for a given
152
+ * `(cwd, phase, projectCode)` within one command invocation, both shapes
153
+ * return identical results for identical inputs — so passing one
154
+ * unconditionally-resolved `roadmapPhase` in here (mirroring shape (a))
155
+ * reproduces shape (b)'s output exactly, just without the redundant second
156
+ * disk read shape (b) performed when the first branch already resolved it.
157
+ */
158
+ function applyRoadmapFallback(phaseInfo, roadmapPhase, buildFallback) {
159
+ if (phaseInfo?.['archived'] && roadmapPhase?.['found']) {
160
+ phaseInfo = null;
161
+ }
162
+ if (!phaseInfo && roadmapPhase?.['found']) {
163
+ phaseInfo = buildFallback(roadmapPhase);
164
+ }
165
+ return phaseInfo;
166
+ }
109
167
  function listPhaseSummaryFiles(phaseDir) {
110
168
  return scanPhasePlans(phaseDir)['summaryFiles'];
111
169
  }
@@ -143,6 +201,10 @@ function buildPhaseCompletionProjection(cwd, phaseNumber, phaseDir, planCount, s
143
201
  completion_status: projectCompletionStatus(implementationComplete, verificationPassed),
144
202
  verification_next_action: projectedVerificationAction,
145
203
  verification_next_command: verificationStatus.next_command,
204
+ // #3057 B3: only readVerificationStatus's result ever carries this flag —
205
+ // the `not_required` synthetic object above never does.
206
+ verification_stale_check_indeterminate: 'staleCheckIndeterminate' in verificationStatus
207
+ && verificationStatus.staleCheckIndeterminate === true,
146
208
  };
147
209
  }
148
210
  function getLatestCompletedMilestone(cwd) {
@@ -161,7 +223,7 @@ function getLatestCompletedMilestone(cwd) {
161
223
  function withProjectRoot(cwd, result) {
162
224
  result['project_root'] = cwd;
163
225
  const activeRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
164
- const agentStatus = checkAgentsInstalled(activeRuntime);
226
+ const agentStatus = checkAgentsInstalled(activeRuntime, cwd);
165
227
  result['agents_installed'] = agentStatus.agents_installed;
166
228
  result['missing_agents'] = agentStatus.missing_agents;
167
229
  result['agents_dir'] = agentStatus.agents_dir;
@@ -250,6 +312,385 @@ function getInitGitState(cwd) {
250
312
  in_nested_subdir: inNestedSubdir,
251
313
  };
252
314
  }
315
+ // #2932 (Phase 5, ADR-1671): shipped, generated artifact — see
316
+ // scripts/gen-section-manifest.cjs and gsd-core/workflows/section-manifest.json.
317
+ // Resolved the same way model-catalog.cts resolves model-catalog.json: relative
318
+ // to the compiled module's own directory (gsd-core/bin/lib -> gsd-core/workflows),
319
+ // with a GSD_SECTION_MANIFEST env override so tests can point at a temp fixture
320
+ // (missing/malformed-JSON degraded-path coverage) without mutating the shipped
321
+ // artifact — the shipped file is a shared, concurrently-read resource across
322
+ // parallel test runs and must never be moved/corrupted in place.
323
+ const _sectionManifestCandidatePath = () => process.env['GSD_SECTION_MANIFEST']
324
+ ? node_path_1.default.resolve(process.env['GSD_SECTION_MANIFEST'])
325
+ : node_path_1.default.resolve(__dirname, '..', '..', 'workflows', 'section-manifest.json');
326
+ /**
327
+ * Defense-in-depth shape check for a manifest entry's `read` field, which is
328
+ * documented as a POSIX-normalized, repo-root-RELATIVE path (never a
329
+ * filesystem escape). Rejects any absolute path (POSIX leading `/`, a
330
+ * Windows drive prefix like `C:\`/`C:/`, or a Windows UNC/rooted path
331
+ * starting with `\`) and any path containing a `..` segment (checked on
332
+ * BOTH separators — the artifact is generated as POSIX-normalized, but this
333
+ * validates the raw field defensively rather than trusting that invariant).
334
+ * `false` here is the only accept path in {@link loadSectionManifestSections};
335
+ * a `true` degrades the WHOLE load to `null`, same as every other shape
336
+ * violation — never throws, never partially loads.
337
+ */
338
+ function isUnsafeManifestReadPath(readPath) {
339
+ if (readPath.startsWith('/') || readPath.startsWith('\\'))
340
+ return true;
341
+ if (/^[a-zA-Z]:[\\/]/.test(readPath))
342
+ return true;
343
+ return readPath.split(/[\\/]/).includes('..');
344
+ }
345
+ /**
346
+ * Loads and shape-validates the generated section manifest, then returns the
347
+ * document-order section array for exactly one named `workflow` (#2992 Phase
348
+ * 6.1: the artifact is now `{ workflows: { <name>: [...] } }`, keyed by
349
+ * `.md` basename — see `scripts/gen-section-manifest.cjs`). Returns `null`
350
+ * — never throws — when the artifact is missing, unreadable, malformed
351
+ * JSON, valid JSON of the wrong shape (INCLUDING the pre-6.1 flat
352
+ * `{sections:[...]}` shape, which must never be mis-attributed to any
353
+ * workflow — design row C4), or when `workflow` has no key in `workflows`.
354
+ * `Object.hasOwn` guards the key lookup so a hostile workflow name
355
+ * (`constructor`, `toString`, `__proto__`) can never resolve via the
356
+ * prototype chain instead of a genuine own key. Each entry's `read` field is
357
+ * additionally validated by {@link isUnsafeManifestReadPath} (rejects an
358
+ * absolute path or a `..` segment) — a single unsafe entry degrades the
359
+ * WHOLE load to `null`, all-or-nothing like every other shape violation.
360
+ */
361
+ function loadSectionManifestSections(workflow) {
362
+ try {
363
+ const raw = node_fs_1.default.readFileSync(_sectionManifestCandidatePath(), 'utf8');
364
+ const parsed = JSON.parse(raw);
365
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
366
+ return null;
367
+ const workflows = parsed['workflows'];
368
+ if (workflows === null || typeof workflows !== 'object' || Array.isArray(workflows))
369
+ return null;
370
+ if (!Object.hasOwn(workflows, workflow))
371
+ return null;
372
+ const sections = workflows[workflow];
373
+ if (!Array.isArray(sections))
374
+ return null;
375
+ for (const section of sections) {
376
+ const readValue = section?.['read'];
377
+ if (!section ||
378
+ typeof section !== 'object' ||
379
+ typeof section['id'] !== 'string' ||
380
+ typeof section['when'] !== 'string' ||
381
+ typeof readValue !== 'string' ||
382
+ isUnsafeManifestReadPath(readValue)) {
383
+ return null;
384
+ }
385
+ }
386
+ return sections;
387
+ }
388
+ catch {
389
+ return null;
390
+ }
391
+ }
392
+ /**
393
+ * `state:has-prior-phases` ground truth (design doc §Behavior table, regression-gate
394
+ * body: "Skip if: this is the first phase (no prior phases)"): TRUE when at least
395
+ * one OTHER phase directory under `.planning/phases/` contains a `*-VERIFICATION.md`
396
+ * file. Bounded, non-throwing — an unreadable phases directory degrades to `false`
397
+ * rather than surfacing an error from an init query.
398
+ */
399
+ function detectHasPriorPhases(cwd, phaseInfo) {
400
+ const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
401
+ const currentDirName = phaseInfo?.['directory']
402
+ ? node_path_1.default.basename(phaseInfo['directory'])
403
+ : null;
404
+ try {
405
+ if (!node_fs_1.default.existsSync(phasesDir))
406
+ return false;
407
+ const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
408
+ for (const entry of entries) {
409
+ if (!entry.isDirectory() || entry.name === currentDirName)
410
+ continue;
411
+ let files;
412
+ try {
413
+ files = node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, entry.name));
414
+ }
415
+ catch {
416
+ continue;
417
+ }
418
+ if (files.some((f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md')) {
419
+ return true;
420
+ }
421
+ }
422
+ return false;
423
+ }
424
+ catch {
425
+ return false;
426
+ }
427
+ }
428
+ /**
429
+ * Strict-boolean, bounded, non-throwing read of a dotted key path from
430
+ * `.planning/config.json` (design rows D7-D10): absent file, unreadable
431
+ * file (fs error), malformed JSON, a non-object intermediate segment, or a
432
+ * present-but-non-boolean value (e.g. the string `"true"`) all degrade to
433
+ * `false` — strict `=== true`, never coerced, mirrors `detectHasPriorPhases`'s
434
+ * degrade-to-false discipline. `keyPath` is always a fixed literal supplied
435
+ * by this module, never attacker/user input, so a plain bracket traversal
436
+ * carries no prototype hazard here.
437
+ */
438
+ function readConfigJsonBoolean(cwd, keyPath) {
439
+ try {
440
+ const raw = node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'config.json'), 'utf8');
441
+ let cursor = JSON.parse(raw);
442
+ for (const segment of keyPath) {
443
+ if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor))
444
+ return false;
445
+ cursor = cursor[segment];
446
+ }
447
+ return cursor === true;
448
+ }
449
+ catch {
450
+ return false;
451
+ }
452
+ }
453
+ /**
454
+ * Bounded, non-throwing read of a dotted key path from `.planning/config.json`,
455
+ * returning the raw resolved value (any JSON type) or `undefined` on any
456
+ * degraded condition (absent file, unreadable file, malformed JSON, or a
457
+ * non-object intermediate segment) — the generic sibling of
458
+ * {@link readConfigJsonBoolean} for callers that need the actual value
459
+ * (a string like `code_quality.fallow.profile`) rather than a strict
460
+ * boolean coercion. `keyPath` is always a fixed literal supplied by this
461
+ * module, never attacker/user input, so a plain bracket traversal carries
462
+ * no prototype hazard here (same discipline as `readConfigJsonBoolean`).
463
+ */
464
+ function readConfigJsonValue(cwd, keyPath) {
465
+ try {
466
+ const raw = node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'config.json'), 'utf8');
467
+ let cursor = JSON.parse(raw);
468
+ for (const segment of keyPath) {
469
+ if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor))
470
+ return undefined;
471
+ cursor = cursor[segment];
472
+ }
473
+ return cursor;
474
+ }
475
+ catch {
476
+ return undefined;
477
+ }
478
+ }
479
+ /**
480
+ * `state:fallow-enabled` ground truth (#2994): resolves `code-review.md`'s
481
+ * `structural_pre_pass` fallow config gate — previously re-derived INSIDE the
482
+ * gated section body itself (`gsd_run query config-get code_quality.fallow.*`),
483
+ * which is circular/self-disabling the moment a section is gated on a fact
484
+ * its own body computes (the same hazard `state:chunked-mode` /
485
+ * `state:ui-phase-active` document for a compound condition). Fail-closed
486
+ * default `false` for `enabled`/`mcp`, matching the pre-hoist bash resolver's
487
+ * `2>/dev/null || echo "false"` fallback; `scope`/`profile` default to
488
+ * `"phase"`/`"standard"` matching that same resolver's `|| echo` fallbacks.
489
+ * `maxCrap` mirrors the step body's profile->threshold mapping (minimal=50,
490
+ * strict=15, else standard=30) so the step file never has to re-derive it.
491
+ */
492
+ function detectFallowConfig(cwd) {
493
+ const enabled = readConfigJsonValue(cwd, ['code_quality', 'fallow', 'enabled']) === true;
494
+ const rawScope = readConfigJsonValue(cwd, ['code_quality', 'fallow', 'scope']);
495
+ const scope = typeof rawScope === 'string' && rawScope ? rawScope : 'phase';
496
+ const rawProfile = readConfigJsonValue(cwd, ['code_quality', 'fallow', 'profile']);
497
+ const profile = typeof rawProfile === 'string' && rawProfile ? rawProfile : 'standard';
498
+ const mcp = readConfigJsonValue(cwd, ['code_quality', 'fallow', 'mcp']) === true;
499
+ const maxCrap = profile === 'minimal' ? 50 : profile === 'strict' ? 15 : 30;
500
+ return { enabled, scope, profile, mcp, maxCrap };
501
+ }
502
+ /**
503
+ * `state:git-create-tag` ground truth (#2994): resolves `complete-milestone.md`'s
504
+ * `git_tag` step config gate — previously re-derived INSIDE a `<config-check>`
505
+ * sub-tag at the top of the step itself (`gsd-tools.cjs query config-get
506
+ * git.create_tag 2>/dev/null || echo "true"`), gating the step's OWN inclusion
507
+ * on a fact only that same step computed. Fail-OPEN default `true` (an unset
508
+ * or missing `git.create_tag` key means "create the tag"), matching the
509
+ * pre-hoist resolver's `|| echo "true"` fallback exactly — this is
510
+ * deliberately the inverse polarity of `detectFallowConfig`'s fail-closed
511
+ * default, mirroring the two source resolvers' own opposite defaults.
512
+ */
513
+ function detectGitCreateTag(cwd) {
514
+ return readConfigJsonValue(cwd, ['git', 'create_tag']) !== false;
515
+ }
516
+ /**
517
+ * `state:phase-mvp-mode` ground truth (design doc §Behavior table: ROADMAP.md
518
+ * `**Mode:** mvp` for the CURRENT phase). Bounded, non-throwing — an absent
519
+ * `phaseNumber`, an absent ROADMAP.md, an absent phase heading, or a phase
520
+ * section with no `**Mode:**` line (or a `**Mode:**` value other than the
521
+ * literal `mvp` token, case-insensitively) all degrade to `false` (D11; "a
522
+ * phase with no `**Mode:**` line and an absent ROADMAP are both false, but
523
+ * neither may throw"). Self-contained rather than reusing `phase.cts`'s
524
+ * private `getRoadmapModeForPhase` (unexported, and importing it here would
525
+ * be a cross-module surface change outside this task's scope) — but derived
526
+ * from the SAME extraction primitives (`extractCurrentMilestone`,
527
+ * `PHASE_NUMBER_TOKEN_SOURCE`-adjacent `escapeRegex`) already used by this
528
+ * file's own `cmdInitProgress` MVP-heading scan, so it is not a second
529
+ * ROADMAP-heading parser invented from scratch.
530
+ */
531
+ function detectPhaseMvpMode(cwd, phaseNumber) {
532
+ if (!phaseNumber)
533
+ return false;
534
+ try {
535
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
536
+ if (!node_fs_1.default.existsSync(roadmapPath))
537
+ return false;
538
+ const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
539
+ const content = extractCurrentMilestone(rawContent, cwd);
540
+ const escapedPhase = escapeRegex(phaseNumber);
541
+ const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
542
+ const headerMatch = content.match(phaseHeader);
543
+ if (!headerMatch || headerMatch.index === undefined)
544
+ return false;
545
+ const sectionStart = headerMatch.index;
546
+ const rest = content.slice(sectionStart + headerMatch[0].length);
547
+ const nextHeaderMatch = rest.match(/\n#{2,4}\s+Phase\s+\S/i);
548
+ const sectionEnd = nextHeaderMatch
549
+ ? sectionStart + headerMatch[0].length + nextHeaderMatch.index
550
+ : content.length;
551
+ const section = content.slice(sectionStart, sectionEnd);
552
+ const modeMatch = section.match(/\*\*Mode:\*\*\s*([^\n]+)/i);
553
+ return modeMatch ? modeMatch[1].trim().toLowerCase() === 'mvp' : false;
554
+ }
555
+ catch {
556
+ return false;
557
+ }
558
+ }
559
+ /**
560
+ * `state:ui-phase-active` ground truth (#2994): whether the phase's active
561
+ * `plan:pre` loop hooks include the `ui-phase` step (`capabilities/ui/
562
+ * capability.json`'s `plan:pre` step, `ref.skill: "ui-phase"`, gated on
563
+ * config `workflow.ui_phase`), OR the phase directory already contains a
564
+ * `*-UI-SPEC.md` file. The disjunction is resolved to ONE boolean here —
565
+ * same discipline as `chunkedMode` above — so the `when=` grammar never
566
+ * sees an OR. Mirrors `cmdLoopRenderHooks`'s own registry/capability-state
567
+ * setup (`src/loop-resolver.cts`) rather than reinventing a second loop-hook
568
+ * resolution path. Bounded, non-throwing: any failure in loop-hook /
569
+ * registry / capability-state resolution degrades that half of the OR to
570
+ * `false`, never throws; the UI-SPEC file check is independently bounded.
571
+ */
572
+ function detectUiPhaseActive(cwd, phaseInfo) {
573
+ let hasActiveUiStep = false;
574
+ try {
575
+ const config = loadConfig(cwd);
576
+ const state = resolveCapabilityRuntimeState(cwd, undefined, config);
577
+ const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] });
578
+ const capabilityStatesById = new Map();
579
+ for (const cap of state.capabilities || []) {
580
+ capabilityStatesById.set(cap.id, cap);
581
+ }
582
+ const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config, cwd, capabilityStatesById });
583
+ hasActiveUiStep = resolved.activeHooks.some((h) => h.kind === 'step' && h.ref?.skill === 'ui-phase');
584
+ }
585
+ catch {
586
+ hasActiveUiStep = false;
587
+ }
588
+ let hasUiSpecFile = false;
589
+ const rawDir = phaseInfo?.['directory'];
590
+ if (typeof rawDir === 'string' && rawDir) {
591
+ try {
592
+ // Re-derive under planningDir(cwd)/phases/<basename> rather than trusting
593
+ // rawDir's own absolute/relative-ness (callers mix both — see the #2376
594
+ // comments elsewhere in this file), same technique as detectHasPriorPhases above.
595
+ const dirName = node_path_1.default.basename(rawDir);
596
+ const files = node_fs_1.default.readdirSync(node_path_1.default.join(planningDir(cwd), 'phases', dirName));
597
+ hasUiSpecFile = files.some((f) => f.endsWith('-UI-SPEC.md') || f === 'UI-SPEC.md');
598
+ }
599
+ catch {
600
+ hasUiSpecFile = false;
601
+ }
602
+ }
603
+ return hasActiveUiStep || hasUiSpecFile;
604
+ }
605
+ /**
606
+ * Builds the `section_manifest` init-bundle field (#2932 Deliverable 2): resolves
607
+ * {@link sectionManifest.InvocationFacts} from this invocation, loads the generated
608
+ * manifest, and partitions it via the pure {@link sectionManifest.selectSections}
609
+ * evaluator. Returns `null` on any degraded condition (missing/malformed artifact,
610
+ * or an unexpected throw from the evaluator itself) — this field is additive and
611
+ * optional, never load-bearing for dispatch (Hyrum's Law: 22 direct init-bundle
612
+ * dependents must be unaffected by its absence).
613
+ *
614
+ * `flags` (D1-D5): built from `options`'s OWN keys, gated on VALUE TRUTHINESS
615
+ * — not merely `!== undefined`. `parseNamedArgs` (src/command-arg-projection.cts)
616
+ * never yields `undefined` for an absent flag of either kind: a value-flag's
617
+ * absence is `null`, a booleanFlag's absence is `false`. An `undefined`-only
618
+ * absence check therefore lets BOTH kinds of absent flag leak into `flags` as
619
+ * present. A present value-flag is always a non-empty string, and a present
620
+ * booleanFlag is always `true` — so skipping any falsy value (`undefined`,
621
+ * `null`, `false`, `''`, `0`) is a safe, single-rule absence test for both
622
+ * flag kinds; `--wave 0` still resolves to `true` via `booleanFlags`, so
623
+ * truthiness never misclassifies a real invocation as absent. `Object.keys`
624
+ * + a plain `new Set()` so a hostile option key (e.g. `constructor`) can
625
+ * never leak via the prototype chain.
626
+ *
627
+ * `needsCodebaseMap` is not computed in this shared facts-assembly scope —
628
+ * `isBrownfield && !hasCodebaseMap` is only meaningful for `new-project`
629
+ * (`cmdInitNewProject` already computes both operands for its own result
630
+ * object). Rather than recomputing it here (a second, divergence-prone
631
+ * codebase-map scan) or widening every call site's positional signature,
632
+ * callers that HAVE the fact pass it via the optional `overrides` param;
633
+ * every other caller passes nothing and gets `undefined` (falsy per
634
+ * `WHEN_PREDICATES`, never invented, never throws).
635
+ */
636
+ function buildSectionManifestField(cwd, phaseInfo, options, workflow, overrides = {}) {
637
+ const sections = loadSectionManifestSections(workflow);
638
+ if (!sections)
639
+ return null;
640
+ const rawPhaseNumber = phaseInfo?.['phase_number'];
641
+ const phaseNumber = typeof rawPhaseNumber === 'string'
642
+ ? rawPhaseNumber
643
+ : typeof rawPhaseNumber === 'number'
644
+ ? String(rawPhaseNumber)
645
+ : null;
646
+ const flags = new Set();
647
+ for (const key of Object.keys(options)) {
648
+ if (!options[key])
649
+ continue;
650
+ flags.add(`--${key}`);
651
+ }
652
+ // `state:chunked-mode` (#2993) is a disjunction — `--chunked` flag OR
653
+ // `.planning/config.json` `workflow.plan_chunked` — resolved to ONE
654
+ // boolean HERE, in fact computation, never in the `when=` grammar itself
655
+ // (WHEN_PREDICATES['state:chunked-mode'] reads only `facts.chunkedMode`).
656
+ // That separation is what keeps ADR-1671:69's Greenspun guard intact: the
657
+ // grammar still sees exactly one atom with no operator.
658
+ const chunkedMode = flags.has('--chunked') || readConfigJsonBoolean(cwd, ['workflow', 'plan_chunked']);
659
+ const facts = {
660
+ flags,
661
+ phaseNumber,
662
+ hasPriorPhases: detectHasPriorPhases(cwd, phaseInfo),
663
+ worktreesEnabled: readConfigJsonBoolean(cwd, ['workflow', 'use_worktrees']),
664
+ phaseMvpMode: detectPhaseMvpMode(cwd, phaseNumber),
665
+ needsCodebaseMap: overrides.needsCodebaseMap,
666
+ chunkedMode,
667
+ uiPhaseActive: overrides.uiPhaseActive,
668
+ fallowEnabled: overrides.fallowEnabled,
669
+ gitCreateTag: overrides.gitCreateTag,
670
+ planStrategyConverge: overrides.planStrategyConverge,
671
+ reviewerInstancesConfigured: overrides.reviewerInstancesConfigured,
672
+ autoAdvanceActive: overrides.autoAdvanceActive,
673
+ isMonorepo: overrides.isMonorepo,
674
+ nextChannel: overrides.nextChannel,
675
+ workstreamActive: overrides.workstreamActive,
676
+ flatMode: overrides.flatMode,
677
+ };
678
+ try {
679
+ const selection = sectionManifest.selectSections(sections, facts);
680
+ const readById = new Map(sections.map((s) => [s.id, s.read]));
681
+ return {
682
+ workflow,
683
+ included: selection.included,
684
+ excluded: selection.excluded,
685
+ read: selection.included
686
+ .map((id) => readById.get(id))
687
+ .filter((p) => typeof p === 'string'),
688
+ };
689
+ }
690
+ catch {
691
+ return null;
692
+ }
693
+ }
253
694
  function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
254
695
  if (!phase) {
255
696
  error('phase required for init execute-phase');
@@ -258,28 +699,26 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
258
699
  let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
259
700
  const milestone = getMilestoneInfo(cwd);
260
701
  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 = {
702
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
703
+ const phaseName = rp['phase_name'];
704
+ return {
267
705
  found: true,
268
706
  directory: null,
269
- phase_number: roadmapPhase['phase_number'],
707
+ phase_number: rp['phase_number'],
270
708
  phase_name: phaseName,
271
- phase_slug: phaseName
272
- ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
273
- : null,
709
+ phase_slug: slugifyPhaseName(phaseName),
274
710
  plans: [],
275
711
  summaries: [],
276
712
  incomplete_plans: [],
713
+ halted_plans: [],
714
+ blocked_by: {},
715
+ runnable_plans: [],
277
716
  has_research: false,
278
717
  has_context: false,
279
718
  has_verification: false,
280
719
  has_reviews: false,
281
720
  };
282
- }
721
+ });
283
722
  const reqMatch = roadmapPhase?.['section']?.match(REQUIREMENTS_HEADER_RE);
284
723
  const reqExtracted = reqMatch
285
724
  ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
@@ -313,6 +752,15 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
313
752
  incomplete_plans: phaseInfo?.['incomplete_plans'] || [],
314
753
  plan_count: phaseInfo?.['plans']?.length || 0,
315
754
  incomplete_count: phaseInfo?.['incomplete_plans']?.length || 0,
755
+ // #2830: the halt-aware view, forwarded from the shared computation in
756
+ // phase-locator. Additive — `incomplete_plans`/`incomplete_count` above keep
757
+ // their exact name, type and semantics. Without this passthrough the shared
758
+ // truth is computed and then dropped at this consumer, which is the path the
759
+ // issue reports as regressed.
760
+ halted_plans: phaseInfo?.['halted_plans'] || [],
761
+ blocked_by: phaseInfo?.['blocked_by'] || {},
762
+ runnable_plans: phaseInfo?.['runnable_plans'] || [],
763
+ runnable_count: phaseInfo?.['runnable_plans']?.length || 0,
316
764
  branch_name: config.branching_strategy === 'phase' && phaseInfo
317
765
  ? config.phase_branch_template
318
766
  .replace('{project}', config.project_code || '')
@@ -359,6 +807,8 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
359
807
  /* intentionally empty */
360
808
  }
361
809
  }
810
+ // #2932/#2992 (Phase 5/6.1): additive, optional field — degrades to null, never throws.
811
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options, 'execute-phase');
362
812
  output(withProjectRoot(cwd, result), raw);
363
813
  }
364
814
  function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
@@ -369,19 +819,14 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
369
819
  // #2056/#2104: foreign-prefixed queries must not collapse to numeric phases.
370
820
  let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
371
821
  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 = {
822
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
823
+ const phaseName = rp['phase_name'];
824
+ return {
378
825
  found: true,
379
826
  directory: null,
380
- phase_number: roadmapPhase['phase_number'],
827
+ phase_number: rp['phase_number'],
381
828
  phase_name: phaseName,
382
- phase_slug: phaseName
383
- ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
384
- : null,
829
+ phase_slug: slugifyPhaseName(phaseName),
385
830
  plans: [],
386
831
  summaries: [],
387
832
  incomplete_plans: [],
@@ -390,7 +835,7 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
390
835
  has_verification: false,
391
836
  has_reviews: false,
392
837
  };
393
- }
838
+ });
394
839
  const reqMatch = roadmapPhase?.['section']?.match(REQUIREMENTS_HEADER_RE);
395
840
  const reqExtracted = reqMatch
396
841
  ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
@@ -511,9 +956,11 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
511
956
  /* intentionally empty */
512
957
  }
513
958
  }
959
+ // #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
960
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options, 'plan-phase');
514
961
  output(withProjectRoot(cwd, result), raw);
515
962
  }
516
- function cmdInitNewProject(cwd, raw) {
963
+ function cmdInitNewProject(cwd, raw, options = {}) {
517
964
  const config = loadConfig(cwd);
518
965
  const homedir = node_os_1.default.homedir();
519
966
  const braveKeyFile = node_path_1.default.join(homedir, '.gsd', 'brave_api_key');
@@ -552,9 +999,16 @@ function cmdInitNewProject(cwd, raw) {
552
999
  config_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'config.json')),
553
1000
  research_dir: toPosixPath(node_path_1.default.join(planningRoot(cwd), 'research')),
554
1001
  };
1002
+ // #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
1003
+ // needsCodebaseMap is threaded from this scope's own isBrownfield/hasCodebaseMap
1004
+ // computation (see `needs_codebase_map` above) so `state:needs-codebase-map` is
1005
+ // genuinely computed for this workflow, not left permanently false.
1006
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'new-project', {
1007
+ needsCodebaseMap: isBrownfield && !hasCodebaseMap,
1008
+ });
555
1009
  output(withProjectRoot(cwd, result), raw);
556
1010
  }
557
- function cmdInitNewMilestone(cwd, raw) {
1011
+ function cmdInitNewMilestone(cwd, raw, options = {}) {
558
1012
  const config = loadConfig(cwd);
559
1013
  const milestone = getMilestoneInfo(cwd);
560
1014
  const latestCompleted = getLatestCompletedMilestone(cwd);
@@ -601,9 +1055,32 @@ function cmdInitNewMilestone(cwd, raw) {
601
1055
  research_dir: toPosixPath(node_path_1.default.join(planningRoot(cwd), 'research')),
602
1056
  milestones_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'MILESTONES.md')),
603
1057
  };
1058
+ // `state:flat-mode` (#2994): whether NO workstream is active — the inverse
1059
+ // of `state:workstream-active` (introduced for `cmdInitTransition` below).
1060
+ // `new-milestone.md`'s Step 4 Part A (milestone-state write) runs ONLY in
1061
+ // flat mode; a workstream's own `.planning/workstreams/<name>/STATE.md`/
1062
+ // `ROADMAP.md`/`REQUIREMENTS.md` already carry the milestone state, so
1063
+ // writing the shared `## Current Milestone` heading here would clobber it
1064
+ // (#2308). The `when=` grammar has no negation operator (ADR-1671:69), so
1065
+ // Part A's condition — "skip when a workstream IS active" — cannot be
1066
+ // expressed by negating `state:workstream-active` in the marker; a
1067
+ // SEPARATE, positively-phrased atom whose fact is the inverse is the
1068
+ // sanctioned resolution (same discipline as `state:chunked-mode` folding
1069
+ // an OR — never an operator in the grammar itself). Same authoritative
1070
+ // source as `cmdInitTransition`: `GSD_WORKSTREAM` env, falling back to the
1071
+ // stored active-workstream pointer (mirrors `cmdInitProgress`'s own
1072
+ // resolution above).
1073
+ const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
1074
+ const workstreamActive = !!resolvedWorkstream;
1075
+ const flatMode = !workstreamActive;
1076
+ // #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
1077
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'new-milestone', {
1078
+ workstreamActive,
1079
+ flatMode,
1080
+ });
604
1081
  output(withProjectRoot(cwd, result), raw);
605
1082
  }
606
- function cmdInitQuick(cwd, description, raw) {
1083
+ function cmdInitQuick(cwd, description, raw, options = {}) {
607
1084
  const config = loadConfig(cwd);
608
1085
  const now = new Date();
609
1086
  const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null;
@@ -645,6 +1122,21 @@ function cmdInitQuick(cwd, description, raw) {
645
1122
  roadmap_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
646
1123
  planning_exists: node_fs_1.default.existsSync(planningRoot(cwd)),
647
1124
  };
1125
+ // #2994: `--full` IMPLIES `--discuss`/`--research`/`--validate` — resolved to
1126
+ // ONE set of facts HERE, in fact computation, never in the `when=` grammar
1127
+ // itself (mirrors `state:chunked-mode`'s disjunction fold at
1128
+ // `buildSectionManifestField`'s `chunkedMode` computation above). The three
1129
+ // implied tokens are folded into the flags BEFORE `buildSectionManifestField`
1130
+ // builds its `InvocationFacts.flags` Set, so `discussion-phase`/`research-phase`/
1131
+ // `plan-checker-loop`/`quick-verification` (all gated on their own single
1132
+ // `flag:--discuss`/`flag:--research`/`flag:--validate` atom) include correctly
1133
+ // for a bare `/gsd:quick --full` invocation that never passed the individual
1134
+ // tokens — the grammar still sees exactly one atom per marker, no OR.
1135
+ const sectionManifestOptions = options['full']
1136
+ ? { ...options, discuss: true, research: true, validate: true }
1137
+ : options;
1138
+ // #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
1139
+ result['section_manifest'] = buildSectionManifestField(cwd, null, sectionManifestOptions, 'quick');
648
1140
  output(withProjectRoot(cwd, result), raw);
649
1141
  }
650
1142
  function cmdInitIngestDocs(cwd, raw) {
@@ -710,33 +1202,23 @@ function cmdInitVerifyWork(cwd, phase, raw) {
710
1202
  const config = loadConfig(cwd);
711
1203
  const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
712
1204
  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
- }
1205
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1206
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
1207
+ const phaseName = rp['phase_name'];
1208
+ return {
1209
+ found: true,
1210
+ directory: null,
1211
+ phase_number: rp['phase_number'],
1212
+ phase_name: phaseName,
1213
+ phase_slug: slugifyPhaseName(phaseName),
1214
+ plans: [],
1215
+ summaries: [],
1216
+ incomplete_plans: [],
1217
+ has_research: false,
1218
+ has_context: false,
1219
+ has_verification: false,
1220
+ };
1221
+ });
740
1222
  const phaseDir = phaseInfo?.['directory'] || null;
741
1223
  const planCount = phaseInfo?.['plans']?.length || 0;
742
1224
  const summaryCount = phaseInfo?.['summaries']?.length || 0;
@@ -746,6 +1228,7 @@ function cmdInitVerifyWork(cwd, phase, raw) {
746
1228
  policy: { requireVerification: true },
747
1229
  })
748
1230
  : null;
1231
+ const uiPhaseActive = detectUiPhaseActive(cwd, phaseInfo);
749
1232
  const result = {
750
1233
  planner_model: resolveModelInternal(cwd, 'gsd-planner'),
751
1234
  checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
@@ -767,7 +1250,227 @@ function cmdInitVerifyWork(cwd, phase, raw) {
767
1250
  uat_blockers: uatReport?.blockers ?? [],
768
1251
  ready_to_transition: completion.phase_complete && (uatReport?.passed ?? false),
769
1252
  },
1253
+ // #2994 (resolver-hoist-guard G5): hoisted `state:ui-phase-active` ground
1254
+ // truth (previously re-derived inline inside the automated_ui_verification
1255
+ // step body via its own `gsd_run loop render-hooks plan:pre --raw` call —
1256
+ // a circular, self-disabling resolver, since the section is only read
1257
+ // when this same fact is already true). Resolved once here, exposed so
1258
+ // the step body can consume it directly instead of recomputing it.
1259
+ ui_phase_active: uiPhaseActive,
1260
+ };
1261
+ // #2994 (Phase 6.3): additive, optional field — degrades to null, never throws.
1262
+ // phaseInfo is passed through directly (mirrors cmdInitExecutePhase / cmdInitPlanPhase)
1263
+ // so buildSectionManifestField's internal detectPhaseMvpMode call gets a real
1264
+ // phase_number/directory rather than permanently-false facts. uiPhaseActive is
1265
+ // computed once above (not re-derived here) and threaded through via overrides,
1266
+ // mirroring the fallow/git-create-tag hoist pattern.
1267
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, {}, 'verify-work', {
1268
+ uiPhaseActive,
1269
+ });
1270
+ output(withProjectRoot(cwd, result), raw);
1271
+ }
1272
+ /**
1273
+ * `code-review.md`'s dedicated init entry point (#2994, epic #1671 Phase
1274
+ * 6.3). `code-review.md` previously routed through the shared, 20+-caller
1275
+ * `init.phase-op` (`cmdInitPhaseOp` below), reading only 6 of its fields
1276
+ * (`phase_found`, `phase_dir`, `phase_number`, `phase_name`, `padded_phase`,
1277
+ * `commit_docs` — verified against the workflow's own "Parse from init
1278
+ * JSON" line). `cmdInitPhaseOp` is CRITICAL blast radius (179 dependents
1279
+ * across 24 processes per the #2994 dispatch) and is never modified for
1280
+ * this — this function resolves phase info itself via the SAME shared
1281
+ * primitives `cmdInitPhaseOp` calls (`guardedFindPhase`/
1282
+ * `guardedGetRoadmapPhase`, plus the shared `applyRoadmapFallback` archived/
1283
+ * not-found fallback also used by execute-phase, plan-phase, verify-work
1284
+ * and review — see `applyRoadmapFallback`'s own doc comment; `cmdInitPhaseOp`
1285
+ * is deliberately excluded from that shared helper), producing the identical
1286
+ * 6-field shape rather than a second, hand-maintained copy of
1287
+ * `cmdInitPhaseOp`'s full ~60-field bundle. See
1288
+ * `tests/init-code-review-parity.test.cjs` for the DEFECT.GENERATIVE-FIX
1289
+ * parity guard between the two.
1290
+ *
1291
+ * Two further facts are resolved and exposed here that `init.phase-op`
1292
+ * never carried: the fallow structural-pre-pass config gate
1293
+ * (`detectFallowConfig`, `state:fallow-enabled`) and the `--fix` flag
1294
+ * (folded into `options` so `buildSectionManifestField` picks it up as
1295
+ * `flag:--fix`).
1296
+ */
1297
+ function cmdInitCodeReview(cwd, phase, raw, options = {}) {
1298
+ const config = loadConfig(cwd);
1299
+ let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
1300
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1301
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
1302
+ const rpName = rp['phase_name'];
1303
+ return {
1304
+ found: true,
1305
+ directory: null,
1306
+ phase_number: rp['phase_number'],
1307
+ phase_name: rpName,
1308
+ phase_slug: slugifyPhaseName(rpName),
1309
+ };
1310
+ });
1311
+ const phaseDir = phaseInfo?.['directory'] || null;
1312
+ const phaseNumber = phaseInfo?.['phase_number'] || null;
1313
+ const phaseName = phaseInfo?.['phase_name'] || null;
1314
+ const fallow = detectFallowConfig(cwd);
1315
+ const result = {
1316
+ commit_docs: config.commit_docs,
1317
+ phase_found: !!phaseInfo,
1318
+ // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
1319
+ phase_dir: phaseDir ? toPosixPath(node_path_1.default.join(cwd, phaseDir)) : null,
1320
+ phase_number: phaseNumber,
1321
+ phase_name: phaseName,
1322
+ padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
1323
+ // #2994: hoisted fallow config-gate resolution (previously re-derived
1324
+ // inline inside code-review.md's structural_pre_pass step body — a
1325
+ // circular self-disabling gate now resolved once here at init time).
1326
+ fallow_enabled: fallow.enabled,
1327
+ fallow_scope: fallow.scope,
1328
+ fallow_profile: fallow.profile,
1329
+ fallow_mcp: fallow.mcp,
1330
+ fallow_max_crap: fallow.maxCrap,
1331
+ };
1332
+ // #2994 (Phase 6.3): additive, optional field — degrades to null, never throws.
1333
+ const sectionManifestOptions = {
1334
+ ...options,
1335
+ fix: options['fix'] || undefined,
1336
+ };
1337
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, sectionManifestOptions, 'code-review', {
1338
+ fallowEnabled: fallow.enabled,
1339
+ });
1340
+ output(withProjectRoot(cwd, result), raw);
1341
+ }
1342
+ /**
1343
+ * `review.md`'s dedicated init entry point (#2994, epic #1671 Phase 6.3
1344
+ * amendment). `review.md` previously routed through the shared, 20+-caller
1345
+ * `init.phase-op` (`cmdInitPhaseOp` below), reading only 3 of its fields
1346
+ * (`phase_dir`, `phase_number`, `padded_phase` — verified against the
1347
+ * workflow's own "Read from init" line in `gather_context`). `cmdInitPhaseOp`
1348
+ * is CRITICAL blast radius (179 dependents across 24 processes) and is never
1349
+ * modified for this — this function resolves phase info itself via the SAME
1350
+ * shared primitives `cmdInitPhaseOp` calls (`guardedFindPhase`/
1351
+ * `guardedGetRoadmapPhase`), plus the shared `applyRoadmapFallback`
1352
+ * archived/not-found fallback (see its own doc comment), producing the
1353
+ * identical 3-field shape rather than a second, hand-maintained copy of
1354
+ * `cmdInitPhaseOp`'s full ~60-field bundle.
1355
+ *
1356
+ * One further fact is resolved and exposed here that `init.phase-op` never
1357
+ * carried: whether reviewer instances are configured
1358
+ * (`.planning/config.json`'s `review.reviewer_instances`, present AND
1359
+ * non-empty — `state:reviewer-instances-configured`), reusing
1360
+ * `readConfigJsonValue` (added for `detectFallowConfig`) rather than a
1361
+ * second, divergence-prone config reader (DEFECT.GENERATIVE-FIX).
1362
+ */
1363
+ function cmdInitReview(cwd, phase, raw, options = {}) {
1364
+ const config = loadConfig(cwd);
1365
+ let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
1366
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1367
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => ({
1368
+ found: true,
1369
+ directory: null,
1370
+ phase_number: rp['phase_number'],
1371
+ phase_name: rp['phase_name'],
1372
+ }));
1373
+ const phaseDir = phaseInfo?.['directory'] || null;
1374
+ const phaseNumber = phaseInfo?.['phase_number'] || null;
1375
+ // #2994: `state:reviewer-instances-configured` ground truth — present AND
1376
+ // non-empty `review.reviewer_instances` object. A missing key, a non-object
1377
+ // value, or an empty object all resolve to `false` (fail-closed, matching
1378
+ // the workflow's own pre-hoist prose gate — "Unconfigured -> default path
1379
+ // unchanged").
1380
+ const rawReviewerInstances = readConfigJsonValue(cwd, ['review', 'reviewer_instances']);
1381
+ const reviewerInstancesConfigured = rawReviewerInstances !== null &&
1382
+ typeof rawReviewerInstances === 'object' &&
1383
+ !Array.isArray(rawReviewerInstances) &&
1384
+ Object.keys(rawReviewerInstances).length > 0;
1385
+ const result = {
1386
+ // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
1387
+ phase_dir: phaseDir ? toPosixPath(node_path_1.default.join(cwd, phaseDir)) : null,
1388
+ phase_number: phaseNumber,
1389
+ padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
1390
+ };
1391
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options, 'review', {
1392
+ reviewerInstancesConfigured,
1393
+ });
1394
+ output(withProjectRoot(cwd, result), raw);
1395
+ }
1396
+ /**
1397
+ * `discuss-phase-assumptions.md`'s dedicated init entry point (#2994, epic
1398
+ * #1671 Phase 6.3 amendment). Previously routed through the shared,
1399
+ * 20+-caller `init.phase-op` (`cmdInitPhaseOp` below), reading 14 of its
1400
+ * fields (`commit_docs`, `phase_found`, `phase_dir`, `phase_number`,
1401
+ * `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`,
1402
+ * `has_plans`, `has_verification`, `plan_count`, `roadmap_exists`,
1403
+ * `planning_exists` — verified against the workflow's own "Parse JSON for"
1404
+ * line). `cmdInitPhaseOp` is CRITICAL blast radius (179 dependents across 24
1405
+ * processes) and is never modified for this — this function resolves phase
1406
+ * info itself via the SAME shared primitives `cmdInitPhaseOp` calls
1407
+ * (`guardedFindPhase`/`guardedGetRoadmapPhase`), plus the shared
1408
+ * `applyRoadmapFallback` archived/not-found fallback (see its own doc
1409
+ * comment) producing the identical fallback shape (`plans: []`,
1410
+ * `has_research: false`, `has_context: false`, `has_verification: false`)
1411
+ * rather than a second, hand-maintained copy of `cmdInitPhaseOp`'s full
1412
+ * ~60-field bundle.
1413
+ *
1414
+ * One further fact is resolved and exposed here that `init.phase-op` never
1415
+ * carried: `state:auto-advance-active` — the workflow's own `auto_advance`
1416
+ * step resolves `--auto` OR a consolidated `check auto-mode --pick active`
1417
+ * fact (itself `workflow._auto_chain_active` OR `workflow.auto_advance`) via
1418
+ * a runtime `gsd_run` call; that identical disjunction is folded into ONE
1419
+ * boolean FACT here (same discipline as `state:chunked-mode` /
1420
+ * `state:plan-strategy-converge`), exposed as `auto_advance_active`.
1421
+ */
1422
+ function cmdInitDiscussPhaseAssumptions(cwd, phase, raw, options = {}) {
1423
+ const config = loadConfig(cwd);
1424
+ let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
1425
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1426
+ phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
1427
+ const rpName = rp['phase_name'];
1428
+ return {
1429
+ found: true,
1430
+ directory: null,
1431
+ phase_number: rp['phase_number'],
1432
+ phase_name: rpName,
1433
+ phase_slug: slugifyPhaseName(rpName),
1434
+ plans: [],
1435
+ has_research: false,
1436
+ has_context: false,
1437
+ has_verification: false,
1438
+ };
1439
+ });
1440
+ const phaseDir = phaseInfo?.['directory'] || null;
1441
+ const phaseNumber = phaseInfo?.['phase_number'] || null;
1442
+ const phaseName = phaseInfo?.['phase_name'] || null;
1443
+ // #2994: mirrors discuss-phase-assumptions.md's own auto_advance step
1444
+ // resolver — `--auto` flag OR the consolidated `check auto-mode --pick
1445
+ // active` fact (workflow._auto_chain_active OR workflow.auto_advance).
1446
+ const autoAdvanceActive = options['auto'] === true ||
1447
+ readConfigJsonBoolean(cwd, ['workflow', '_auto_chain_active']) ||
1448
+ readConfigJsonBoolean(cwd, ['workflow', 'auto_advance']);
1449
+ const result = {
1450
+ commit_docs: config.commit_docs,
1451
+ phase_found: !!phaseInfo,
1452
+ // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
1453
+ phase_dir: phaseDir ? toPosixPath(node_path_1.default.join(cwd, phaseDir)) : null,
1454
+ phase_number: phaseNumber,
1455
+ phase_name: phaseName,
1456
+ phase_slug: phaseInfo?.['phase_slug'] || null,
1457
+ padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null,
1458
+ has_research: phaseInfo?.['has_research'] || false,
1459
+ has_context: phaseInfo?.['has_context'] || false,
1460
+ has_plans: (phaseInfo?.['plans']?.length || 0) > 0,
1461
+ has_verification: phaseInfo?.['has_verification'] || false,
1462
+ plan_count: phaseInfo?.['plans']?.length || 0,
1463
+ roadmap_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
1464
+ planning_exists: node_fs_1.default.existsSync(planningDir(cwd)),
1465
+ };
1466
+ // #2994 (Phase 6.3): additive, optional field — degrades to null, never throws.
1467
+ const sectionManifestOptions = {
1468
+ ...options,
1469
+ auto: options['auto'] || undefined,
770
1470
  };
1471
+ result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, sectionManifestOptions, 'discuss-phase-assumptions', {
1472
+ autoAdvanceActive,
1473
+ });
771
1474
  output(withProjectRoot(cwd, result), raw);
772
1475
  }
773
1476
  function cmdInitPhaseOp(cwd, phase, raw) {
@@ -1205,7 +1908,15 @@ function cmdInitManager(cwd, raw) {
1205
1908
  /* intentionally empty */
1206
1909
  }
1207
1910
  const roadmapComplete = _checkboxStates.get(phaseNum) || false;
1208
- if (roadmapComplete && completion.phase_complete && diskStatus !== 'complete') {
1911
+ // #3033: a zero-plan phase (split parent — intentionally plan-less, holds
1912
+ // shared context for sub-phases) whose roadmap checkbox is marked complete
1913
+ // must resolve as complete. The original gate required completion.phase_complete
1914
+ // (derived from plan/summary counts), which is always false for zero-plan
1915
+ // phases — so the checkbox override never fired and the parent was permanently
1916
+ // stuck as 'researched' (an in-progress state eligible for current-phase
1917
+ // selection). Now: when the roadmap marks it complete AND it has zero plans,
1918
+ // treat it as complete regardless of the plan-count derivation.
1919
+ if (roadmapComplete && (completion.phase_complete || planCount === 0) && diskStatus !== 'complete') {
1209
1920
  diskStatus = 'complete';
1210
1921
  }
1211
1922
  phases.push({
@@ -1405,7 +2116,222 @@ function cmdInitManager(cwd, raw) {
1405
2116
  };
1406
2117
  output(withProjectRoot(cwd, result), raw);
1407
2118
  }
1408
- function cmdInitProgress(cwd, raw) {
2119
+ /**
2120
+ * `complete-milestone.md`'s dedicated init entry point (#2994, epic #1671
2121
+ * Phase 6.3). Additive alongside the workflow's existing `init.manager`
2122
+ * (readiness/phase-projection, `cmdInitManager` above — CRITICAL blast
2123
+ * radius, never modified) and `init.execute-phase` (branching-strategy
2124
+ * fields) calls; `cmdInitCompleteMilestone` carries NO phase-listing logic
2125
+ * of its own to delegate — its only job is the `git.create_tag` config-gate
2126
+ * fact the `git_tag` step's `<config-check>` sub-tag used to re-derive
2127
+ * inline (gating the step's own inclusion on a fact only that step
2128
+ * computed), now hoisted here and exposed as `git_create_tag`, plus the
2129
+ * `section_manifest` field neither `init.manager` nor `init.execute-phase`
2130
+ * carries.
2131
+ */
2132
+ function cmdInitCompleteMilestone(cwd, raw, options = {}) {
2133
+ const gitCreateTag = detectGitCreateTag(cwd);
2134
+ const result = {
2135
+ // #2994: hoisted from complete-milestone.md's git_tag step
2136
+ // <config-check> resolver (git.create_tag, fail-open default true).
2137
+ git_create_tag: gitCreateTag,
2138
+ };
2139
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'complete-milestone', {
2140
+ gitCreateTag,
2141
+ });
2142
+ output(withProjectRoot(cwd, result), raw);
2143
+ }
2144
+ /**
2145
+ * `autonomous.md`'s dedicated init entry point (#2994, epic #1671 Phase
2146
+ * 6.3). Additive alongside the workflow's existing `init.milestone-op`
2147
+ * (`cmdInitMilestoneOp`), `init.manager` (`cmdInitManager`), and
2148
+ * `init.phase-op` (`cmdInitPhaseOp`) calls — all three are CRITICAL blast
2149
+ * radius (179 dependents across 24 processes) and are never modified for
2150
+ * this; `autonomous.md` keeps every one of those calls exactly as it had
2151
+ * them. `cmdInitAutonomous` carries NO phase-listing logic of its own to
2152
+ * delegate — its only job is the `PLAN_STRATEGY` disjunction the workflow's
2153
+ * own bash resolver (`PLAN_STRATEGY="converge"` on `--converge` OR
2154
+ * `--cross-ai`) already computes at the top of the `initialize` step, now
2155
+ * mirrored here as a single boolean FACT (same discipline as
2156
+ * `state:chunked-mode`/`state:ui-phase-active`: the disjunction is resolved
2157
+ * ONCE, in fact computation, never in the `when=` grammar), exposed as
2158
+ * `plan_strategy_converge`, plus the `section_manifest` field none of the
2159
+ * three existing calls carries.
2160
+ */
2161
+ function cmdInitAutonomous(cwd, raw, options = {}) {
2162
+ const planStrategyConverge = options['converge'] === true || options['cross-ai'] === true;
2163
+ const result = {
2164
+ // #2994: mirrors autonomous.md's own PLAN_STRATEGY resolver
2165
+ // (--converge OR its documented alias --cross-ai).
2166
+ plan_strategy_converge: planStrategyConverge,
2167
+ };
2168
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'autonomous', {
2169
+ planStrategyConverge,
2170
+ });
2171
+ output(withProjectRoot(cwd, result), raw);
2172
+ }
2173
+ /**
2174
+ * `docs-update.md`'s dedicated init entry point (#2994, epic #1671 Phase
2175
+ * 6.3 — final slice). `docs-update.md` previously carried NO `gsd_run query
2176
+ * init.*` call at all: its own `docs-init` command (`cmdDocsInit`,
2177
+ * src/docs.cts) is a SEPARATE, pre-existing entry point outside this
2178
+ * `init.*` family and is left untouched here. This function's only job is
2179
+ * the `section_manifest` field neither `docs-init` nor any other call
2180
+ * carries, gating `docs-update.md`'s `dispatch-monorepo-packages` section.
2181
+ *
2182
+ * `state:is-monorepo` ground truth: the project's monorepo workspaces list
2183
+ * is non-empty — reuses `detectMonorepoWorkspaces` (src/docs.cts, exported
2184
+ * for this purpose) rather than a second, divergence-prone workspace-glob
2185
+ * scan (DEFECT.GENERATIVE-FIX dual surface); this is the SAME detector that
2186
+ * already backs `docs-init`'s own `monorepo_workspaces` field.
2187
+ */
2188
+ function cmdInitDocsUpdate(cwd, raw, options = {}) {
2189
+ const isMonorepo = detectMonorepoWorkspaces(cwd).length > 0;
2190
+ const result = {};
2191
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'docs-update', {
2192
+ isMonorepo,
2193
+ });
2194
+ output(withProjectRoot(cwd, result), raw);
2195
+ }
2196
+ /**
2197
+ * `update.md`'s dedicated init entry point (#2994, epic #1671 Phase 6.3 —
2198
+ * final slice). `update.md` previously carried NO `gsd_run query init.*`
2199
+ * call at all: it resolves `gsd-tools.cjs` itself (its own bespoke
2200
+ * `PREFERRED_CONFIG_DIR`/`PREFERRED_RUNTIME`-aware `$GSD_TOOLS` cascade,
2201
+ * `update.md` ~lines 13-45) because the update workflow must run BEFORE any
2202
+ * install can be assumed resolvable — the canonical launcher preamble's
2203
+ * fixed candidate list is not a substitute for that cascade, and both
2204
+ * resolutions assign the identical `$GSD_TOOLS` shell variable, so copying
2205
+ * the canonical preamble in ADDITION to the existing cascade would silently
2206
+ * clobber the value `backup_custom_files`/`restore_custom_files` (later
2207
+ * steps) still depend on. This function is invoked via that ALREADY
2208
+ * resolved `$GSD_TOOLS`, not a redundant `gsd_run()` shell function.
2209
+ *
2210
+ * `state:next-channel` ground truth: `--next` OR its documented alias
2211
+ * `--rc` (same disjunction-to-one-boolean discipline as
2212
+ * `state:chunked-mode`/`state:plan-strategy-converge`). This DELIBERATELY
2213
+ * does not replace `update.md`'s own `parse_update_channel` case-statement
2214
+ * (`TAG="next"`/`TAG="latest"`) — issue #815's regression test
2215
+ * (`tests/issue-815-update-next-channel.test.cjs`) asserts that literal
2216
+ * case-statement text stays in `update.md` verbatim (the npm dist-tag
2217
+ * selection has to run in the workflow's own shell before any `gsd_run`
2218
+ * round-trip), so `next_channel` exists purely to gate the `channel-banner`
2219
+ * section's admission — a parallel, consistent-but-not-replacing
2220
+ * resolution of the same flags.
2221
+ */
2222
+ function cmdInitUpdate(cwd, raw, options = {}) {
2223
+ const nextChannel = options['next'] === true || options['rc'] === true;
2224
+ const result = {
2225
+ next_channel: nextChannel,
2226
+ };
2227
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'update', {
2228
+ nextChannel,
2229
+ });
2230
+ output(withProjectRoot(cwd, result), raw);
2231
+ }
2232
+ /**
2233
+ * `transition.md`'s dedicated init entry point (#2994, epic #1671 Phase
2234
+ * 6.3 — final slice). `transition.md` is an internal workflow (no
2235
+ * user-facing `/gsd-transition` command) that previously carried NO
2236
+ * `gsd_run query init.*` call at all; it already establishes `gsd_run()`
2237
+ * via the canonical launcher preamble in its `update_roadmap_and_state`
2238
+ * step (before this call's insertion point in `offer_next_phase`), so no
2239
+ * second preamble copy is needed in the host file.
2240
+ *
2241
+ * `state:workstream-active` ground truth: a workstream is active — resolved
2242
+ * via `GSD_WORKSTREAM` env, falling back to the stored active-workstream
2243
+ * pointer (mirrors `cmdInitProgress`'s own `_resolvedWorkstream` resolution
2244
+ * above, the established authoritative source for "is a workstream active"
2245
+ * in this file).
2246
+ *
2247
+ * `other_active_workstreams` hoists the resolver-in-body hazard out of
2248
+ * `transition.md`'s `workstream-collision-check` step: that step's body
2249
+ * previously re-derived this via an inline `gsd_run query workstream.list
2250
+ * --raw` call gated on the identical `if [ -n "$GSD_WORKSTREAM" ]`
2251
+ * condition that now backs this section's OWN admission — resolving it here
2252
+ * instead reuses `getOtherActiveWorkstreamInventories` (src/workstream-
2253
+ * inventory.cts), the SAME primitive `workstream.list` itself calls
2254
+ * (`cmdWorkstreamList`, src/workstream.cts), pre-filtered exactly as the
2255
+ * step's own prose described (excludes the current workstream and any
2256
+ * workstream whose status contains "milestone complete" or "archived",
2257
+ * case-insensitively — `isCompletedInventory`), so the step body becomes a
2258
+ * pure JSON consumer with no `gsd_run` call of its own.
2259
+ */
2260
+ function cmdInitTransition(cwd, raw, options = {}) {
2261
+ const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
2262
+ const workstreamActive = !!resolvedWorkstream;
2263
+ const result = {
2264
+ other_active_workstreams: workstreamActive
2265
+ ? getOtherActiveWorkstreamInventories(cwd, resolvedWorkstream).map((inv) => ({
2266
+ name: inv.name,
2267
+ status: inv.status,
2268
+ }))
2269
+ : [],
2270
+ };
2271
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'transition', {
2272
+ workstreamActive,
2273
+ });
2274
+ output(withProjectRoot(cwd, result), raw);
2275
+ }
2276
+ /**
2277
+ * `debug.md`'s dedicated init entry point (#3149; prerequisite for #3128).
2278
+ * `debug.md` previously carried NO `gsd_run query init.*` call at all — it made
2279
+ * THREE separate round-trips instead: `state.load` (for `commit_docs`,
2280
+ * `config.response_language` and `debug_dir`), `resolve-model gsd-debugger
2281
+ * --pick model`, and `config-get workflow.tdd_mode --raw`. Because no
2282
+ * debug-scoped fact was computed at any entry point, a `when=` atom naming one
2283
+ * would have evaluated FALSE forever — ADR-1671's admission gate (2) and the
2284
+ * silent-exclusion bug it exists to prevent (`docs/adr/1671-…:122-131`).
2285
+ *
2286
+ * Every field is resolved through the SAME primitive the call it replaces used,
2287
+ * never a second hand-maintained copy (DEFECT.GENERATIVE-FIX):
2288
+ *
2289
+ * - `commit_docs` — `loadConfig`, the same loader `cmdStateLoad` calls.
2290
+ * - `response_language` — NOT read here: `withProjectRoot` already injects it
2291
+ * when configured (#2402), which is also the shape sibling init bundles use.
2292
+ * It is absent, not null, when unset.
2293
+ * - `debug_dir` — `planningPaths(cwd).debug`, the SAME expression `cmdStateLoad`
2294
+ * now uses; the `debug` field was added to `PlanningPaths` (#3149) so the
2295
+ * location has one source instead of two kept in sync by hand.
2296
+ * - `debugger_model` — `resolveModelInternal`, which IS what `query
2297
+ * resolve-model --pick model` returns (`cmdResolveModel`, src/commands.cts).
2298
+ * - `tdd_mode` — the `Boolean(wf['tdd_mode'])` idiom `cmdInitExecutePhase` and
2299
+ * `cmdInitPlanPhase` already use. `/gsd:debug` has no `--tdd` flag, so the
2300
+ * sibling handlers' `options['tdd'] ||` disjunct is deliberately omitted
2301
+ * rather than carried as a phantom.
2302
+ *
2303
+ * `state.load` is deliberately NOT narrowed — see the note beside its own
2304
+ * `debug_dir` field. This handler is purely additive alongside it.
2305
+ *
2306
+ * `diagnose` is the one flag `/gsd:debug` already documents. Exposing it as a
2307
+ * top-level fact follows `cmdInitUpdate`'s `next_channel` and
2308
+ * `cmdInitAutonomous`'s `plan_strategy_converge` precedent, and is what makes
2309
+ * the router's flag forwarding observable. No `when=` atom consumes it yet:
2310
+ * admission gate (1) — a consuming section of at least 400 bytes — is #3128's
2311
+ * to satisfy, and shipping the atom before its section is the same
2312
+ * silent-exclusion bug from the other direction.
2313
+ */
2314
+ function cmdInitDebug(cwd, raw, options = {}) {
2315
+ const config = loadConfig(cwd);
2316
+ const wf = (config.workflow ?? {});
2317
+ const result = {
2318
+ commit_docs: config.commit_docs,
2319
+ // #2376: absolute — debug.md builds `debug_file_path` as
2320
+ // `{debug_dir}/{slug}.md` for its gsd-debug-session-manager spawns, whose
2321
+ // own cwd may differ from the orchestrator's.
2322
+ debug_dir: toPosixPath(planningPaths(cwd).debug),
2323
+ debugger_model: resolveModelInternal(cwd, 'gsd-debugger'),
2324
+ tdd_mode: Boolean(wf['tdd_mode']),
2325
+ diagnose: options['diagnose'] === true,
2326
+ };
2327
+ // Additive, optional field — degrades to null while `debug` has no key in
2328
+ // `gsd-core/workflows/section-manifest.json` (it has no `gsd:section` markers
2329
+ // until #3128). null means "read everything", which is NOT the same as a
2330
+ // computed empty selection.
2331
+ result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'debug', {});
2332
+ output(withProjectRoot(cwd, result), raw);
2333
+ }
2334
+ function cmdInitProgress(cwd, raw, options = {}) {
1409
2335
  try {
1410
2336
  pruneOrphanedWorktrees(cwd);
1411
2337
  }
@@ -1545,6 +2471,19 @@ function cmdInitProgress(cwd, raw) {
1545
2471
  if (pauseMatch)
1546
2472
  pausedAt = pauseMatch[1].trim();
1547
2473
  }
2474
+ // #2994: the CURRENT phase's number, used both to expose `phase_mvp_mode`
2475
+ // at the top level (so the `mvp-display` step body can consume an
2476
+ // already-resolved fact instead of re-invoking `gsd_run query
2477
+ // phase.mvp-mode` itself — that inline resolver would otherwise gate a
2478
+ // section on a fact the section's own body recomputes, which is circular
2479
+ // and self-disabling) and to thread a real `phase_number` into
2480
+ // `buildSectionManifestField` below so `state:phase-mvp-mode` is genuinely
2481
+ // computed for this workflow rather than permanently false (the previous
2482
+ // `buildSectionManifestField(cwd, null, ...)` call passed no phase info at
2483
+ // all, so `detectPhaseMvpMode` always short-circuited on the `!phaseNumber`
2484
+ // guard).
2485
+ const currentPhaseNumber = currentPhase?.['number'] ?? null;
2486
+ const phaseMvpMode = detectPhaseMvpMode(cwd, currentPhaseNumber);
1548
2487
  const result = {
1549
2488
  executor_model: resolveModelInternal(cwd, 'gsd-executor'),
1550
2489
  planner_model: resolveModelInternal(cwd, 'gsd-planner'),
@@ -1559,6 +2498,7 @@ function cmdInitProgress(cwd, raw) {
1559
2498
  next_phase: nextPhase,
1560
2499
  paused_at: pausedAt,
1561
2500
  has_work_in_progress: !!currentPhase,
2501
+ phase_mvp_mode: phaseMvpMode,
1562
2502
  project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
1563
2503
  roadmap_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
1564
2504
  state_exists: node_fs_1.default.existsSync(node_path_1.default.join(planningDir(cwd), 'STATE.md')),
@@ -1568,6 +2508,8 @@ function cmdInitProgress(cwd, raw) {
1568
2508
  project_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'PROJECT.md')),
1569
2509
  config_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'config.json')),
1570
2510
  };
2511
+ // #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
2512
+ result['section_manifest'] = buildSectionManifestField(cwd, currentPhaseNumber ? { phase_number: currentPhaseNumber } : null, options, 'progress');
1571
2513
  output(withProjectRoot(cwd, result), raw);
1572
2514
  }
1573
2515
  function detectChildRepos(dir) {
@@ -1822,7 +2764,20 @@ function buildAgentSkillsBlock(config, agentType, projectRoot, diagnostics) {
1822
2764
  }
1823
2765
  const skillMdPath = node_path_1.default.join(projectRoot, skillPath, 'SKILL.md');
1824
2766
  if (!node_fs_1.default.existsSync(skillMdPath)) {
1825
- warn(`[agent-skills] WARNING: Skill not found at "${skillPath}/SKILL.md" — skipping\n`);
2767
+ // #2941: if the bare name matches a global skill, hint at the global: prefix.
2768
+ // The bare name resolves as project-relative (which doesn't exist), but the
2769
+ // user likely meant to reference a global skill. getGlobalSkillDir is already
2770
+ // imported for the global: branch above; guard on globalSkillsBase being non-null
2771
+ // since runtimes without a skills directory don't support the prefix.
2772
+ let hint = '';
2773
+ if (globalSkillsBase !== null) {
2774
+ const baseName = node_path_1.default.basename(skillPath);
2775
+ const globalDir = (0, runtime_homes_cjs_1.getGlobalSkillDir)(runtime, baseName);
2776
+ if (globalDir && node_fs_1.default.existsSync(node_path_1.default.join(globalDir, 'SKILL.md'))) {
2777
+ hint = ` — a global skill named "${baseName}" exists; use "global:${baseName}" to reference it`;
2778
+ }
2779
+ }
2780
+ warn(`[agent-skills] WARNING: Skill not found at "${skillPath}/SKILL.md"${hint} — skipping\n`);
1826
2781
  continue;
1827
2782
  }
1828
2783
  validEntries.push({ kind: 'include', ref: `${skillPath}/SKILL.md`, display: skillPath });
@@ -1866,7 +2821,7 @@ function cmdAgentSkills(cwd, agentType, raw, jsonMode) {
1866
2821
  if (!block) {
1867
2822
  const runtime = (config && config['runtime']) || process.env['GSD_RUNTIME'] || 'claude';
1868
2823
  if (runtime !== 'claude') {
1869
- const agentCheck = checkAgentsInstalled(runtime);
2824
+ const agentCheck = checkAgentsInstalled(runtime, projectRoot);
1870
2825
  const agentsDir = agentCheck?.agents_dir;
1871
2826
  if (typeof agentsDir === 'string' && agentsDir.length > 0) {
1872
2827
  const agentFile = node_path_1.default.join(agentsDir, `${agentType}.md`);
@@ -2198,11 +3153,20 @@ module.exports = {
2198
3153
  cmdInitResume,
2199
3154
  cmdInitVerifyWork,
2200
3155
  cmdInitPhaseOp,
3156
+ cmdInitCodeReview,
3157
+ cmdInitReview,
3158
+ cmdInitDiscussPhaseAssumptions,
2201
3159
  cmdInitTodos,
2202
3160
  cmdInitMilestoneOp,
2203
3161
  cmdInitMapCodebase,
2204
3162
  cmdInitProgress,
2205
3163
  cmdInitManager,
3164
+ cmdInitCompleteMilestone,
3165
+ cmdInitAutonomous,
3166
+ cmdInitDocsUpdate,
3167
+ cmdInitUpdate,
3168
+ cmdInitTransition,
3169
+ cmdInitDebug,
2206
3170
  cmdInitNewWorkspace,
2207
3171
  cmdInitListWorkspaces,
2208
3172
  cmdInitRemoveWorkspace,