@opengsd/gsd-core 1.2.0-rc.1

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 (503) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja-JP.md +870 -0
  3. package/README.ko-KR.md +861 -0
  4. package/README.md +301 -0
  5. package/README.pt-BR.md +492 -0
  6. package/README.zh-CN.md +842 -0
  7. package/agents/gsd-advisor-researcher.md +127 -0
  8. package/agents/gsd-ai-researcher.md +133 -0
  9. package/agents/gsd-assumptions-analyzer.md +105 -0
  10. package/agents/gsd-code-fixer.md +668 -0
  11. package/agents/gsd-code-reviewer.md +387 -0
  12. package/agents/gsd-codebase-mapper.md +853 -0
  13. package/agents/gsd-debug-session-manager.md +314 -0
  14. package/agents/gsd-debugger.md +1452 -0
  15. package/agents/gsd-doc-classifier.md +168 -0
  16. package/agents/gsd-doc-synthesizer.md +204 -0
  17. package/agents/gsd-doc-verifier.md +217 -0
  18. package/agents/gsd-doc-writer.md +615 -0
  19. package/agents/gsd-domain-researcher.md +153 -0
  20. package/agents/gsd-eval-auditor.md +191 -0
  21. package/agents/gsd-eval-planner.md +154 -0
  22. package/agents/gsd-executor.md +772 -0
  23. package/agents/gsd-framework-selector.md +160 -0
  24. package/agents/gsd-integration-checker.md +470 -0
  25. package/agents/gsd-intel-updater.md +342 -0
  26. package/agents/gsd-nyquist-auditor.md +203 -0
  27. package/agents/gsd-pattern-mapper.md +335 -0
  28. package/agents/gsd-phase-researcher.md +928 -0
  29. package/agents/gsd-plan-checker.md +978 -0
  30. package/agents/gsd-planner.md +1218 -0
  31. package/agents/gsd-project-researcher.md +677 -0
  32. package/agents/gsd-research-synthesizer.md +255 -0
  33. package/agents/gsd-roadmapper.md +688 -0
  34. package/agents/gsd-security-auditor.md +155 -0
  35. package/agents/gsd-ui-auditor.md +495 -0
  36. package/agents/gsd-ui-checker.md +309 -0
  37. package/agents/gsd-ui-researcher.md +380 -0
  38. package/agents/gsd-user-profiler.md +171 -0
  39. package/agents/gsd-verifier.md +917 -0
  40. package/bin/install.js +10936 -0
  41. package/bin/lib/ui-safety-gate.cjs +107 -0
  42. package/commands/gsd/add-tests.md +42 -0
  43. package/commands/gsd/ai-integration-phase.md +37 -0
  44. package/commands/gsd/audit-fix.md +34 -0
  45. package/commands/gsd/audit-milestone.md +37 -0
  46. package/commands/gsd/audit-uat.md +24 -0
  47. package/commands/gsd/autonomous.md +46 -0
  48. package/commands/gsd/capture.md +62 -0
  49. package/commands/gsd/cleanup.md +24 -0
  50. package/commands/gsd/code-review.md +59 -0
  51. package/commands/gsd/complete-milestone.md +143 -0
  52. package/commands/gsd/config.md +56 -0
  53. package/commands/gsd/debug.md +52 -0
  54. package/commands/gsd/discuss-phase.md +76 -0
  55. package/commands/gsd/docs-update.md +49 -0
  56. package/commands/gsd/eval-review.md +33 -0
  57. package/commands/gsd/execute-phase.md +64 -0
  58. package/commands/gsd/explore.md +27 -0
  59. package/commands/gsd/extract-learnings.md +23 -0
  60. package/commands/gsd/fast.md +31 -0
  61. package/commands/gsd/forensics.md +57 -0
  62. package/commands/gsd/graphify.md +199 -0
  63. package/commands/gsd/health.md +31 -0
  64. package/commands/gsd/help.md +28 -0
  65. package/commands/gsd/import.md +41 -0
  66. package/commands/gsd/inbox.md +39 -0
  67. package/commands/gsd/ingest-docs.md +42 -0
  68. package/commands/gsd/manager.md +45 -0
  69. package/commands/gsd/map-codebase.md +83 -0
  70. package/commands/gsd/milestone-summary.md +51 -0
  71. package/commands/gsd/mvp-phase.md +45 -0
  72. package/commands/gsd/new-milestone.md +45 -0
  73. package/commands/gsd/new-project.md +47 -0
  74. package/commands/gsd/ns-context.md +23 -0
  75. package/commands/gsd/ns-ideate.md +24 -0
  76. package/commands/gsd/ns-manage.md +29 -0
  77. package/commands/gsd/ns-project.md +22 -0
  78. package/commands/gsd/ns-review.md +26 -0
  79. package/commands/gsd/ns-workflow.md +28 -0
  80. package/commands/gsd/pause-work.md +43 -0
  81. package/commands/gsd/phase.md +56 -0
  82. package/commands/gsd/plan-phase.md +62 -0
  83. package/commands/gsd/plan-review-convergence.md +59 -0
  84. package/commands/gsd/pr-branch.md +26 -0
  85. package/commands/gsd/profile-user.md +46 -0
  86. package/commands/gsd/progress.md +47 -0
  87. package/commands/gsd/quick.md +174 -0
  88. package/commands/gsd/resume-work.md +30 -0
  89. package/commands/gsd/review-backlog.md +63 -0
  90. package/commands/gsd/review.md +41 -0
  91. package/commands/gsd/secure-phase.md +36 -0
  92. package/commands/gsd/settings.md +29 -0
  93. package/commands/gsd/ship.md +24 -0
  94. package/commands/gsd/sketch.md +60 -0
  95. package/commands/gsd/spec-phase.md +63 -0
  96. package/commands/gsd/spike.md +57 -0
  97. package/commands/gsd/stats.md +19 -0
  98. package/commands/gsd/surface.md +155 -0
  99. package/commands/gsd/thread.md +24 -0
  100. package/commands/gsd/ui-phase.md +35 -0
  101. package/commands/gsd/ui-review.md +33 -0
  102. package/commands/gsd/ultraplan-phase.md +34 -0
  103. package/commands/gsd/undo.md +35 -0
  104. package/commands/gsd/update.md +48 -0
  105. package/commands/gsd/validate-phase.md +36 -0
  106. package/commands/gsd/verify-work.md +39 -0
  107. package/commands/gsd/workspace.md +52 -0
  108. package/commands/gsd/workstreams.md +70 -0
  109. package/get-shit-done/bin/check-latest-version.cjs +106 -0
  110. package/get-shit-done/bin/gsd-tools.cjs +1676 -0
  111. package/get-shit-done/bin/lib/active-workstream-store.cjs +302 -0
  112. package/get-shit-done/bin/lib/adr-parser.cjs +394 -0
  113. package/get-shit-done/bin/lib/agent-command-router.cjs +65 -0
  114. package/get-shit-done/bin/lib/artifacts.cjs +53 -0
  115. package/get-shit-done/bin/lib/audit.cjs +755 -0
  116. package/get-shit-done/bin/lib/check-command-router.cjs +333 -0
  117. package/get-shit-done/bin/lib/cjs-command-router-adapter.cjs +118 -0
  118. package/get-shit-done/bin/lib/clock.cjs +96 -0
  119. package/get-shit-done/bin/lib/clusters.cjs +135 -0
  120. package/get-shit-done/bin/lib/code-review-flags.cjs +74 -0
  121. package/get-shit-done/bin/lib/command-aliases.cjs +815 -0
  122. package/get-shit-done/bin/lib/command-arg-projection.cjs +62 -0
  123. package/get-shit-done/bin/lib/command-routing-hub.cjs +388 -0
  124. package/get-shit-done/bin/lib/commands.cjs +1188 -0
  125. package/get-shit-done/bin/lib/config-schema.cjs +31 -0
  126. package/get-shit-done/bin/lib/config.cjs +728 -0
  127. package/get-shit-done/bin/lib/configuration.cjs +248 -0
  128. package/get-shit-done/bin/lib/context-utilization.cjs +47 -0
  129. package/get-shit-done/bin/lib/core.cjs +2121 -0
  130. package/get-shit-done/bin/lib/decisions.cjs +116 -0
  131. package/get-shit-done/bin/lib/docs.cjs +270 -0
  132. package/get-shit-done/bin/lib/drift.cjs +388 -0
  133. package/get-shit-done/bin/lib/fallow-runner.cjs +109 -0
  134. package/get-shit-done/bin/lib/frontmatter.cjs +389 -0
  135. package/get-shit-done/bin/lib/gap-checker.cjs +205 -0
  136. package/get-shit-done/bin/lib/graphify.cjs +592 -0
  137. package/get-shit-done/bin/lib/gsd2-import.cjs +514 -0
  138. package/get-shit-done/bin/lib/init-command-router.cjs +58 -0
  139. package/get-shit-done/bin/lib/init.cjs +2112 -0
  140. package/get-shit-done/bin/lib/install-profiles.cjs +603 -0
  141. package/get-shit-done/bin/lib/installer-migration-authoring.cjs +117 -0
  142. package/get-shit-done/bin/lib/installer-migration-report.cjs +354 -0
  143. package/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs +220 -0
  144. package/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs +41 -0
  145. package/get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs +80 -0
  146. package/get-shit-done/bin/lib/installer-migrations.cjs +778 -0
  147. package/get-shit-done/bin/lib/intel.cjs +708 -0
  148. package/get-shit-done/bin/lib/learnings.cjs +421 -0
  149. package/get-shit-done/bin/lib/milestone.cjs +314 -0
  150. package/get-shit-done/bin/lib/model-catalog.cjs +212 -0
  151. package/get-shit-done/bin/lib/model-profiles.cjs +31 -0
  152. package/get-shit-done/bin/lib/observability/event.cjs +82 -0
  153. package/get-shit-done/bin/lib/observability/logger.cjs +174 -0
  154. package/get-shit-done/bin/lib/observability/redaction.cjs +50 -0
  155. package/get-shit-done/bin/lib/package-identity.cjs +31 -0
  156. package/get-shit-done/bin/lib/phase-command-router.cjs +191 -0
  157. package/get-shit-done/bin/lib/phase-lifecycle.cjs +80 -0
  158. package/get-shit-done/bin/lib/phase.cjs +1607 -0
  159. package/get-shit-done/bin/lib/phases-command-router.cjs +39 -0
  160. package/get-shit-done/bin/lib/plan-scan.cjs +97 -0
  161. package/get-shit-done/bin/lib/planning-workspace.cjs +238 -0
  162. package/get-shit-done/bin/lib/profile-output.cjs +1141 -0
  163. package/get-shit-done/bin/lib/profile-pipeline.cjs +539 -0
  164. package/get-shit-done/bin/lib/project-root.cjs +112 -0
  165. package/get-shit-done/bin/lib/prompt-budget.cjs +399 -0
  166. package/get-shit-done/bin/lib/review-reviewer-selection.cjs +125 -0
  167. package/get-shit-done/bin/lib/roadmap-command-router.cjs +28 -0
  168. package/get-shit-done/bin/lib/roadmap.cjs +650 -0
  169. package/get-shit-done/bin/lib/runtime-artifact-layout.cjs +301 -0
  170. package/get-shit-done/bin/lib/runtime-homes.cjs +222 -0
  171. package/get-shit-done/bin/lib/runtime-name-policy.cjs +83 -0
  172. package/get-shit-done/bin/lib/runtime-slash.cjs +112 -0
  173. package/get-shit-done/bin/lib/schema-detect.cjs +165 -0
  174. package/get-shit-done/bin/lib/secrets.cjs +32 -0
  175. package/get-shit-done/bin/lib/security.cjs +600 -0
  176. package/get-shit-done/bin/lib/semver-compare.cjs +35 -0
  177. package/get-shit-done/bin/lib/shell-command-projection.cjs +500 -0
  178. package/get-shit-done/bin/lib/state-command-router.cjs +252 -0
  179. package/get-shit-done/bin/lib/state-document.cjs +263 -0
  180. package/get-shit-done/bin/lib/state.cjs +2038 -0
  181. package/get-shit-done/bin/lib/surface.cjs +470 -0
  182. package/get-shit-done/bin/lib/task-command-router.cjs +81 -0
  183. package/get-shit-done/bin/lib/template.cjs +228 -0
  184. package/get-shit-done/bin/lib/uat.cjs +289 -0
  185. package/get-shit-done/bin/lib/update-context.cjs +209 -0
  186. package/get-shit-done/bin/lib/validate-command-router.cjs +83 -0
  187. package/get-shit-done/bin/lib/validate.cjs +92 -0
  188. package/get-shit-done/bin/lib/verify-command-router.cjs +40 -0
  189. package/get-shit-done/bin/lib/verify.cjs +1511 -0
  190. package/get-shit-done/bin/lib/workstream-inventory-builder.cjs +74 -0
  191. package/get-shit-done/bin/lib/workstream-inventory.cjs +146 -0
  192. package/get-shit-done/bin/lib/workstream-name-policy.cjs +94 -0
  193. package/get-shit-done/bin/lib/workstream.cjs +389 -0
  194. package/get-shit-done/bin/lib/worktree-safety.cjs +985 -0
  195. package/get-shit-done/bin/shared/config-defaults.manifest.json +97 -0
  196. package/get-shit-done/bin/shared/config-schema.manifest.json +175 -0
  197. package/get-shit-done/bin/shared/model-catalog.json +122 -0
  198. package/get-shit-done/bin/shared/runtime-aliases.manifest.json +75 -0
  199. package/get-shit-done/bin/verify-reapply-patches.cjs +352 -0
  200. package/get-shit-done/contexts/dev.md +21 -0
  201. package/get-shit-done/contexts/research.md +22 -0
  202. package/get-shit-done/contexts/review.md +23 -0
  203. package/get-shit-done/references/agent-contracts.md +79 -0
  204. package/get-shit-done/references/ai-evals.md +156 -0
  205. package/get-shit-done/references/ai-frameworks.md +186 -0
  206. package/get-shit-done/references/artifact-types.md +131 -0
  207. package/get-shit-done/references/autonomous-smart-discuss.md +277 -0
  208. package/get-shit-done/references/checkpoints.md +814 -0
  209. package/get-shit-done/references/common-bug-patterns.md +114 -0
  210. package/get-shit-done/references/context-budget.md +85 -0
  211. package/get-shit-done/references/continuation-format.md +253 -0
  212. package/get-shit-done/references/debugger-philosophy.md +76 -0
  213. package/get-shit-done/references/decimal-phase-calculation.md +64 -0
  214. package/get-shit-done/references/doc-conflict-engine.md +91 -0
  215. package/get-shit-done/references/domain-probes.md +125 -0
  216. package/get-shit-done/references/execute-mvp-tdd.md +81 -0
  217. package/get-shit-done/references/executor-examples.md +110 -0
  218. package/get-shit-done/references/few-shot-examples/plan-checker.md +73 -0
  219. package/get-shit-done/references/few-shot-examples/verifier.md +109 -0
  220. package/get-shit-done/references/gate-prompts.md +100 -0
  221. package/get-shit-done/references/gates.md +70 -0
  222. package/get-shit-done/references/git-integration.md +298 -0
  223. package/get-shit-done/references/git-planning-commit.md +40 -0
  224. package/get-shit-done/references/ios-scaffold.md +123 -0
  225. package/get-shit-done/references/mandatory-initial-read.md +2 -0
  226. package/get-shit-done/references/model-profile-resolution.md +38 -0
  227. package/get-shit-done/references/model-profiles.md +245 -0
  228. package/get-shit-done/references/mvp-concepts.md +49 -0
  229. package/get-shit-done/references/phase-argument-parsing.md +61 -0
  230. package/get-shit-done/references/planner-antipatterns.md +89 -0
  231. package/get-shit-done/references/planner-chunked.md +49 -0
  232. package/get-shit-done/references/planner-gap-closure.md +62 -0
  233. package/get-shit-done/references/planner-graphify-auto-update.md +67 -0
  234. package/get-shit-done/references/planner-human-verify-mode.md +57 -0
  235. package/get-shit-done/references/planner-interface-context.md +62 -0
  236. package/get-shit-done/references/planner-mvp-mode.md +53 -0
  237. package/get-shit-done/references/planner-reviews.md +39 -0
  238. package/get-shit-done/references/planner-revision.md +87 -0
  239. package/get-shit-done/references/planner-source-audit.md +73 -0
  240. package/get-shit-done/references/planning-config.md +471 -0
  241. package/get-shit-done/references/project-skills-discovery.md +19 -0
  242. package/get-shit-done/references/questioning.md +162 -0
  243. package/get-shit-done/references/revision-loop.md +97 -0
  244. package/get-shit-done/references/scout-codebase.md +51 -0
  245. package/get-shit-done/references/skeleton-template.md +48 -0
  246. package/get-shit-done/references/sketch-interactivity.md +41 -0
  247. package/get-shit-done/references/sketch-theme-system.md +94 -0
  248. package/get-shit-done/references/sketch-tooling.md +45 -0
  249. package/get-shit-done/references/sketch-variant-patterns.md +81 -0
  250. package/get-shit-done/references/spidr-splitting.md +69 -0
  251. package/get-shit-done/references/tdd.md +330 -0
  252. package/get-shit-done/references/thinking-models-debug.md +44 -0
  253. package/get-shit-done/references/thinking-models-execution.md +50 -0
  254. package/get-shit-done/references/thinking-models-planning.md +62 -0
  255. package/get-shit-done/references/thinking-models-research.md +50 -0
  256. package/get-shit-done/references/thinking-models-verification.md +55 -0
  257. package/get-shit-done/references/thinking-partner.md +96 -0
  258. package/get-shit-done/references/ui-brand.md +160 -0
  259. package/get-shit-done/references/universal-anti-patterns.md +63 -0
  260. package/get-shit-done/references/user-profiling.md +681 -0
  261. package/get-shit-done/references/user-story-template.md +58 -0
  262. package/get-shit-done/references/verification-overrides.md +227 -0
  263. package/get-shit-done/references/verification-patterns.md +612 -0
  264. package/get-shit-done/references/verify-mvp-mode.md +85 -0
  265. package/get-shit-done/references/workstream-flag.md +111 -0
  266. package/get-shit-done/references/worktree-path-safety.md +89 -0
  267. package/get-shit-done/templates/AI-SPEC.md +246 -0
  268. package/get-shit-done/templates/DEBUG.md +169 -0
  269. package/get-shit-done/templates/README.md +77 -0
  270. package/get-shit-done/templates/SECURITY.md +61 -0
  271. package/get-shit-done/templates/UAT.md +265 -0
  272. package/get-shit-done/templates/UI-SPEC.md +100 -0
  273. package/get-shit-done/templates/VALIDATION.md +76 -0
  274. package/get-shit-done/templates/claude-md.md +145 -0
  275. package/get-shit-done/templates/codebase/architecture.md +255 -0
  276. package/get-shit-done/templates/codebase/concerns.md +310 -0
  277. package/get-shit-done/templates/codebase/conventions.md +307 -0
  278. package/get-shit-done/templates/codebase/integrations.md +280 -0
  279. package/get-shit-done/templates/codebase/stack.md +186 -0
  280. package/get-shit-done/templates/codebase/structure.md +285 -0
  281. package/get-shit-done/templates/codebase/testing.md +480 -0
  282. package/get-shit-done/templates/config.json +62 -0
  283. package/get-shit-done/templates/context.md +352 -0
  284. package/get-shit-done/templates/continue-here.md +78 -0
  285. package/get-shit-done/templates/copilot-instructions.md +7 -0
  286. package/get-shit-done/templates/debug-subagent-prompt.md +91 -0
  287. package/get-shit-done/templates/dev-preferences.md +21 -0
  288. package/get-shit-done/templates/discovery.md +146 -0
  289. package/get-shit-done/templates/discussion-log.md +63 -0
  290. package/get-shit-done/templates/milestone-archive.md +123 -0
  291. package/get-shit-done/templates/milestone.md +115 -0
  292. package/get-shit-done/templates/phase-prompt.md +610 -0
  293. package/get-shit-done/templates/planner-subagent-prompt.md +117 -0
  294. package/get-shit-done/templates/project.md +186 -0
  295. package/get-shit-done/templates/requirements.md +231 -0
  296. package/get-shit-done/templates/research-project/ARCHITECTURE.md +204 -0
  297. package/get-shit-done/templates/research-project/FEATURES.md +147 -0
  298. package/get-shit-done/templates/research-project/PITFALLS.md +200 -0
  299. package/get-shit-done/templates/research-project/STACK.md +120 -0
  300. package/get-shit-done/templates/research-project/SUMMARY.md +170 -0
  301. package/get-shit-done/templates/research.md +592 -0
  302. package/get-shit-done/templates/retrospective.md +54 -0
  303. package/get-shit-done/templates/roadmap.md +202 -0
  304. package/get-shit-done/templates/spec.md +307 -0
  305. package/get-shit-done/templates/state.md +195 -0
  306. package/get-shit-done/templates/summary-complex.md +59 -0
  307. package/get-shit-done/templates/summary-minimal.md +41 -0
  308. package/get-shit-done/templates/summary-standard.md +48 -0
  309. package/get-shit-done/templates/summary.md +248 -0
  310. package/get-shit-done/templates/user-profile.md +146 -0
  311. package/get-shit-done/templates/user-setup.md +311 -0
  312. package/get-shit-done/templates/verification-report.md +322 -0
  313. package/get-shit-done/workflows/_runtime-launcher.snippet.sh +1 -0
  314. package/get-shit-done/workflows/add-backlog.md +91 -0
  315. package/get-shit-done/workflows/add-phase.md +113 -0
  316. package/get-shit-done/workflows/add-tests.md +355 -0
  317. package/get-shit-done/workflows/add-todo.md +161 -0
  318. package/get-shit-done/workflows/ai-integration-phase.md +295 -0
  319. package/get-shit-done/workflows/analyze-dependencies.md +96 -0
  320. package/get-shit-done/workflows/audit-fix.md +178 -0
  321. package/get-shit-done/workflows/audit-milestone.md +358 -0
  322. package/get-shit-done/workflows/audit-uat.md +110 -0
  323. package/get-shit-done/workflows/autonomous.md +795 -0
  324. package/get-shit-done/workflows/check-todos.md +180 -0
  325. package/get-shit-done/workflows/cleanup.md +155 -0
  326. package/get-shit-done/workflows/code-review-fix.md +502 -0
  327. package/get-shit-done/workflows/code-review.md +656 -0
  328. package/get-shit-done/workflows/complete-milestone.md +855 -0
  329. package/get-shit-done/workflows/debug.md +232 -0
  330. package/get-shit-done/workflows/diagnose-issues.md +241 -0
  331. package/get-shit-done/workflows/discovery-phase.md +291 -0
  332. package/get-shit-done/workflows/discuss-phase/modes/advisor.md +176 -0
  333. package/get-shit-done/workflows/discuss-phase/modes/all.md +28 -0
  334. package/get-shit-done/workflows/discuss-phase/modes/analyze.md +44 -0
  335. package/get-shit-done/workflows/discuss-phase/modes/auto.md +57 -0
  336. package/get-shit-done/workflows/discuss-phase/modes/batch.md +52 -0
  337. package/get-shit-done/workflows/discuss-phase/modes/chain.md +98 -0
  338. package/get-shit-done/workflows/discuss-phase/modes/default.md +141 -0
  339. package/get-shit-done/workflows/discuss-phase/modes/power.md +44 -0
  340. package/get-shit-done/workflows/discuss-phase/modes/text.md +55 -0
  341. package/get-shit-done/workflows/discuss-phase/templates/checkpoint.json +18 -0
  342. package/get-shit-done/workflows/discuss-phase/templates/context.md +136 -0
  343. package/get-shit-done/workflows/discuss-phase/templates/discussion-log.md +50 -0
  344. package/get-shit-done/workflows/discuss-phase-assumptions.md +675 -0
  345. package/get-shit-done/workflows/discuss-phase-power.md +291 -0
  346. package/get-shit-done/workflows/discuss-phase.md +499 -0
  347. package/get-shit-done/workflows/do.md +111 -0
  348. package/get-shit-done/workflows/docs-update.md +1162 -0
  349. package/get-shit-done/workflows/edit-phase.md +295 -0
  350. package/get-shit-done/workflows/eval-review.md +156 -0
  351. package/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md +82 -0
  352. package/get-shit-done/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  353. package/get-shit-done/workflows/execute-phase/steps/post-merge-gate.md +117 -0
  354. package/get-shit-done/workflows/execute-phase.md +1709 -0
  355. package/get-shit-done/workflows/execute-plan.md +526 -0
  356. package/get-shit-done/workflows/explore.md +144 -0
  357. package/get-shit-done/workflows/extract-learnings.md +243 -0
  358. package/get-shit-done/workflows/fast.md +124 -0
  359. package/get-shit-done/workflows/forensics.md +279 -0
  360. package/get-shit-done/workflows/graduation.md +196 -0
  361. package/get-shit-done/workflows/health.md +224 -0
  362. package/get-shit-done/workflows/help/modes/brief.md +22 -0
  363. package/get-shit-done/workflows/help/modes/default.md +50 -0
  364. package/get-shit-done/workflows/help/modes/full.md +784 -0
  365. package/get-shit-done/workflows/help/modes/topic.md +74 -0
  366. package/get-shit-done/workflows/help.md +24 -0
  367. package/get-shit-done/workflows/import.md +254 -0
  368. package/get-shit-done/workflows/inbox.md +387 -0
  369. package/get-shit-done/workflows/ingest-docs.md +339 -0
  370. package/get-shit-done/workflows/insert-phase.md +152 -0
  371. package/get-shit-done/workflows/list-phase-assumptions.md +178 -0
  372. package/get-shit-done/workflows/list-workspaces.md +57 -0
  373. package/get-shit-done/workflows/manager.md +393 -0
  374. package/get-shit-done/workflows/map-codebase.md +444 -0
  375. package/get-shit-done/workflows/milestone-summary.md +224 -0
  376. package/get-shit-done/workflows/mvp-phase.md +222 -0
  377. package/get-shit-done/workflows/new-milestone.md +635 -0
  378. package/get-shit-done/workflows/new-project.md +1555 -0
  379. package/get-shit-done/workflows/new-workspace.md +240 -0
  380. package/get-shit-done/workflows/next.md +299 -0
  381. package/get-shit-done/workflows/node-repair.md +92 -0
  382. package/get-shit-done/workflows/note.md +158 -0
  383. package/get-shit-done/workflows/pause-work.md +244 -0
  384. package/get-shit-done/workflows/plan-milestone-gaps.md +281 -0
  385. package/get-shit-done/workflows/plan-phase.md +1809 -0
  386. package/get-shit-done/workflows/plan-review-convergence.md +346 -0
  387. package/get-shit-done/workflows/plant-seed.md +230 -0
  388. package/get-shit-done/workflows/pr-branch.md +157 -0
  389. package/get-shit-done/workflows/profile-user.md +453 -0
  390. package/get-shit-done/workflows/progress.md +699 -0
  391. package/get-shit-done/workflows/quick.md +1039 -0
  392. package/get-shit-done/workflows/reapply-patches.md +426 -0
  393. package/get-shit-done/workflows/remove-phase.md +156 -0
  394. package/get-shit-done/workflows/remove-workspace.md +108 -0
  395. package/get-shit-done/workflows/resume-project.md +332 -0
  396. package/get-shit-done/workflows/review.md +623 -0
  397. package/get-shit-done/workflows/scan.md +105 -0
  398. package/get-shit-done/workflows/secure-phase.md +180 -0
  399. package/get-shit-done/workflows/session-report.md +146 -0
  400. package/get-shit-done/workflows/settings-advanced.md +620 -0
  401. package/get-shit-done/workflows/settings-integrations.md +312 -0
  402. package/get-shit-done/workflows/settings.md +552 -0
  403. package/get-shit-done/workflows/ship.md +356 -0
  404. package/get-shit-done/workflows/sketch-wrap-up.md +286 -0
  405. package/get-shit-done/workflows/sketch.md +361 -0
  406. package/get-shit-done/workflows/spec-phase.md +262 -0
  407. package/get-shit-done/workflows/spike-wrap-up.md +307 -0
  408. package/get-shit-done/workflows/spike.md +453 -0
  409. package/get-shit-done/workflows/stats.md +80 -0
  410. package/get-shit-done/workflows/sync-skills.md +182 -0
  411. package/get-shit-done/workflows/thread.md +222 -0
  412. package/get-shit-done/workflows/transition.md +694 -0
  413. package/get-shit-done/workflows/ui-phase.md +328 -0
  414. package/get-shit-done/workflows/ui-review.md +193 -0
  415. package/get-shit-done/workflows/ultraplan-phase.md +199 -0
  416. package/get-shit-done/workflows/undo.md +314 -0
  417. package/get-shit-done/workflows/update.md +443 -0
  418. package/get-shit-done/workflows/validate-phase.md +179 -0
  419. package/get-shit-done/workflows/verify-phase.md +544 -0
  420. package/get-shit-done/workflows/verify-work.md +781 -0
  421. package/hooks/dist/gsd-check-update-worker.js +95 -0
  422. package/hooks/dist/gsd-check-update.js +64 -0
  423. package/hooks/dist/gsd-context-monitor.js +195 -0
  424. package/hooks/dist/gsd-graphify-update.sh +158 -0
  425. package/hooks/dist/gsd-phase-boundary.sh +47 -0
  426. package/hooks/dist/gsd-prompt-guard.js +97 -0
  427. package/hooks/dist/gsd-read-guard.js +101 -0
  428. package/hooks/dist/gsd-read-injection-scanner.js +203 -0
  429. package/hooks/dist/gsd-session-state.sh +59 -0
  430. package/hooks/dist/gsd-statusline.js +548 -0
  431. package/hooks/dist/gsd-update-banner.js +134 -0
  432. package/hooks/dist/gsd-validate-commit.sh +57 -0
  433. package/hooks/dist/gsd-workflow-guard.js +166 -0
  434. package/hooks/dist/lib/git-cmd.js +150 -0
  435. package/hooks/dist/lib/gsd-graphify-rebuild.sh +65 -0
  436. package/hooks/gsd-check-update-worker.js +95 -0
  437. package/hooks/gsd-check-update.js +64 -0
  438. package/hooks/gsd-context-monitor.js +195 -0
  439. package/hooks/gsd-graphify-update.sh +158 -0
  440. package/hooks/gsd-phase-boundary.sh +47 -0
  441. package/hooks/gsd-prompt-guard.js +97 -0
  442. package/hooks/gsd-read-guard.js +101 -0
  443. package/hooks/gsd-read-injection-scanner.js +203 -0
  444. package/hooks/gsd-session-state.sh +59 -0
  445. package/hooks/gsd-statusline.js +548 -0
  446. package/hooks/gsd-update-banner.js +134 -0
  447. package/hooks/gsd-validate-commit.sh +57 -0
  448. package/hooks/gsd-workflow-guard.js +166 -0
  449. package/hooks/lib/git-cmd.js +150 -0
  450. package/hooks/lib/gsd-graphify-rebuild.sh +65 -0
  451. package/hooks/managed-hooks-registry.cjs +34 -0
  452. package/package.json +102 -0
  453. package/scripts/affected-tests-lib.cjs +541 -0
  454. package/scripts/audit-workflow-script-paths.cjs +73 -0
  455. package/scripts/base64-scan.sh +339 -0
  456. package/scripts/build-hooks.js +236 -0
  457. package/scripts/changeset/README.md +129 -0
  458. package/scripts/changeset/cli.cjs +392 -0
  459. package/scripts/changeset/github-release-notes.cjs +199 -0
  460. package/scripts/changeset/lint.cjs +110 -0
  461. package/scripts/changeset/new.cjs +137 -0
  462. package/scripts/changeset/parse.cjs +114 -0
  463. package/scripts/changeset/render.cjs +34 -0
  464. package/scripts/changeset/serialize.cjs +130 -0
  465. package/scripts/check-alias-drift.cjs +108 -0
  466. package/scripts/check-env.cjs +302 -0
  467. package/scripts/check-npm-integrity.cjs +209 -0
  468. package/scripts/ci-guard-runner.cjs +16 -0
  469. package/scripts/ci-prepare-test-scope.cjs +46 -0
  470. package/scripts/ci-rebase-check.cjs +85 -0
  471. package/scripts/ci-test-scope.cjs +302 -0
  472. package/scripts/command-contract-helpers.cjs +64 -0
  473. package/scripts/diff-touches-shipped-paths.cjs +147 -0
  474. package/scripts/fix-slash-commands.cjs +147 -0
  475. package/scripts/gen-inventory-manifest.cjs +109 -0
  476. package/scripts/generate-package-identity.cjs +104 -0
  477. package/scripts/lint-command-contract.cjs +108 -0
  478. package/scripts/lint-descriptions.cjs +83 -0
  479. package/scripts/lint-docs-required.cjs +222 -0
  480. package/scripts/lint-no-source-grep-extras.cjs +81 -0
  481. package/scripts/lint-no-source-grep.cjs +174 -0
  482. package/scripts/lint-package-identity-drift.cjs +141 -0
  483. package/scripts/lint-pr-check-project-dir.cjs +98 -0
  484. package/scripts/lint-shared-module-handsync.cjs +388 -0
  485. package/scripts/lint-shell-command-projection-drift.cjs +57 -0
  486. package/scripts/lint-skill-deps.cjs +180 -0
  487. package/scripts/lint-test-file-count.allowlist.json +36 -0
  488. package/scripts/lint-test-file-count.cjs +190 -0
  489. package/scripts/pr-template-policy.cjs +268 -0
  490. package/scripts/prompt-injection-scan.sh +203 -0
  491. package/scripts/release-tarball-smoke.cjs +627 -0
  492. package/scripts/run-affected-tests.cjs +6 -0
  493. package/scripts/run-cross-platform-tests.cjs +63 -0
  494. package/scripts/run-tests.cjs +282 -0
  495. package/scripts/secret-scan-lint.sh +231 -0
  496. package/scripts/secret-scan.sh +358 -0
  497. package/scripts/setup-branch-protection.sh +236 -0
  498. package/scripts/shared-module-handsync-allowlist.json +183 -0
  499. package/scripts/strip-prose-atrefs.cjs +106 -0
  500. package/scripts/sync-rulesets.sh +34 -0
  501. package/scripts/sync-runtime-launcher.cjs +402 -0
  502. package/scripts/test-failure-reasons.cjs +34 -0
  503. package/scripts/workflow-policy.cjs +450 -0
@@ -0,0 +1,1607 @@
1
+ /**
2
+ * Phase — Phase CRUD, query, and lifecycle operations
3
+ *
4
+ * Re-export shim note (issue #4 / ADR-3524):
5
+ * The phase lifecycle pure-computation helpers live in phase-lifecycle.cjs.
6
+ * cmdPhaseComplete uses
7
+ * deriveProgressFromRoadmap + clampPercent from that module to fix the
8
+ * non-idempotent Completed Phases blind-increment bug.
9
+ *
10
+ * The async mutation handlers (phaseAdd, phaseInsert, phaseRemove, phaseComplete)
11
+ * in phase-lifecycle.ts are I/O-bound and remain per-side per ADR-3524 Section 4.
12
+ * This file provides the CJS (sync) implementations of those handlers.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { escapeRegex, loadConfig, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, output, error, readSubdirectories, phaseTokenMatches, ERROR_REASON } = require('./core.cjs');
18
+ const { platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
19
+ const { planningDir, withPlanningLock } = require('./planning-workspace.cjs');
20
+ const { extractFrontmatter } = require('./frontmatter.cjs');
21
+ const { readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection } = require('./state.cjs');
22
+ const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs');
23
+ // Pure-computation helpers for cmdPhaseComplete (issue #4 fix).
24
+ const { deriveProgressFromRoadmap, clampPercent } = require('./phase-lifecycle.cjs');
25
+
26
+ // #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
27
+ // Documented in agents/gsd-planner.md (write_phase_prompt step). The wider
28
+ // "looks like a plan but isn't canonical" probe below is used to surface a
29
+ // loud warning instead of silently returning zero plans.
30
+ const isCanonicalPlanFile = (f) => f.endsWith('-PLAN.md') || f === 'PLAN.md';
31
+
32
+ // Any .md file with PLAN anywhere in the basename — the diagnostic net for
33
+ // catching agent deviations like `01-PLAN-01-foundation.md` (#2893).
34
+ // Excludes derivative files (`-PLAN-OUTLINE.md`, `*.pre-bounce.md`, etc.) that
35
+ // the planner legitimately produces alongside canonical plans.
36
+ const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
37
+ const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
38
+ const looksLikePlanFile = (f) =>
39
+ /\.md$/i.test(f)
40
+ && /PLAN/i.test(f)
41
+ && !PLAN_OUTLINE_RE.test(f)
42
+ && !PLAN_PRE_BOUNCE_RE.test(f);
43
+
44
+ /**
45
+ * Detect plan-shaped files that the canonical filter would reject. Returns
46
+ * a warning string when offenders exist, else null. Centralised so every
47
+ * read site (phase-plan-index, phases list --type plans, find-phase) emits
48
+ * the same message.
49
+ *
50
+ * @param {string[]} dirFiles — readdirSync output for one phase directory
51
+ * @param {string[]} matchedFiles — what the canonical filter accepted
52
+ * @returns {string|null}
53
+ */
54
+ function describeNonCanonicalPlans(dirFiles, matchedFiles) {
55
+ const matched = new Set(matchedFiles);
56
+ const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
57
+ if (offenders.length === 0) return null;
58
+ return (
59
+ `Found ${offenders.length} plan-shaped file(s) in this phase that don't match the canonical ` +
60
+ `naming convention "{padded_phase}-{NN}-PLAN.md" (or bare "PLAN.md") and were skipped: ` +
61
+ offenders.map((f) => `"${f}"`).join(', ') +
62
+ `. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` +
63
+ `See agents/gsd-planner.md write_phase_prompt step for the full contract.`
64
+ );
65
+ }
66
+
67
+ function extractCanonicalPlanId(filename) {
68
+ const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
69
+ const parts = base.split('-').filter(Boolean);
70
+ const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
71
+ const phaseIdx = parts.findIndex(p => tokenRe.test(p));
72
+ if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
73
+ return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
74
+ }
75
+ return base;
76
+ }
77
+
78
+ function cmdPhasesList(cwd, options, raw) {
79
+ const phasesDir = path.join(planningDir(cwd), 'phases');
80
+ const { type, phase, includeArchived } = options;
81
+
82
+ // If no phases directory, return empty
83
+ if (!fs.existsSync(phasesDir)) {
84
+ if (type) {
85
+ output({ files: [], count: 0 }, raw, '');
86
+ } else {
87
+ output({ directories: [], count: 0 }, raw, '');
88
+ }
89
+ return;
90
+ }
91
+
92
+ try {
93
+ // Get all phase directories
94
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
95
+ let dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
96
+
97
+ // Include archived phases if requested
98
+ if (includeArchived) {
99
+ const archived = getArchivedPhaseDirs(cwd);
100
+ for (const a of archived) {
101
+ dirs.push(`${a.name} [${a.milestone}]`);
102
+ }
103
+ }
104
+
105
+ // Sort numerically (handles integers, decimals, letter-suffix, hybrids)
106
+ dirs.sort((a, b) => comparePhaseNum(a, b));
107
+
108
+ // If filtering by phase number
109
+ if (phase) {
110
+ const normalized = normalizePhaseName(phase);
111
+ const match = dirs.find(d => phaseTokenMatches(d, normalized));
112
+ if (!match) {
113
+ output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
114
+ return;
115
+ }
116
+ dirs = [match];
117
+ }
118
+
119
+ // If listing files of a specific type
120
+ if (type) {
121
+ const files = [];
122
+ const warnings = [];
123
+ for (const dir of dirs) {
124
+ const dirPath = path.join(phasesDir, dir);
125
+ const dirFiles = fs.readdirSync(dirPath);
126
+
127
+ let filtered;
128
+ if (type === 'plans') {
129
+ filtered = dirFiles.filter(isCanonicalPlanFile);
130
+ // #2893 — surface plan-shaped files the canonical filter rejected
131
+ // so callers (executor init, etc.) don't silently see zero plans.
132
+ const w = describeNonCanonicalPlans(dirFiles, filtered);
133
+ if (w) warnings.push(`${dir}: ${w}`);
134
+ } else if (type === 'summaries') {
135
+ filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
136
+ } else {
137
+ filtered = dirFiles;
138
+ }
139
+
140
+ files.push(...filtered.sort());
141
+ }
142
+
143
+ const result = {
144
+ files,
145
+ count: files.length,
146
+ phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)*-?/, '') : null,
147
+ };
148
+ if (warnings.length) result.warning = warnings.join(' | ');
149
+ output(result, raw, files.join('\n'));
150
+ return;
151
+ }
152
+
153
+ // Default: list directories
154
+ output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n'));
155
+ } catch (e) {
156
+ error('Failed to list phases: ' + e.message);
157
+ }
158
+ }
159
+
160
+ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
161
+ const phasesDir = path.join(planningDir(cwd), 'phases');
162
+ const normalized = normalizePhaseName(basePhase);
163
+
164
+ try {
165
+ let baseExists = false;
166
+ const decimalSet = new Set();
167
+
168
+ // Scan directory names for existing decimal phases
169
+ if (fs.existsSync(phasesDir)) {
170
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
171
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
172
+ baseExists = dirs.some(d => phaseTokenMatches(d, normalized));
173
+
174
+ const dirPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalized)}\\.(\\d+)`);
175
+ for (const dir of dirs) {
176
+ const match = dir.match(dirPattern);
177
+ if (match) decimalSet.add(parseInt(match[1], 10));
178
+ }
179
+ }
180
+
181
+ // Also scan ROADMAP.md for phase entries that may not have directories yet
182
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
183
+ if (fs.existsSync(roadmapPath)) {
184
+ try {
185
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
186
+ // #3537: padding-tolerant on both sides — `0*${escapeRegex(...)}`
187
+ // tolerated extra padding but not missing.
188
+ const phasePattern = new RegExp(
189
+ `#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)\\s*:`, 'gi'
190
+ );
191
+ let pm;
192
+ while ((pm = phasePattern.exec(roadmapContent)) !== null) {
193
+ decimalSet.add(parseInt(pm[1], 10));
194
+ }
195
+ } catch { /* ROADMAP.md read failure is non-fatal */ }
196
+ }
197
+
198
+ // Build sorted list of existing decimals
199
+ const existingDecimals = Array.from(decimalSet)
200
+ .sort((a, b) => a - b)
201
+ .map(n => `${normalized}.${n}`);
202
+
203
+ // Calculate next decimal
204
+ let nextDecimal;
205
+ if (decimalSet.size === 0) {
206
+ nextDecimal = `${normalized}.1`;
207
+ } else {
208
+ nextDecimal = `${normalized}.${Math.max(...decimalSet) + 1}`;
209
+ }
210
+
211
+ output(
212
+ {
213
+ found: baseExists,
214
+ base_phase: normalized,
215
+ next: nextDecimal,
216
+ existing: existingDecimals,
217
+ },
218
+ raw,
219
+ nextDecimal
220
+ );
221
+ } catch (e) {
222
+ error('Failed to calculate next decimal phase: ' + e.message);
223
+ }
224
+ }
225
+
226
+ function getRoadmapModeForPhase(cwd, phaseNum) {
227
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
228
+ if (!fs.existsSync(roadmapPath)) return null;
229
+
230
+ const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
231
+ const milestoneContent = extractCurrentMilestone(rawContent, cwd);
232
+ const fullContent = stripShippedMilestones(rawContent);
233
+ const escapedPhase = phaseMarkdownRegexSource(phaseNum);
234
+ const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}\\s*:`, 'i');
235
+
236
+ for (const content of [milestoneContent, fullContent]) {
237
+ const headerMatch = content.match(phaseHeader);
238
+ if (!headerMatch || headerMatch.index === undefined) continue;
239
+
240
+ const sectionStart = headerMatch.index;
241
+ const rest = content.slice(sectionStart);
242
+ const nextHeader = rest.slice(headerMatch[0].length).match(/\n#{2,4}\s+Phase\s+\S/i);
243
+ const sectionEnd = nextHeader ? sectionStart + headerMatch[0].length + nextHeader.index : content.length;
244
+ const section = content.slice(sectionStart, sectionEnd);
245
+ const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
246
+ if (modeMatch) return modeMatch[1].trim().toLowerCase();
247
+ }
248
+
249
+ return null;
250
+ }
251
+
252
+ function cmdPhaseMvpMode(cwd, args, raw) {
253
+ const phaseNum = args[0];
254
+ if (!phaseNum) {
255
+ error('Usage: phase.mvp-mode <phase-number> [--cli-flag]', ERROR_REASON.USAGE);
256
+ }
257
+
258
+ const cliFlagPresent = args.includes('--cli-flag');
259
+ const roadmapMode = getRoadmapModeForPhase(cwd, phaseNum);
260
+ const config = loadConfig(cwd);
261
+ const configMvpMode = Boolean(config.mvp_mode);
262
+
263
+ let active = false;
264
+ let source = 'none';
265
+ if (cliFlagPresent) {
266
+ active = true;
267
+ source = 'cli_flag';
268
+ } else if (roadmapMode === 'mvp') {
269
+ active = true;
270
+ source = 'roadmap';
271
+ } else if (configMvpMode) {
272
+ active = true;
273
+ source = 'config';
274
+ }
275
+
276
+ output({
277
+ active,
278
+ source,
279
+ roadmap_mode: roadmapMode,
280
+ config_mvp_mode: configMvpMode,
281
+ cli_flag_present: cliFlagPresent,
282
+ }, raw);
283
+ }
284
+
285
+ function cmdFindPhase(cwd, phase, raw) {
286
+ if (!phase) {
287
+ error('phase identifier required');
288
+ }
289
+
290
+ const planBase = planningDir(cwd);
291
+ const normalized = normalizePhaseName(phase);
292
+ const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [], searched_directories: [] };
293
+
294
+ // Build candidate search dirs: flat layout first, then milestone-archive layout.
295
+ const searchDirs = [];
296
+ const flatPhasesDir = path.join(planBase, 'phases');
297
+ if (fs.existsSync(flatPhasesDir)) searchDirs.push(flatPhasesDir);
298
+ try {
299
+ const milestonesDir = path.join(planBase, 'milestones');
300
+ const entries = fs.readdirSync(milestonesDir, { withFileTypes: true })
301
+ .filter(e => e.isDirectory() && /^v\d+.*-phases$/.test(e.name))
302
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
303
+ for (const e of entries) {
304
+ searchDirs.push(path.join(milestonesDir, e.name));
305
+ }
306
+ } catch { /* no milestones dir */ }
307
+
308
+ notFound.searched_directories = searchDirs.map((searchDir) =>
309
+ toPosixPath(path.join(path.relative(cwd, planBase), path.relative(planBase, searchDir))));
310
+
311
+ for (const searchDir of searchDirs) {
312
+ try {
313
+ const entries = fs.readdirSync(searchDir, { withFileTypes: true });
314
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
315
+
316
+ const match = dirs.find(d => phaseTokenMatches(d, normalized));
317
+ if (!match) continue;
318
+
319
+ // Extract phase number — supports project-code-prefixed (CK-01-name), numeric (01-name), and custom IDs
320
+ const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
321
+ || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
322
+ const phaseNumber = dirMatch ? dirMatch[1] : normalized;
323
+ const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
324
+
325
+ const phaseDir = path.join(searchDir, match);
326
+ const phaseFiles = fs.readdirSync(phaseDir);
327
+ const plans = phaseFiles.filter(isCanonicalPlanFile).sort();
328
+ const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
329
+ // #2893 — same diagnostic as phase-plan-index for consistency.
330
+ const planNamingWarning = describeNonCanonicalPlans(phaseFiles, plans);
331
+
332
+ const result = {
333
+ found: true,
334
+ directory: toPosixPath(path.join(path.relative(cwd, planBase), path.relative(planBase, searchDir), match)),
335
+ phase_number: phaseNumber,
336
+ phase_name: phaseName,
337
+ plans,
338
+ summaries,
339
+ };
340
+ if (planNamingWarning) result.warning = planNamingWarning;
341
+
342
+ output(result, raw, result.directory);
343
+ return;
344
+ } catch { continue; }
345
+ }
346
+
347
+ output(notFound, raw, '');
348
+ }
349
+
350
+ function extractObjective(content) {
351
+ const m = content.match(/<objective>\s*\n?\s*(.+)/);
352
+ return m ? m[1].trim() : null;
353
+ }
354
+
355
+ // O(V + E). Assigns each in-phase plan its longest-path topological level over the
356
+ // in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number }.
357
+ // visited < rawPlans.length signals a dependency cycle.
358
+ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
359
+ // Kahn's algorithm — compute in-degree and adjacency for in-phase deps only.
360
+ const level = new Map();
361
+ const inDeg = new Map();
362
+ const adj = new Map();
363
+
364
+ for (const p of rawPlans) {
365
+ if (!inDeg.has(p.id)) inDeg.set(p.id, 0);
366
+ if (!adj.has(p.id)) adj.set(p.id, []);
367
+ for (const dep of p.dependsOn) {
368
+ // Accept both full-stem ('03-01-auth-hardening') and canonical-prefix ('03-01') forms.
369
+ // All lookups are lowercased so mixed-case depends_on refs resolve correctly (#3785).
370
+ const depLower = dep.toLowerCase();
371
+ const resolvedDep = planMap.has(depLower) ? planMap.get(depLower).id : canonicalToId.get(depLower);
372
+ if (!resolvedDep) continue; // external dep — ignore
373
+ if (!adj.has(resolvedDep)) adj.set(resolvedDep, []);
374
+ adj.get(resolvedDep).push(p.id);
375
+ inDeg.set(p.id, (inDeg.get(p.id) ?? 0) + 1);
376
+ }
377
+ }
378
+
379
+ // Start with nodes that have no in-phase dependencies.
380
+ const queue = [];
381
+ for (const p of rawPlans) {
382
+ if ((inDeg.get(p.id) ?? 0) === 0) {
383
+ queue.push(p.id);
384
+ level.set(p.id, 0);
385
+ }
386
+ }
387
+
388
+ // Dequeue by head index (queue[head++]), NOT Array.shift(): shift() is O(n) per
389
+ // call in V8 (it re-indexes the backing store), which would make this Kahn's BFS
390
+ // O(V^2) on deep queues (e.g. wide fan-in graphs). Head-index dequeue is O(1)
391
+ // amortized -> O(V+E) overall. Do not "simplify" this back to queue.shift(). (#307)
392
+ let head = 0;
393
+ let visited = 0;
394
+ while (head < queue.length) {
395
+ const cur = queue[head++];
396
+ visited++;
397
+ const curLevel = level.get(cur);
398
+ for (const dep of (adj.get(cur) ?? [])) {
399
+ const newLevel = curLevel + 1;
400
+ if (newLevel > (level.get(dep) ?? -1)) {
401
+ level.set(dep, newLevel);
402
+ }
403
+ inDeg.set(dep, inDeg.get(dep) - 1);
404
+ if (inDeg.get(dep) === 0) {
405
+ queue.push(dep);
406
+ }
407
+ }
408
+ }
409
+
410
+ return { level, visited };
411
+ }
412
+
413
+ function cmdPhasePlanIndex(cwd, phase, raw) {
414
+ if (!phase) {
415
+ error('phase required for phase-plan-index');
416
+ }
417
+
418
+ const phasesDir = path.join(planningDir(cwd), 'phases');
419
+ const normalized = normalizePhaseName(phase);
420
+
421
+ // Find phase directory
422
+ let phaseDir = null;
423
+ let phaseDirName = null;
424
+ try {
425
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
426
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
427
+ const match = dirs.find(d => phaseTokenMatches(d, normalized));
428
+ if (match) {
429
+ phaseDir = path.join(phasesDir, match);
430
+ phaseDirName = match;
431
+ }
432
+ } catch {
433
+ // phases dir doesn't exist
434
+ }
435
+
436
+ if (!phaseDir) {
437
+ output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw);
438
+ return;
439
+ }
440
+
441
+ // Get all files in phase directory
442
+ const phaseFiles = fs.readdirSync(phaseDir);
443
+ const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort();
444
+ const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
445
+ // #2893 — surface plan-shaped files the canonical filter rejected so a
446
+ // misnamed plan never silently produces plan_count: 0 at executor init.
447
+ const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles);
448
+
449
+ // Build set of plan IDs with summaries
450
+ const completedPlanIds = new Set(
451
+ summaryFiles.flatMap(s => {
452
+ const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
453
+ const canonical = extractCanonicalPlanId(s);
454
+ return canonical === exact ? [exact] : [exact, canonical];
455
+ })
456
+ );
457
+
458
+ // ── Pass 1: parse each plan file ─────────────────────────────────────────
459
+
460
+ const rawPlans = [];
461
+
462
+ for (const planFile of planFiles) {
463
+ const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
464
+ const planPath = path.join(phaseDir, planFile);
465
+ const content = fs.readFileSync(planPath, 'utf-8');
466
+ const fm = extractFrontmatter(content);
467
+
468
+ // Count tasks: XML <task> tags (canonical) or ## Task N markdown (legacy)
469
+ const xmlTasks = content.match(/<task[\s>]/gi) || [];
470
+ const mdTasks = content.match(/##\s*Task\s*\d+/gi) || [];
471
+ const taskCount = xmlTasks.length || mdTasks.length;
472
+
473
+ // Parse wave as integer — use nullish handling so wave: 0 is preserved.
474
+ // parseInt returns NaN for missing/non-numeric values; fall back to null
475
+ // (meaning "no declared wave") so downstream can apply the topo default.
476
+ const parsedWave = parseInt(fm.wave, 10);
477
+ const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
478
+
479
+ // Parse depends_on — normalise to string[]
480
+ let dependsOn = [];
481
+ const fmDeps = fm['depends_on'];
482
+ if (Array.isArray(fmDeps)) {
483
+ dependsOn = fmDeps.map(String);
484
+ } else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
485
+ dependsOn = [fmDeps];
486
+ }
487
+
488
+ // Parse autonomous (default true if not specified)
489
+ let autonomous = true;
490
+ if (fm.autonomous !== undefined) {
491
+ autonomous = fm.autonomous === 'true' || fm.autonomous === true;
492
+ }
493
+
494
+ // Parse files_modified (underscore is canonical; also accept hyphenated for compat)
495
+ let filesModified = [];
496
+ const fmFiles = fm['files_modified'] || fm['files-modified'];
497
+ if (fmFiles) {
498
+ filesModified = Array.isArray(fmFiles) ? fmFiles : [fmFiles];
499
+ }
500
+
501
+ const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
502
+
503
+ rawPlans.push({
504
+ id: planId,
505
+ declaredWave,
506
+ dependsOn,
507
+ autonomous,
508
+ objective: extractObjective(content) || fm.objective || null,
509
+ filesModified,
510
+ taskCount,
511
+ hasSummary,
512
+ });
513
+ }
514
+
515
+ // ── Pass 2: topological level assignment via depends_on DAG ──────────────
516
+
517
+ // Guard: detect case-insensitive key collisions before building dependency
518
+ // maps. Two plan IDs that differ only by case would silently overwrite each
519
+ // other in planMap, routing depends_on edges to whichever plan survived last.
520
+ // This is a configuration error — fail fast with the conflicting IDs. (#3785)
521
+ //
522
+ // This guard catches case-fold collisions on full plan IDs.
523
+ // Shared-numeric-prefix collisions (e.g. '20-01-Auth' and '20-01' both
524
+ // producing canonical '20-01') are resolved by first-write-wins ordering
525
+ // from sorted planFiles — not explicitly guarded here.
526
+ // seenLower is intentionally separate from planMap — it exists only to detect
527
+ // collisions before planMap is built, so the error fires before any Map
528
+ // entry silently overwrites another.
529
+ const seenLower = new Map(); // lowercase key → original id
530
+ for (const p of rawPlans) {
531
+ // ASCII plan IDs only — toLowerCase() is correct and locale-safe here.
532
+ const lower = p.id.toLowerCase();
533
+ const existing = seenLower.get(lower);
534
+ if (existing !== undefined) {
535
+ error(`depends_on index collision in phase ${normalized}: plan IDs '${existing}' and '${p.id}' are identical when case-folded. Rename one file to avoid ambiguous dependency resolution.`);
536
+ return;
537
+ }
538
+ seenLower.set(lower, p.id);
539
+ }
540
+
541
+ // Build a map from plan ID → raw plan for fast lookup.
542
+ // Deps that reference plans outside this phase are treated as external and ignored.
543
+ // Keys are lowercased so that depends_on refs with different casing still
544
+ // resolve to the correct plan (#3785: case-insensitive identifier resolution).
545
+ const planMap = new Map(rawPlans.map(p => [p.id.toLowerCase(), p]));
546
+ // Secondary index: canonical prefix → full plan ID, so depends_on: ['03-01'] resolves
547
+ // to '03-01-auth-hardening-PLAN.md'-derived ID '03-01-auth-hardening' (k015).
548
+ // Keyed lowercase for the same case-insensitive reason (#3785).
549
+ const canonicalToId = new Map(rawPlans.map(p => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]));
550
+
551
+ // KNOWN GAP: CJS resolver has only two tiers (planMap + canonicalToId);
552
+ // the SDK has an additional shortFormToId for same-phase short-form refs
553
+ // like '01' or '01A'. Adding the third tier here is tracked as a parity
554
+ // gap and is out of scope for #3785 / PR #3798.
555
+
556
+ const { level, visited } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
557
+
558
+ // Cycle detection — any node not visited has a cycle.
559
+ if (visited < rawPlans.length) {
560
+ const cycleNodes = rawPlans.filter(p => !level.has(p.id)).map(p => p.id);
561
+ error(`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`);
562
+ return;
563
+ }
564
+
565
+ // ── Pass 3: determine lowest bucket key and build output ─────────────────
566
+
567
+ // If any plan has declared wave: 0, the lowest level maps to "0"; otherwise "1".
568
+ const anyWaveZero = rawPlans.some(p => p.declaredWave === 0);
569
+ const levelOffset = anyWaveZero ? 0 : 1;
570
+
571
+ const plans = [];
572
+ const waves = {};
573
+ const incomplete = [];
574
+ let hasCheckpoints = false;
575
+ const warnings = [];
576
+
577
+ for (const raw of rawPlans) {
578
+ if (!raw.autonomous) {
579
+ hasCheckpoints = true;
580
+ }
581
+ if (!raw.hasSummary) {
582
+ incomplete.push(raw.id);
583
+ }
584
+
585
+ // Computed wave = topological level + offset (so lowest level → 0 or 1).
586
+ const computedWave = (level.get(raw.id) ?? 0) + levelOffset;
587
+
588
+ // The effective wave used for bucketing is always the computed topo level.
589
+ // If the plan declared a wave that disagrees, emit a non-fatal warning.
590
+ const effectiveWave = computedWave;
591
+ if (raw.declaredWave !== null && raw.declaredWave !== computedWave) {
592
+ warnings.push(
593
+ `Plan ${raw.id}: declared wave: ${raw.declaredWave} but depends_on DAG places it in wave ${computedWave}`,
594
+ );
595
+ }
596
+
597
+ const plan = {
598
+ id: raw.id,
599
+ wave: effectiveWave,
600
+ // Resolve each user-typed dep to its canonical plan ID (preserving on-disk casing)
601
+ // so the output never reflects the user's case typo. Unresolved deps (external
602
+ // phase refs) are kept as-is since planMap only contains plans in this phase.
603
+ depends_on: raw.dependsOn.map(dep => {
604
+ const lower = String(dep).toLowerCase();
605
+ return planMap.has(lower) ? planMap.get(lower).id : dep;
606
+ }),
607
+ autonomous: raw.autonomous,
608
+ objective: raw.objective,
609
+ files_modified: raw.filesModified,
610
+ task_count: raw.taskCount,
611
+ has_summary: raw.hasSummary,
612
+ };
613
+
614
+ plans.push(plan);
615
+
616
+ const waveKey = String(effectiveWave);
617
+ if (!waves[waveKey]) {
618
+ waves[waveKey] = [];
619
+ }
620
+ waves[waveKey].push(raw.id);
621
+ }
622
+
623
+ const result = {
624
+ phase: normalized,
625
+ plans,
626
+ waves,
627
+ incomplete,
628
+ has_checkpoints: hasCheckpoints,
629
+ };
630
+ if (planNamingWarning) result.warning = planNamingWarning;
631
+ if (warnings.length > 0) result.warnings = warnings;
632
+
633
+ output(result, raw);
634
+ }
635
+
636
+ function cmdPhaseAdd(cwd, description, raw, customId) {
637
+ if (!description) {
638
+ error('description required for phase add');
639
+ }
640
+
641
+ const config = loadConfig(cwd);
642
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
643
+ if (!fs.existsSync(roadmapPath)) {
644
+ error('ROADMAP.md not found');
645
+ }
646
+
647
+ const slug = generateSlugInternal(description);
648
+
649
+ // Wrap entire read-modify-write in lock to prevent concurrent corruption
650
+ const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
651
+ const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
652
+ const content = extractCurrentMilestone(rawContent, cwd);
653
+
654
+ // Optional project code prefix (e.g., 'CK' → 'CK-01-foundation')
655
+ const projectCode = config.project_code || '';
656
+ const prefix = projectCode ? `${projectCode}-` : '';
657
+
658
+ let _newPhaseId;
659
+ let _dirName;
660
+
661
+ if (customId || config.phase_naming === 'custom') {
662
+ // Custom phase naming: use provided ID or generate from description
663
+ _newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
664
+ if (!_newPhaseId) error('--id required when phase_naming is "custom"');
665
+ _dirName = `${prefix}${_newPhaseId}-${slug}`;
666
+ } else {
667
+ // Sequential mode: find highest integer phase number from two sources:
668
+ // 1. ROADMAP.md (current milestone only)
669
+ // 2. .planning/phases/ on disk (orphan directories not tracked in roadmap)
670
+ // Skip 999.x backlog phases — they live outside the active sequence
671
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
672
+ let maxPhase = 0;
673
+ let m;
674
+ while ((m = phasePattern.exec(content)) !== null) {
675
+ const num = parseInt(m[1], 10);
676
+ if (num === 999) continue; // backlog phases use 999.x numbering
677
+ if (num > maxPhase) maxPhase = num;
678
+ }
679
+
680
+ // Also scan .planning/phases/ for orphan directories not tracked in ROADMAP.
681
+ // Directory names follow: [PREFIX-]NN-slug (e.g. 03-api or CK-05-old-feature).
682
+ // Strip the optional project_code prefix before extracting the leading integer.
683
+ const phasesOnDisk = path.join(planningDir(cwd), 'phases');
684
+ if (fs.existsSync(phasesOnDisk)) {
685
+ const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
686
+ for (const entry of fs.readdirSync(phasesOnDisk)) {
687
+ const match = entry.match(dirNumPattern);
688
+ if (!match) continue;
689
+ const num = parseInt(match[1], 10);
690
+ if (num === 999) continue; // skip backlog orphans
691
+ if (num > maxPhase) maxPhase = num;
692
+ }
693
+ }
694
+
695
+ _newPhaseId = maxPhase + 1;
696
+ const paddedNum = String(_newPhaseId).padStart(2, '0');
697
+ _dirName = `${prefix}${paddedNum}-${slug}`;
698
+ }
699
+
700
+ const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
701
+
702
+ // Create directory with .gitkeep so git tracks empty folders
703
+ platformEnsureDir(dirPath);
704
+ platformWriteSync(path.join(dirPath, '.gitkeep'), '');
705
+
706
+ // Build phase entry
707
+ const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
708
+ const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${formatGsdSlash('plan-phase', resolveRuntime(cwd))} ${_newPhaseId} to break down)\n`;
709
+
710
+ // Find insertion point: before last "---" or at end
711
+ let updatedContent;
712
+ const lastSeparator = rawContent.lastIndexOf('\n---');
713
+ if (lastSeparator > 0) {
714
+ updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
715
+ } else {
716
+ updatedContent = rawContent + phaseEntry;
717
+ }
718
+
719
+ platformWriteSync(roadmapPath, updatedContent);
720
+ return { newPhaseId: _newPhaseId, dirName: _dirName };
721
+ });
722
+
723
+ const result = {
724
+ phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
725
+ padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
726
+ name: description,
727
+ slug,
728
+ directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)),
729
+ naming_mode: config.phase_naming,
730
+ };
731
+
732
+ output(result, raw, result.padded);
733
+ }
734
+
735
+ function cmdPhaseAddBatch(cwd, descriptions, raw) {
736
+ if (!Array.isArray(descriptions) || descriptions.length === 0) {
737
+ error('descriptions array required for phase add-batch');
738
+ }
739
+ const config = loadConfig(cwd);
740
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
741
+ if (!fs.existsSync(roadmapPath)) { error('ROADMAP.md not found'); }
742
+ const projectCode = config.project_code || '';
743
+ const prefix = projectCode ? `${projectCode}-` : '';
744
+
745
+ const results = withPlanningLock(cwd, () => {
746
+ let rawContent = fs.readFileSync(roadmapPath, 'utf-8');
747
+ const content = extractCurrentMilestone(rawContent, cwd);
748
+ let maxPhase = 0;
749
+ if (config.phase_naming !== 'custom') {
750
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
751
+ let m;
752
+ while ((m = phasePattern.exec(content)) !== null) {
753
+ const num = parseInt(m[1], 10);
754
+ if (num === 999) continue;
755
+ if (num > maxPhase) maxPhase = num;
756
+ }
757
+ const phasesOnDisk = path.join(planningDir(cwd), 'phases');
758
+ if (fs.existsSync(phasesOnDisk)) {
759
+ const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
760
+ for (const entry of fs.readdirSync(phasesOnDisk)) {
761
+ const match = entry.match(dirNumPattern);
762
+ if (!match) continue;
763
+ const num = parseInt(match[1], 10);
764
+ if (num === 999) continue;
765
+ if (num > maxPhase) maxPhase = num;
766
+ }
767
+ }
768
+ }
769
+ const added = [];
770
+ for (const description of descriptions) {
771
+ const slug = generateSlugInternal(description);
772
+ let newPhaseId, dirName;
773
+ if (config.phase_naming === 'custom') {
774
+ newPhaseId = slug.toUpperCase().replace(/-/g, '-');
775
+ dirName = `${prefix}${newPhaseId}-${slug}`;
776
+ } else {
777
+ maxPhase += 1;
778
+ newPhaseId = maxPhase;
779
+ dirName = `${prefix}${String(newPhaseId).padStart(2, '0')}-${slug}`;
780
+ }
781
+ const dirPath = path.join(planningDir(cwd), 'phases', dirName);
782
+ platformEnsureDir(dirPath);
783
+ platformWriteSync(path.join(dirPath, '.gitkeep'), '');
784
+ const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
785
+ const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${formatGsdSlash('plan-phase', resolveRuntime(cwd))} ${newPhaseId} to break down)\n`;
786
+ const lastSeparator = rawContent.lastIndexOf('\n---');
787
+ rawContent = lastSeparator > 0
788
+ ? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator)
789
+ : rawContent + phaseEntry;
790
+ added.push({
791
+ phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
792
+ padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
793
+ name: description,
794
+ slug,
795
+ directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)),
796
+ naming_mode: config.phase_naming,
797
+ });
798
+ }
799
+ platformWriteSync(roadmapPath, rawContent);
800
+ return added;
801
+ });
802
+ output({ phases: results, count: results.length }, raw);
803
+ }
804
+
805
+ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
806
+ if (!afterPhase || !description) {
807
+ error('after-phase and description required for phase insert');
808
+ }
809
+
810
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
811
+ if (!fs.existsSync(roadmapPath)) {
812
+ error('ROADMAP.md not found');
813
+ }
814
+
815
+ const slug = generateSlugInternal(description);
816
+
817
+ // Wrap entire read-modify-write in lock to prevent concurrent corruption
818
+ const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
819
+ const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
820
+ const content = extractCurrentMilestone(rawContent, cwd);
821
+
822
+ // Normalize input then route through canonical padding-tolerant fragment
823
+ // (#3537). The prior hand-rolled `0*${unpadded}` worked for the integer
824
+ // base but duplicated logic — funnel it through the shared helper.
825
+ const normalizedAfter = normalizePhaseName(afterPhase);
826
+ const afterPhaseEscaped = phaseMarkdownRegexSource(normalizedAfter);
827
+ const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+${afterPhaseEscaped}:`, 'i');
828
+ const headingMatch = targetPattern.test(content);
829
+
830
+ // #3815: also recognise the checked-bullet phase format used by projects
831
+ // that list phases as `- [ ] **Phase N: name**` or `- [ ] Phase N: name`
832
+ // (both bold and plain variants). Mirrors phaseRemove / phaseComplete.
833
+ //
834
+ // Bullet-style only activates when there are NO heading-style phases in the
835
+ // milestone content. A bullet entry in a hybrid (headings + bullets) ROADMAP
836
+ // means the detail section is missing — that is the #3098 case and must keep
837
+ // producing the "missing a detail section" error.
838
+ const bulletPattern = new RegExp(
839
+ `-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s]`,
840
+ 'i',
841
+ );
842
+ const anyHeadingPattern = /#{2,4}\s*Phase\s+\d/i;
843
+ const roadmapHasHeadingPhases = anyHeadingPattern.test(content);
844
+ const isBulletStyle = !headingMatch && bulletPattern.test(content) && !roadmapHasHeadingPhases;
845
+
846
+ if (!headingMatch && !isBulletStyle) {
847
+ // Bug #3098 parity: when the ROADMAP uses heading-style phases and only
848
+ // the summary checklist exists for this phase (no `### Phase N:` detail
849
+ // section), point the user at the missing detail section.
850
+ const checklistPattern = new RegExp(
851
+ `-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s]`,
852
+ 'i',
853
+ );
854
+ if (checklistPattern.test(content)) {
855
+ error(`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`);
856
+ }
857
+ error(`Phase ${afterPhase} not found in ROADMAP.md`);
858
+ }
859
+
860
+ // Calculate next decimal by scanning both directories AND ROADMAP.md entries
861
+ const phasesDir = path.join(planningDir(cwd), 'phases');
862
+ const normalizedBase = normalizePhaseName(afterPhase);
863
+ const decimalSet = new Set();
864
+
865
+ try {
866
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
867
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
868
+ const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalizedBase)}\\.(\\d+)`);
869
+ for (const dir of dirs) {
870
+ const dm = dir.match(decimalPattern);
871
+ if (dm) decimalSet.add(parseInt(dm[1], 10));
872
+ }
873
+ } catch { /* intentionally empty */ }
874
+
875
+ // Also scan ROADMAP.md content (already loaded) for decimal entries.
876
+ // #3537: padding-tolerant fragment so un-padded `Phase 2.7:` is found
877
+ // when caller passes the padded base `02`.
878
+ const rmPhasePattern = new RegExp(
879
+ `#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)\\s*:`, 'gi'
880
+ );
881
+ let rmMatch;
882
+ while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
883
+ decimalSet.add(parseInt(rmMatch[1], 10));
884
+ }
885
+
886
+ const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
887
+ const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
888
+ // Optional project code prefix
889
+ const insertConfig = loadConfig(cwd);
890
+ const projectCode = insertConfig.project_code || '';
891
+ const pfx = projectCode ? `${projectCode}-` : '';
892
+ const _dirName = `${pfx}${_decimalPhase}-${slug}`;
893
+ const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
894
+
895
+ // Create directory with .gitkeep so git tracks empty folders
896
+ platformEnsureDir(dirPath);
897
+ platformWriteSync(path.join(dirPath, '.gitkeep'), '');
898
+
899
+ let updatedContent;
900
+
901
+ if (isBulletStyle) {
902
+ // #3815: Insert in checked-bullet format, mirroring the style of the
903
+ // surrounding entries. Detect whether the matched bullet uses bold
904
+ // (`**Phase N: …**`) to preserve file-internal format consistency.
905
+ const boldBulletPattern = new RegExp(
906
+ `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}:`,
907
+ 'i',
908
+ );
909
+ const useBold = boldBulletPattern.test(content);
910
+ const phaseLabel = useBold
911
+ ? `**Phase ${_decimalPhase}: ${description}**`
912
+ : `Phase ${_decimalPhase}: ${description}`;
913
+ const bulletEntry = `\n- [ ] ${phaseLabel}`;
914
+
915
+ // Locate the target bullet line in the raw content
916
+ const targetBulletPattern = new RegExp(
917
+ `(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s][^\\n]*)`,
918
+ 'i',
919
+ );
920
+ const bulletMatchResult = rawContent.match(targetBulletPattern);
921
+ if (!bulletMatchResult) {
922
+ error(`Could not find Phase ${afterPhase} bullet line`);
923
+ }
924
+
925
+ const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
926
+ const afterBullet = rawContent.slice(bulletLineEnd);
927
+ const nextBulletMatch = afterBullet.match(/\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
928
+
929
+ let insertIdx;
930
+ if (nextBulletMatch) {
931
+ insertIdx = bulletLineEnd + nextBulletMatch.index;
932
+ } else {
933
+ insertIdx = bulletLineEnd;
934
+ }
935
+
936
+ updatedContent = rawContent.slice(0, insertIdx) + bulletEntry + rawContent.slice(insertIdx);
937
+ } else {
938
+ // Heading-style insert (original path)
939
+ // Build phase entry
940
+ const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${formatGsdSlash('plan-phase', resolveRuntime(cwd))} ${_decimalPhase} to break down)\n`;
941
+
942
+ // Insert after the target phase section
943
+ const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
944
+ const headerMatch = rawContent.match(headerPattern);
945
+ if (!headerMatch) {
946
+ error(`Could not find Phase ${afterPhase} header`);
947
+ }
948
+
949
+ const headerIdx = rawContent.indexOf(headerMatch[0]);
950
+ const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
951
+ // #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
952
+ // recognised as section boundaries.
953
+ const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
954
+
955
+ let insertIdx;
956
+ if (nextPhaseMatch) {
957
+ insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
958
+ } else {
959
+ insertIdx = rawContent.length;
960
+ }
961
+
962
+ updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
963
+ }
964
+
965
+ platformWriteSync(roadmapPath, updatedContent);
966
+ return { decimalPhase: _decimalPhase, dirName: _dirName };
967
+ });
968
+
969
+ const result = {
970
+ phase_number: decimalPhase,
971
+ after_phase: afterPhase,
972
+ name: description,
973
+ slug,
974
+ directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)),
975
+ };
976
+
977
+ output(result, raw, decimalPhase);
978
+ }
979
+
980
+ /**
981
+ * Renumber sibling decimal phases after a decimal phase is removed.
982
+ * e.g. removing 06.2 → 06.3 becomes 06.2, 06.4 becomes 06.3, etc.
983
+ * Returns { renamedDirs, renamedFiles }.
984
+ */
985
+ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
986
+ const renamedDirs = [], renamedFiles = [];
987
+ // Capture the zero-padded prefix (e.g. "06" from "06.3-slug") so the renamed
988
+ // directory preserves the original padding format.
989
+ const decPattern = new RegExp(`^(0*${baseInt})\\.(\\d+)-(.+)$`);
990
+ const dirs = readSubdirectories(phasesDir, true);
991
+ const toRename = dirs
992
+ .map(dir => { const m = dir.match(decPattern); return m ? { dir, prefix: m[1], oldDecimal: parseInt(m[2], 10), slug: m[3] } : null; })
993
+ .filter(item => item && item.oldDecimal > removedDecimal)
994
+ .sort((a, b) => b.oldDecimal - a.oldDecimal); // descending to avoid conflicts
995
+
996
+ for (const item of toRename) {
997
+ const newDecimal = item.oldDecimal - 1;
998
+ const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
999
+ const newPhaseId = `${baseInt}.${newDecimal}`;
1000
+ const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`;
1001
+ fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
1002
+ renamedDirs.push({ from: item.dir, to: newDirName });
1003
+ for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
1004
+ if (f.includes(oldPhaseId)) {
1005
+ const newFileName = f.replace(oldPhaseId, newPhaseId);
1006
+ fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName));
1007
+ renamedFiles.push({ from: f, to: newFileName });
1008
+ }
1009
+ }
1010
+ }
1011
+ return { renamedDirs, renamedFiles };
1012
+ }
1013
+
1014
+ /**
1015
+ * Renumber all integer phases after removedInt.
1016
+ * e.g. removing phase 5 → phase 6 becomes 5, phase 7 becomes 6, etc.
1017
+ * Returns { renamedDirs, renamedFiles }.
1018
+ */
1019
+ function renameIntegerPhases(phasesDir, removedInt) {
1020
+ const renamedDirs = [], renamedFiles = [];
1021
+ const dirs = readSubdirectories(phasesDir, true);
1022
+ const toRename = dirs
1023
+ .map(dir => {
1024
+ const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
1025
+ if (!m) return null;
1026
+ const dirInt = parseInt(m[1], 10);
1027
+ return (dirInt > removedInt && dirInt !== 999) ? { dir, oldInt: dirInt, letter: m[2] ? m[2].toUpperCase() : '', decimal: m[3] ? parseInt(m[3], 10) : null, slug: m[4] } : null;
1028
+ })
1029
+ .filter(Boolean)
1030
+ .sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
1031
+
1032
+ for (const item of toRename) {
1033
+ const newInt = item.oldInt - 1;
1034
+ const newPadded = String(newInt).padStart(2, '0');
1035
+ const oldPadded = String(item.oldInt).padStart(2, '0');
1036
+ const letterSuffix = item.letter || '';
1037
+ const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
1038
+ const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
1039
+ const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
1040
+ const newDirName = `${newPrefix}-${item.slug}`;
1041
+ fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
1042
+ renamedDirs.push({ from: item.dir, to: newDirName });
1043
+ for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
1044
+ if (f.startsWith(oldPrefix)) {
1045
+ const newFileName = newPrefix + f.slice(oldPrefix.length);
1046
+ fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName));
1047
+ renamedFiles.push({ from: f, to: newFileName });
1048
+ }
1049
+ }
1050
+ }
1051
+ return { renamedDirs, renamedFiles };
1052
+ }
1053
+
1054
+ function decrementRoadmapPhaseNumber(raw, removedInt) {
1055
+ const num = parseInt(raw, 10);
1056
+ if (!Number.isInteger(num) || num <= removedInt || num === 999) return raw;
1057
+ return String(num - 1);
1058
+ }
1059
+
1060
+ function decrementRoadmapPhaseToken(raw, removedInt) {
1061
+ const match = String(raw).match(/^(\d+)(\.\d+)?$/);
1062
+ if (!match) return raw;
1063
+ const num = parseInt(match[1], 10);
1064
+ if (!Number.isInteger(num) || num <= removedInt || num === 999) return raw;
1065
+ return `${num - 1}${match[2] || ''}`;
1066
+ }
1067
+
1068
+ function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
1069
+ const num = parseInt(raw, 10);
1070
+ if (!Number.isInteger(num) || num <= removedInt || num === 999) return raw;
1071
+ return String(num - 1).padStart(raw.length, '0');
1072
+ }
1073
+
1074
+ /**
1075
+ * Remove a phase section from ROADMAP.md and renumber all subsequent integer phases.
1076
+ */
1077
+ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
1078
+ // Wrap entire read-modify-write in lock to prevent concurrent corruption
1079
+ withPlanningLock(cwd, () => {
1080
+ let content = fs.readFileSync(roadmapPath, 'utf-8');
1081
+ const escaped = escapeRegex(targetPhase);
1082
+
1083
+ // #3601: the end-of-section lookahead is depth-aware. It captures the
1084
+ // hash count of the header being removed and stops only at a subsequent
1085
+ // header of the SAME depth, whether integer or decimal. This preserves
1086
+ // two existing contracts:
1087
+ //
1088
+ // (#3601 case) Remove `### Phase 2:` and stop at `### Phase 2.1:` —
1089
+ // `Phase 2.1` is a peer-level decimal phase (depth 3) and must be
1090
+ // preserved.
1091
+ //
1092
+ // (#3355 case) Remove `### Phase 27:` and continue past
1093
+ // `#### Phase 27.1:` (depth 4 — a child of Phase 27) until the next
1094
+ // depth-3 header. The child decimal is part of the integer phase
1095
+ // being removed.
1096
+ //
1097
+ // The `(?!#)` negative lookahead after the backreference prevents the
1098
+ // depth-3 match from being satisfied by a depth-4+ header that starts
1099
+ // with the same three hashes.
1100
+ content = content.replace(new RegExp(`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`, 'i'), '');
1101
+ content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), '');
1102
+ content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
1103
+
1104
+ if (!isDecimal) {
1105
+ content = content.replace(
1106
+ /(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi,
1107
+ (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`
1108
+ );
1109
+ content = content.replace(
1110
+ /(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi,
1111
+ (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`
1112
+ );
1113
+ content = content.replace(
1114
+ /(\|\s*)(\d+)(\.\s)/g,
1115
+ (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`
1116
+ );
1117
+ // #3602: extend the suffix lookahead so slugged plan filenames like
1118
+ // `07-01-cherry-pick-foundation-PLAN.md` match too. The previous
1119
+ // pattern only allowed a compact `-(PLAN|SUMMARY).md` immediately
1120
+ // after the plan number (or no suffix at all); a slug between the
1121
+ // number and the `-PLAN.md` / `-SUMMARY.md` suffix made the
1122
+ // lookahead fail and left the stale `07-01-` prefix in ROADMAP
1123
+ // text while the on-disk file was already renamed to `06-01-…`.
1124
+ // The slug segment `(?:-[A-Za-z][A-Za-z0-9-]*)*` allows any number
1125
+ // of kebab-case tokens before the canonical PLAN/SUMMARY suffix.
1126
+ content = content.replace(
1127
+ /(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)*-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g,
1128
+ (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`
1129
+ );
1130
+ content = content.replace(
1131
+ /(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
1132
+ (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`
1133
+ );
1134
+ content = content.replace(
1135
+ /(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi,
1136
+ (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`
1137
+ );
1138
+ }
1139
+
1140
+ platformWriteSync(roadmapPath, content);
1141
+ });
1142
+ }
1143
+
1144
+ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1145
+ if (!targetPhase) error('phase number required for phase remove');
1146
+
1147
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
1148
+ const phasesDir = path.join(planningDir(cwd), 'phases');
1149
+
1150
+ if (!fs.existsSync(roadmapPath)) error('ROADMAP.md not found');
1151
+
1152
+ const normalized = normalizePhaseName(targetPhase);
1153
+ const isDecimal = targetPhase.includes('.');
1154
+ const force = options.force || false;
1155
+
1156
+ // Find target directory
1157
+ const targetDir = readSubdirectories(phasesDir, true)
1158
+ .find(d => phaseTokenMatches(d, normalized)) || null;
1159
+
1160
+ // Guard against removing executed work
1161
+ if (targetDir && !force) {
1162
+ const files = fs.readdirSync(path.join(phasesDir, targetDir));
1163
+ const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1164
+ if (summaries.length > 0) {
1165
+ error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
1166
+ }
1167
+ }
1168
+
1169
+ if (targetDir) fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true });
1170
+
1171
+ // Renumber subsequent phases on disk
1172
+ let renamedDirs = [], renamedFiles = [];
1173
+ try {
1174
+ const renamed = isDecimal
1175
+ ? renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10))
1176
+ : renameIntegerPhases(phasesDir, parseInt(normalized, 10));
1177
+ renamedDirs = renamed.renamedDirs;
1178
+ renamedFiles = renamed.renamedFiles;
1179
+ } catch { /* intentionally empty */ }
1180
+
1181
+ // Update ROADMAP.md
1182
+ updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
1183
+
1184
+ // Update STATE.md phase count atomically (#P4.4)
1185
+ const statePath = path.join(planningDir(cwd), 'STATE.md');
1186
+ if (fs.existsSync(statePath)) {
1187
+ readModifyWriteStateMd(statePath, (stateContent) => {
1188
+ const totalRaw = stateExtractField(stateContent, 'Total Phases');
1189
+ if (totalRaw) {
1190
+ stateContent = stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) || stateContent;
1191
+ }
1192
+ const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
1193
+ if (ofMatch) {
1194
+ stateContent = stateContent.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
1195
+ }
1196
+ return stateContent;
1197
+ }, cwd);
1198
+ }
1199
+
1200
+ output({
1201
+ removed: targetPhase,
1202
+ directory_deleted: targetDir,
1203
+ renamed_directories: renamedDirs,
1204
+ renamed_files: renamedFiles,
1205
+ roadmap_updated: true,
1206
+ state_updated: fs.existsSync(statePath),
1207
+ }, raw);
1208
+ }
1209
+
1210
+ function writePlanningFileSet(writes) {
1211
+ const applied = [];
1212
+ try {
1213
+ for (const write of writes) {
1214
+ if (write.before === write.after) continue;
1215
+ platformWriteSync(write.filePath, write.after);
1216
+ applied.push(write);
1217
+ }
1218
+ } catch (err) {
1219
+ for (const write of applied.reverse()) {
1220
+ try {
1221
+ platformWriteSync(write.filePath, write.before);
1222
+ } catch (rollbackErr) {
1223
+ err.rollbackError = rollbackErr;
1224
+ err.message += `\nWARNING: rollback failed while restoring ${write.filePath} ` +
1225
+ `(${rollbackErr.message}). Planning files under .planning/ may be left in an ` +
1226
+ `inconsistent, partially rolled back state. Inspect ROADMAP.md / REQUIREMENTS.md / ` +
1227
+ `STATE.md before re-running phase complete.`;
1228
+ break;
1229
+ }
1230
+ }
1231
+ throw err;
1232
+ }
1233
+ }
1234
+
1235
+ function cmdPhaseComplete(cwd, phaseNum, raw) {
1236
+ if (!phaseNum) {
1237
+ error('phase number required for phase complete');
1238
+ }
1239
+
1240
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
1241
+ const statePath = path.join(planningDir(cwd), 'STATE.md');
1242
+ const phasesDir = path.join(planningDir(cwd), 'phases');
1243
+ const normalized = normalizePhaseName(phaseNum);
1244
+ const today = new Date().toISOString().split('T')[0];
1245
+
1246
+ // Verify phase info
1247
+ const phaseInfo = findPhaseInternal(cwd, phaseNum);
1248
+ if (!phaseInfo) {
1249
+ error(`Phase ${phaseNum} not found`);
1250
+ }
1251
+
1252
+ const planCount = phaseInfo.plans.length;
1253
+ const summaryCount = phaseInfo.summaries.length;
1254
+ let requirementsUpdated = false;
1255
+
1256
+ // Check for unresolved verification debt (non-blocking warnings)
1257
+ const warnings = [];
1258
+ try {
1259
+ const phaseFullDir = path.join(cwd, phaseInfo.directory);
1260
+ const phaseFiles = fs.readdirSync(phaseFullDir);
1261
+
1262
+ for (const file of phaseFiles.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
1263
+ const content = fs.readFileSync(path.join(phaseFullDir, file), 'utf-8');
1264
+ if (/result: pending/.test(content)) warnings.push(`${file}: has pending tests`);
1265
+ if (/result: blocked/.test(content)) warnings.push(`${file}: has blocked tests`);
1266
+ if (/status: partial/.test(content)) warnings.push(`${file}: testing incomplete (partial)`);
1267
+ if (/status: diagnosed/.test(content)) warnings.push(`${file}: has diagnosed gaps`);
1268
+ }
1269
+
1270
+ for (const file of phaseFiles.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
1271
+ const content = fs.readFileSync(path.join(phaseFullDir, file), 'utf-8');
1272
+ if (/status: human_needed/.test(content)) warnings.push(`${file}: needs human verification`);
1273
+ if (/status: gaps_found/.test(content)) warnings.push(`${file}: has unresolved gaps`);
1274
+ }
1275
+ } catch {}
1276
+
1277
+ let nextPhaseNum = null;
1278
+ let nextPhaseName = null;
1279
+ let isLastPhase = true;
1280
+
1281
+ // Update ROADMAP.md, REQUIREMENTS.md, and STATE.md from one locked snapshot.
1282
+ // A previous split-lock sequence could publish ROADMAP/REQUIREMENTS and then
1283
+ // fail before STATE advanced, leaving planning files disagreeing about the
1284
+ // current phase.
1285
+ withPlanningLock(cwd, () => {
1286
+ const runPhaseCompleteTransaction = () => {
1287
+ const writes = [];
1288
+ let roadmapContent = null;
1289
+
1290
+ if (fs.existsSync(roadmapPath)) {
1291
+ const originalRoadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
1292
+ roadmapContent = originalRoadmapContent;
1293
+
1294
+ // Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
1295
+ // #3537: padding-tolerant fragment so the caller-resolved padded id
1296
+ // matches un-padded ROADMAP prose.
1297
+ const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
1298
+ const checkboxPattern = new RegExp(
1299
+ `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
1300
+ 'i'
1301
+ );
1302
+ roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
1303
+
1304
+ // Progress table: update Status to Complete, add date (handles 4 or 5 column tables)
1305
+ const tableRowPattern = new RegExp(
1306
+ `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
1307
+ 'im'
1308
+ );
1309
+ roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
1310
+ const cells = fullRow.split('|').slice(1, -1);
1311
+ if (cells.length === 5) {
1312
+ // 5-col: Phase | Milestone | Plans | Status | Completed
1313
+ cells[2] = ` ${summaryCount}/${planCount} `;
1314
+ cells[3] = ' Complete ';
1315
+ cells[4] = ` ${today} `;
1316
+ } else if (cells.length === 4) {
1317
+ // 4-col: Phase | Plans | Status | Completed
1318
+ cells[1] = ` ${summaryCount}/${planCount} `;
1319
+ cells[2] = ' Complete ';
1320
+ cells[3] = ` ${today} `;
1321
+ }
1322
+ return '|' + cells.join('|') + '|';
1323
+ });
1324
+
1325
+ // Update plan count in phase section.
1326
+ // Use direct .replace() rather than replaceInCurrentMilestone() so this
1327
+ // works when the current milestone section is itself inside a <details>
1328
+ // block (the standard /gsd:new-project layout). replaceInCurrentMilestone
1329
+ // scopes to content after the last </details>, which misses content inside
1330
+ // the current milestone's own <details> wrapper (#2005).
1331
+ // The phase-scoped heading pattern is specific enough to avoid matching
1332
+ // archived phases (which belong to different milestones).
1333
+ const planCountPattern = new RegExp(
1334
+ `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
1335
+ 'i'
1336
+ );
1337
+ roadmapContent = roadmapContent.replace(
1338
+ planCountPattern,
1339
+ `$1${summaryCount}/${planCount} plans complete`
1340
+ );
1341
+
1342
+ // Mark completed plan checkboxes (safety net for missed per-plan updates)
1343
+ // Handles both plain IDs ("- [ ] 01-01-PLAN.md") and bold-wrapped IDs ("- [ ] **01-01**")
1344
+ for (const summaryFile of phaseInfo.summaries) {
1345
+ const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
1346
+ if (!planId) continue;
1347
+ const planEscaped = escapeRegex(planId);
1348
+ const planCheckboxPattern = new RegExp(
1349
+ `(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
1350
+ 'i'
1351
+ );
1352
+ roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
1353
+ }
1354
+
1355
+ writes.push({ filePath: roadmapPath, before: originalRoadmapContent, after: roadmapContent });
1356
+
1357
+ // Update REQUIREMENTS.md traceability for this phase's requirements
1358
+ const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
1359
+ if (fs.existsSync(reqPath)) {
1360
+ // Extract the current phase section from roadmap (scoped to avoid cross-phase matching).
1361
+ // #3537: padding-tolerant fragment so an un-padded `Phase 2.7:` heading
1362
+ // is found when caller resolved to padded `02.7`.
1363
+ const phaseEsc = phaseMarkdownRegexSource(phaseNum);
1364
+ const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
1365
+ const phaseSectionMatch = currentMilestoneRoadmap.match(
1366
+ new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
1367
+ );
1368
+
1369
+ const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
1370
+ // Accept all bold/colon variants (#2769) — the previous pattern only
1371
+ // matched **Requirements:** (colon inside bold) and silently skipped
1372
+ // **Requirements**: (colon outside), preventing the matching REQ-IDs
1373
+ // from being ticked off in REQUIREMENTS.md on phase completion.
1374
+ const reqMatch = sectionText.match(/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i);
1375
+
1376
+ const originalReqContent = fs.readFileSync(reqPath, 'utf-8');
1377
+ let reqContent = originalReqContent;
1378
+
1379
+ if (reqMatch) {
1380
+ const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
1381
+
1382
+ for (const reqId of reqIds) {
1383
+ const reqEscaped = escapeRegex(reqId);
1384
+ // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
1385
+ reqContent = reqContent.replace(
1386
+ new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
1387
+ '$1x$2'
1388
+ );
1389
+ // Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete |
1390
+ reqContent = reqContent.replace(
1391
+ new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
1392
+ '$1 Complete $2'
1393
+ );
1394
+ }
1395
+ }
1396
+
1397
+ // Scan body for all **REQ-ID** patterns, warn about any missing from the Traceability table.
1398
+ // Always runs regardless of whether the roadmap has a Requirements: line.
1399
+ const bodyReqIds = [];
1400
+ const bodyReqPattern = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
1401
+ let bodyMatch;
1402
+ while ((bodyMatch = bodyReqPattern.exec(reqContent)) !== null) {
1403
+ const id = bodyMatch[1];
1404
+ if (!bodyReqIds.includes(id)) bodyReqIds.push(id);
1405
+ }
1406
+
1407
+ // Collect REQ-IDs present in the Traceability section only, to avoid
1408
+ // picking up IDs from other tables in the document.
1409
+ const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);
1410
+ const traceabilitySection = traceabilityHeadingMatch
1411
+ ? reqContent.slice(traceabilityHeadingMatch.index)
1412
+ : '';
1413
+ const tableReqIds = new Set();
1414
+ const tableRowPattern = /^\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/gm;
1415
+ let tableMatch;
1416
+ while ((tableMatch = tableRowPattern.exec(traceabilitySection)) !== null) {
1417
+ tableReqIds.add(tableMatch[1]);
1418
+ }
1419
+
1420
+ const unregistered = bodyReqIds.filter(id => !tableReqIds.has(id));
1421
+ if (unregistered.length > 0) {
1422
+ warnings.push(
1423
+ `REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`
1424
+ );
1425
+ }
1426
+
1427
+ writes.push({ filePath: reqPath, before: originalReqContent, after: reqContent });
1428
+ requirementsUpdated = true;
1429
+ }
1430
+ }
1431
+
1432
+ // Find next phase — check both filesystem AND roadmap
1433
+ // Phases may be defined in ROADMAP.md but not yet scaffolded to disk,
1434
+ // so a filesystem-only scan would incorrectly report is_last_phase:true
1435
+ try {
1436
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
1437
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1438
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
1439
+ .filter(isDirInMilestone)
1440
+ .sort((a, b) => comparePhaseNum(a, b));
1441
+
1442
+ // Find the next phase directory after current
1443
+ // Skip backlog phases (999.x) — they are parked ideas, not sequential work (#2129)
1444
+ for (const dir of dirs) {
1445
+ const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
1446
+ if (dm) {
1447
+ if (/^999(?:\.|$)/.test(dm[1])) continue;
1448
+ if (comparePhaseNum(dm[1], phaseNum) > 0) {
1449
+ nextPhaseNum = dm[1];
1450
+ nextPhaseName = dm[2] || null;
1451
+ isLastPhase = false;
1452
+ break;
1453
+ }
1454
+ }
1455
+ }
1456
+ } catch { /* intentionally empty */ }
1457
+
1458
+ // Fallback: if filesystem found no next phase, check ROADMAP.md
1459
+ // for phases that are defined but not yet planned (no directory on disk)
1460
+ if (isLastPhase && roadmapContent !== null) {
1461
+ try {
1462
+ const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
1463
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1464
+ let pm;
1465
+ while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
1466
+ if (comparePhaseNum(pm[1], phaseNum) > 0) {
1467
+ nextPhaseNum = pm[1];
1468
+ nextPhaseName = pm[2].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-');
1469
+ isLastPhase = false;
1470
+ break;
1471
+ }
1472
+ }
1473
+ } catch { /* intentionally empty */ }
1474
+ }
1475
+
1476
+ // Update STATE.md while the planning lock is still held.
1477
+ if (fs.existsSync(statePath)) {
1478
+ const originalStateContent = platformReadSync(statePath) || '';
1479
+ let stateContent = originalStateContent;
1480
+
1481
+ // Update Current Phase — preserve "X of Y (Name)" compound format
1482
+ const phaseValue = nextPhaseNum || phaseNum;
1483
+ const existingPhaseField = stateExtractField(stateContent, 'Current Phase')
1484
+ || stateExtractField(stateContent, 'Phase');
1485
+ let newPhaseValue = String(phaseValue);
1486
+ if (existingPhaseField) {
1487
+ const totalMatch = existingPhaseField.match(/of\s+(\d+)/);
1488
+ const nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
1489
+ if (totalMatch) {
1490
+ const total = totalMatch[1];
1491
+ const nameStr = nextPhaseName ? ` (${nextPhaseName.replace(/-/g, ' ')})` : (nameMatch ? ` (${nameMatch[1]})` : '');
1492
+ newPhaseValue = `${phaseValue} of ${total}${nameStr}`;
1493
+ }
1494
+ }
1495
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase', 'Phase', newPhaseValue);
1496
+
1497
+ // Update Current Phase Name
1498
+ if (nextPhaseName) {
1499
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Phase Name', null, nextPhaseName.replace(/-/g, ' '));
1500
+ }
1501
+
1502
+ // Update Status
1503
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null,
1504
+ isLastPhase ? 'Milestone complete' : 'Ready to plan');
1505
+
1506
+ // Update Current Plan
1507
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Current Plan', 'Plan', 'Not started');
1508
+
1509
+ // Update Last Activity
1510
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
1511
+
1512
+ // Update Last Activity Description
1513
+ stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null,
1514
+ `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`);
1515
+
1516
+ // Update Completed Phases counter — derive from the same ROADMAP snapshot
1517
+ // that will be published in this transaction, not a separately-read file.
1518
+ const completedRaw = stateExtractField(stateContent, 'Completed Phases');
1519
+ if (completedRaw !== null) {
1520
+ // Derive from ROADMAP if available (idempotent); fall back to existing value.
1521
+ let newCompleted = parseInt(completedRaw, 10);
1522
+ let derivedTotalPhases = null;
1523
+ if (roadmapContent !== null) {
1524
+ const derived = deriveProgressFromRoadmap(roadmapContent);
1525
+ if (derived.completedPhases !== null) newCompleted = derived.completedPhases;
1526
+ if (derived.totalPhases !== null) derivedTotalPhases = derived.totalPhases;
1527
+ }
1528
+ stateContent = stateReplaceField(stateContent, 'Completed Phases', String(newCompleted)) || stateContent;
1529
+
1530
+ // Recalculate percent — use clampPercent to prevent >100% (#4 unclamped bug).
1531
+ const totalRaw = stateExtractField(stateContent, 'Total Phases');
1532
+ const totalPhases = derivedTotalPhases
1533
+ || (totalRaw ? parseInt(totalRaw, 10) : null);
1534
+ if (totalPhases && totalPhases > 0) {
1535
+ const newPercent = clampPercent(newCompleted, totalPhases);
1536
+ stateContent = stateReplaceField(stateContent, 'Progress', `${newPercent}%`) || stateContent;
1537
+ stateContent = stateContent.replace(
1538
+ /(percent:\s*)\d+/,
1539
+ `$1${newPercent}`
1540
+ );
1541
+ }
1542
+ }
1543
+
1544
+ // Gate 4: Update Performance Metrics section (#1627)
1545
+ stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
1546
+ stateContent = syncStateFrontmatter(stateContent, cwd);
1547
+
1548
+ writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
1549
+ }
1550
+
1551
+ writePlanningFileSet(writes);
1552
+ };
1553
+
1554
+ if (fs.existsSync(statePath)) {
1555
+ withStateLock(statePath, runPhaseCompleteTransaction);
1556
+ } else {
1557
+ runPhaseCompleteTransaction();
1558
+ }
1559
+ });
1560
+
1561
+ // Auto-prune STATE.md on phase boundary when configured (#2087)
1562
+ let autoPruned = false;
1563
+ try {
1564
+ const configPath = path.join(planningDir(cwd), 'config.json');
1565
+ if (fs.existsSync(configPath)) {
1566
+ const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1567
+ const autoPruneEnabled = rawConfig.workflow && rawConfig.workflow.auto_prune_state === true;
1568
+ if (autoPruneEnabled && fs.existsSync(statePath)) {
1569
+ const { cmdStatePrune } = require('./state.cjs');
1570
+ cmdStatePrune(cwd, { keepRecent: '3', dryRun: false, silent: true }, true);
1571
+ autoPruned = true;
1572
+ }
1573
+ }
1574
+ } catch { /* intentionally empty — auto-prune is best-effort */ }
1575
+
1576
+ const result = {
1577
+ completed_phase: phaseNum,
1578
+ phase_name: phaseInfo.phase_name,
1579
+ plans_executed: `${summaryCount}/${planCount}`,
1580
+ next_phase: nextPhaseNum,
1581
+ next_phase_name: nextPhaseName,
1582
+ is_last_phase: isLastPhase,
1583
+ date: today,
1584
+ roadmap_updated: fs.existsSync(roadmapPath),
1585
+ state_updated: fs.existsSync(statePath),
1586
+ requirements_updated: requirementsUpdated,
1587
+ auto_pruned: autoPruned,
1588
+ warnings,
1589
+ has_warnings: warnings.length > 0,
1590
+ };
1591
+
1592
+ output(result, raw);
1593
+ }
1594
+
1595
+ module.exports = {
1596
+ cmdPhasesList,
1597
+ cmdPhaseNextDecimal,
1598
+ cmdFindPhase,
1599
+ cmdPhasePlanIndex,
1600
+ cmdPhaseAdd,
1601
+ cmdPhaseAddBatch,
1602
+ cmdPhaseMvpMode,
1603
+ cmdPhaseInsert,
1604
+ cmdPhaseRemove,
1605
+ cmdPhaseComplete,
1606
+ computeDependencyLevels,
1607
+ };