@opengsd/gsd-core 1.3.0-rc.1 → 1.3.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 (521) hide show
  1. package/README.ja-JP.md +44 -790
  2. package/README.ko-KR.md +41 -778
  3. package/README.md +39 -180
  4. package/README.pt-BR.md +45 -413
  5. package/README.zh-CN.md +43 -761
  6. package/agents/gsd-ai-researcher.md +1 -1
  7. package/agents/gsd-debugger.md +8 -8
  8. package/agents/gsd-domain-researcher.md +14 -1
  9. package/agents/gsd-eval-auditor.md +1 -1
  10. package/agents/gsd-eval-planner.md +1 -1
  11. package/agents/gsd-executor.md +21 -8
  12. package/agents/gsd-framework-selector.md +1 -1
  13. package/agents/gsd-intel-updater.md +4 -4
  14. package/agents/gsd-phase-researcher.md +18 -5
  15. package/agents/gsd-plan-checker.md +3 -3
  16. package/agents/gsd-planner.md +36 -23
  17. package/agents/gsd-project-researcher.md +13 -0
  18. package/agents/gsd-research-synthesizer.md +6 -2
  19. package/agents/gsd-roadmapper.md +6 -6
  20. package/agents/gsd-ui-researcher.md +15 -2
  21. package/agents/gsd-user-profiler.md +2 -2
  22. package/agents/gsd-verifier.md +7 -7
  23. package/bin/install.js +169 -59
  24. package/commands/gsd/add-tests.md +1 -1
  25. package/commands/gsd/ai-integration-phase.md +3 -3
  26. package/commands/gsd/audit-fix.md +1 -1
  27. package/commands/gsd/audit-milestone.md +1 -1
  28. package/commands/gsd/audit-uat.md +1 -1
  29. package/commands/gsd/autonomous.md +2 -2
  30. package/commands/gsd/capture.md +6 -6
  31. package/commands/gsd/cleanup.md +1 -1
  32. package/commands/gsd/code-review.md +1 -1
  33. package/commands/gsd/complete-milestone.md +2 -2
  34. package/commands/gsd/config.md +3 -3
  35. package/commands/gsd/debug.md +1 -1
  36. package/commands/gsd/discuss-phase.md +5 -4
  37. package/commands/gsd/docs-update.md +1 -1
  38. package/commands/gsd/eval-review.md +2 -2
  39. package/commands/gsd/execute-phase.md +2 -2
  40. package/commands/gsd/explore.md +1 -1
  41. package/commands/gsd/extract-learnings.md +2 -2
  42. package/commands/gsd/fast.md +1 -1
  43. package/commands/gsd/forensics.md +1 -1
  44. package/commands/gsd/graphify.md +9 -9
  45. package/commands/gsd/health.md +1 -1
  46. package/commands/gsd/help.md +2 -2
  47. package/commands/gsd/import.md +5 -5
  48. package/commands/gsd/inbox.md +1 -1
  49. package/commands/gsd/ingest-docs.md +4 -4
  50. package/commands/gsd/manager.md +3 -3
  51. package/commands/gsd/map-codebase.md +1 -1
  52. package/commands/gsd/milestone-summary.md +1 -1
  53. package/commands/gsd/mvp-phase.md +4 -4
  54. package/commands/gsd/new-milestone.md +5 -5
  55. package/commands/gsd/new-project.md +5 -5
  56. package/commands/gsd/pause-work.md +2 -2
  57. package/commands/gsd/phase.md +4 -4
  58. package/commands/gsd/plan-phase.md +2 -2
  59. package/commands/gsd/plan-review-convergence.md +4 -4
  60. package/commands/gsd/pr-branch.md +1 -1
  61. package/commands/gsd/profile-user.md +2 -2
  62. package/commands/gsd/progress.md +4 -4
  63. package/commands/gsd/quick.md +1 -1
  64. package/commands/gsd/resume-work.md +1 -1
  65. package/commands/gsd/review.md +1 -1
  66. package/commands/gsd/secure-phase.md +1 -1
  67. package/commands/gsd/settings.md +1 -1
  68. package/commands/gsd/ship.md +2 -2
  69. package/commands/gsd/sketch.md +7 -7
  70. package/commands/gsd/spec-phase.md +2 -2
  71. package/commands/gsd/spike.md +3 -3
  72. package/commands/gsd/stats.md +1 -1
  73. package/commands/gsd/surface.md +4 -4
  74. package/commands/gsd/thread.md +1 -1
  75. package/commands/gsd/ui-phase.md +2 -2
  76. package/commands/gsd/ui-review.md +2 -2
  77. package/commands/gsd/ultraplan-phase.md +2 -2
  78. package/commands/gsd/undo.md +3 -3
  79. package/commands/gsd/update.md +3 -3
  80. package/commands/gsd/validate-phase.md +1 -1
  81. package/commands/gsd/verify-work.md +2 -2
  82. package/commands/gsd/workspace.md +4 -4
  83. package/{get-shit-done → gsd-core}/bin/check-latest-version.cjs +1 -1
  84. package/{get-shit-done → gsd-core}/bin/gsd-tools.cjs +13 -3
  85. package/gsd-core/bin/lib/active-workstream-store.cjs +291 -0
  86. package/gsd-core/bin/lib/adr-parser.cjs +399 -0
  87. package/gsd-core/bin/lib/agent-command-router.cjs +68 -0
  88. package/gsd-core/bin/lib/artifacts.cjs +51 -0
  89. package/gsd-core/bin/lib/audit.cjs +743 -0
  90. package/gsd-core/bin/lib/check-command-router.cjs +342 -0
  91. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +81 -0
  92. package/gsd-core/bin/lib/clock.cjs +95 -0
  93. package/gsd-core/bin/lib/clusters.cjs +128 -0
  94. package/gsd-core/bin/lib/code-review-flags.cjs +59 -0
  95. package/gsd-core/bin/lib/command-aliases.cjs +809 -0
  96. package/gsd-core/bin/lib/command-arg-projection.cjs +55 -0
  97. package/gsd-core/bin/lib/command-routing-hub.cjs +300 -0
  98. package/gsd-core/bin/lib/commands.cjs +1202 -0
  99. package/gsd-core/bin/lib/config-schema.cjs +29 -0
  100. package/{get-shit-done → gsd-core}/bin/lib/config-types.cjs +1 -1
  101. package/gsd-core/bin/lib/config.cjs +714 -0
  102. package/{get-shit-done → gsd-core}/bin/lib/configuration.cjs +76 -85
  103. package/gsd-core/bin/lib/context-utilization.cjs +48 -0
  104. package/gsd-core/bin/lib/core.cjs +2007 -0
  105. package/{get-shit-done → gsd-core}/bin/lib/decisions.cjs +9 -7
  106. package/gsd-core/bin/lib/docs.cjs +252 -0
  107. package/gsd-core/bin/lib/drift.cjs +364 -0
  108. package/gsd-core/bin/lib/fallow-runner.cjs +115 -0
  109. package/gsd-core/bin/lib/frontmatter.cjs +442 -0
  110. package/gsd-core/bin/lib/gap-checker.cjs +257 -0
  111. package/gsd-core/bin/lib/graphify.cjs +496 -0
  112. package/gsd-core/bin/lib/gsd2-import.cjs +456 -0
  113. package/gsd-core/bin/lib/init-command-router.cjs +62 -0
  114. package/gsd-core/bin/lib/init.cjs +1799 -0
  115. package/gsd-core/bin/lib/install-profiles.cjs +526 -0
  116. package/gsd-core/bin/lib/installer-migration-authoring.cjs +122 -0
  117. package/gsd-core/bin/lib/installer-migration-report.cjs +347 -0
  118. package/gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs +218 -0
  119. package/gsd-core/bin/lib/installer-migrations/001-legacy-orphan-files.cjs +48 -0
  120. package/gsd-core/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs +94 -0
  121. package/gsd-core/bin/lib/installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs +108 -0
  122. package/gsd-core/bin/lib/installer-migrations.cjs +823 -0
  123. package/gsd-core/bin/lib/intel.cjs +607 -0
  124. package/gsd-core/bin/lib/learnings.cjs +270 -0
  125. package/gsd-core/bin/lib/legacy-cleanup.cjs +253 -0
  126. package/gsd-core/bin/lib/milestone.cjs +373 -0
  127. package/gsd-core/bin/lib/model-catalog.cjs +154 -0
  128. package/gsd-core/bin/lib/model-profiles.cjs +24 -0
  129. package/gsd-core/bin/lib/observability/event.cjs +51 -0
  130. package/gsd-core/bin/lib/observability/logger.cjs +146 -0
  131. package/{get-shit-done → gsd-core}/bin/lib/observability/redaction.cjs +19 -21
  132. package/{get-shit-done → gsd-core}/bin/lib/package-identity.cjs +5 -1
  133. package/gsd-core/bin/lib/phase-command-router.cjs +189 -0
  134. package/gsd-core/bin/lib/phase-lifecycle.cjs +74 -0
  135. package/gsd-core/bin/lib/phase.cjs +1307 -0
  136. package/gsd-core/bin/lib/phases-command-router.cjs +43 -0
  137. package/{get-shit-done → gsd-core}/bin/lib/plan-scan.cjs +18 -24
  138. package/gsd-core/bin/lib/planning-workspace.cjs +245 -0
  139. package/gsd-core/bin/lib/profile-output.cjs +1120 -0
  140. package/gsd-core/bin/lib/profile-pipeline.cjs +517 -0
  141. package/gsd-core/bin/lib/project-root.cjs +119 -0
  142. package/gsd-core/bin/lib/prompt-budget.cjs +305 -0
  143. package/gsd-core/bin/lib/review-reviewer-selection.cjs +121 -0
  144. package/gsd-core/bin/lib/roadmap-command-router.cjs +166 -0
  145. package/gsd-core/bin/lib/roadmap-upgrade.cjs +491 -0
  146. package/gsd-core/bin/lib/roadmap.cjs +600 -0
  147. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +254 -0
  148. package/gsd-core/bin/lib/runtime-homes.cjs +169 -0
  149. package/gsd-core/bin/lib/runtime-name-policy.cjs +96 -0
  150. package/gsd-core/bin/lib/runtime-slash.cjs +119 -0
  151. package/{get-shit-done → gsd-core}/bin/lib/schema-detect.cjs +20 -26
  152. package/gsd-core/bin/lib/secrets.cjs +34 -0
  153. package/gsd-core/bin/lib/security.cjs +407 -0
  154. package/{get-shit-done → gsd-core}/bin/lib/semver-compare.cjs +1 -1
  155. package/gsd-core/bin/lib/shell-command-projection.cjs +524 -0
  156. package/gsd-core/bin/lib/state-command-router.cjs +160 -0
  157. package/{get-shit-done → gsd-core}/bin/lib/state-document.cjs +78 -78
  158. package/gsd-core/bin/lib/state.cjs +2010 -0
  159. package/gsd-core/bin/lib/surface.cjs +406 -0
  160. package/gsd-core/bin/lib/task-command-router.cjs +85 -0
  161. package/gsd-core/bin/lib/template.cjs +237 -0
  162. package/gsd-core/bin/lib/uat.cjs +297 -0
  163. package/gsd-core/bin/lib/ui-safety-gate.cjs +98 -0
  164. package/gsd-core/bin/lib/update-context.cjs +218 -0
  165. package/gsd-core/bin/lib/validate-command-router.cjs +91 -0
  166. package/gsd-core/bin/lib/validate.cjs +112 -0
  167. package/gsd-core/bin/lib/verify-command-router.cjs +44 -0
  168. package/gsd-core/bin/lib/verify.cjs +1451 -0
  169. package/{get-shit-done → gsd-core}/bin/lib/workstream-inventory-builder.cjs +19 -12
  170. package/gsd-core/bin/lib/workstream-inventory.cjs +147 -0
  171. package/{get-shit-done → gsd-core}/bin/lib/workstream-name-policy.cjs +28 -31
  172. package/gsd-core/bin/lib/workstream.cjs +380 -0
  173. package/gsd-core/bin/lib/worktree-safety.cjs +912 -0
  174. package/{get-shit-done → gsd-core}/bin/shared/config-schema.manifest.json +5 -0
  175. package/{get-shit-done → gsd-core}/references/artifact-types.md +1 -1
  176. package/{get-shit-done → gsd-core}/references/few-shot-examples/plan-checker.md +2 -2
  177. package/{get-shit-done → gsd-core}/references/few-shot-examples/verifier.md +12 -12
  178. package/{get-shit-done → gsd-core}/references/model-profile-resolution.md +1 -1
  179. package/{get-shit-done → gsd-core}/references/mvp-concepts.md +1 -1
  180. package/{get-shit-done → gsd-core}/references/planner-graphify-auto-update.md +2 -2
  181. package/{get-shit-done → gsd-core}/references/planner-mvp-mode.md +1 -1
  182. package/{get-shit-done → gsd-core}/references/verification-patterns.md +1 -1
  183. package/gsd-core/references/worktree-branch-check.md +38 -0
  184. package/{get-shit-done → gsd-core}/references/worktree-path-safety.md +4 -26
  185. package/{get-shit-done → gsd-core}/templates/README.md +2 -2
  186. package/{get-shit-done → gsd-core}/templates/codebase/structure.md +10 -10
  187. package/{get-shit-done → gsd-core}/templates/copilot-instructions.md +1 -1
  188. package/{get-shit-done → gsd-core}/templates/phase-prompt.md +10 -10
  189. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -0
  190. package/{get-shit-done → gsd-core}/workflows/add-backlog.md +1 -1
  191. package/{get-shit-done → gsd-core}/workflows/add-phase.md +1 -1
  192. package/{get-shit-done → gsd-core}/workflows/add-tests.md +1 -1
  193. package/{get-shit-done → gsd-core}/workflows/add-todo.md +1 -1
  194. package/{get-shit-done → gsd-core}/workflows/ai-integration-phase.md +4 -4
  195. package/{get-shit-done → gsd-core}/workflows/audit-fix.md +1 -1
  196. package/{get-shit-done → gsd-core}/workflows/audit-milestone.md +1 -1
  197. package/{get-shit-done → gsd-core}/workflows/audit-uat.md +1 -1
  198. package/{get-shit-done → gsd-core}/workflows/autonomous.md +4 -4
  199. package/{get-shit-done → gsd-core}/workflows/check-todos.md +1 -1
  200. package/{get-shit-done → gsd-core}/workflows/cleanup.md +1 -1
  201. package/{get-shit-done → gsd-core}/workflows/code-review-fix.md +1 -1
  202. package/{get-shit-done → gsd-core}/workflows/code-review.md +4 -4
  203. package/{get-shit-done → gsd-core}/workflows/complete-milestone.md +2 -2
  204. package/{get-shit-done → gsd-core}/workflows/debug.md +1 -1
  205. package/{get-shit-done → gsd-core}/workflows/diagnose-issues.md +4 -2
  206. package/{get-shit-done → gsd-core}/workflows/discovery-phase.md +5 -5
  207. package/{get-shit-done → gsd-core}/workflows/discuss-phase/modes/advisor.md +4 -4
  208. package/{get-shit-done → gsd-core}/workflows/discuss-phase/modes/auto.md +1 -1
  209. package/{get-shit-done → gsd-core}/workflows/discuss-phase/modes/chain.md +1 -1
  210. package/{get-shit-done → gsd-core}/workflows/discuss-phase/modes/power.md +1 -1
  211. package/{get-shit-done → gsd-core}/workflows/discuss-phase-assumptions.md +2 -2
  212. package/{get-shit-done → gsd-core}/workflows/discuss-phase.md +6 -6
  213. package/{get-shit-done → gsd-core}/workflows/do.md +1 -1
  214. package/{get-shit-done → gsd-core}/workflows/docs-update.md +1 -1
  215. package/{get-shit-done → gsd-core}/workflows/edit-phase.md +1 -1
  216. package/{get-shit-done → gsd-core}/workflows/eval-review.md +2 -2
  217. package/{get-shit-done → gsd-core}/workflows/execute-phase/steps/codebase-drift-gate.md +15 -2
  218. package/{get-shit-done → gsd-core}/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  219. package/{get-shit-done → gsd-core}/workflows/execute-phase.md +68 -46
  220. package/{get-shit-done → gsd-core}/workflows/execute-plan.md +8 -8
  221. package/{get-shit-done → gsd-core}/workflows/explore.md +3 -3
  222. package/{get-shit-done → gsd-core}/workflows/extract-learnings.md +1 -1
  223. package/{get-shit-done → gsd-core}/workflows/forensics.md +1 -1
  224. package/{get-shit-done → gsd-core}/workflows/graduation.md +1 -1
  225. package/{get-shit-done → gsd-core}/workflows/health.md +1 -1
  226. package/{get-shit-done → gsd-core}/workflows/help/modes/full.md +1 -1
  227. package/{get-shit-done → gsd-core}/workflows/import.md +1 -1
  228. package/{get-shit-done → gsd-core}/workflows/ingest-docs.md +3 -3
  229. package/{get-shit-done → gsd-core}/workflows/insert-phase.md +1 -1
  230. package/{get-shit-done → gsd-core}/workflows/list-workspaces.md +1 -1
  231. package/{get-shit-done → gsd-core}/workflows/manager.md +1 -1
  232. package/{get-shit-done → gsd-core}/workflows/map-codebase.md +1 -1
  233. package/{get-shit-done → gsd-core}/workflows/milestone-summary.md +1 -1
  234. package/{get-shit-done → gsd-core}/workflows/mvp-phase.md +7 -7
  235. package/{get-shit-done → gsd-core}/workflows/new-milestone.md +3 -3
  236. package/{get-shit-done → gsd-core}/workflows/new-project.md +6 -6
  237. package/{get-shit-done → gsd-core}/workflows/new-workspace.md +1 -1
  238. package/{get-shit-done → gsd-core}/workflows/next.md +1 -1
  239. package/{get-shit-done → gsd-core}/workflows/pause-work.md +1 -1
  240. package/{get-shit-done → gsd-core}/workflows/plan-milestone-gaps.md +1 -1
  241. package/{get-shit-done → gsd-core}/workflows/plan-phase.md +15 -15
  242. package/{get-shit-done → gsd-core}/workflows/plan-review-convergence.md +7 -7
  243. package/{get-shit-done → gsd-core}/workflows/plant-seed.md +1 -1
  244. package/{get-shit-done → gsd-core}/workflows/profile-user.md +11 -11
  245. package/{get-shit-done → gsd-core}/workflows/progress.md +1 -1
  246. package/{get-shit-done → gsd-core}/workflows/quick.md +2 -26
  247. package/{get-shit-done → gsd-core}/workflows/reapply-patches.md +2 -2
  248. package/{get-shit-done → gsd-core}/workflows/remove-phase.md +1 -1
  249. package/{get-shit-done → gsd-core}/workflows/remove-workspace.md +1 -1
  250. package/{get-shit-done → gsd-core}/workflows/resume-project.md +2 -2
  251. package/{get-shit-done → gsd-core}/workflows/review.md +1 -1
  252. package/{get-shit-done → gsd-core}/workflows/scan.md +1 -1
  253. package/{get-shit-done → gsd-core}/workflows/secure-phase.md +3 -3
  254. package/{get-shit-done → gsd-core}/workflows/settings-advanced.md +1 -1
  255. package/{get-shit-done → gsd-core}/workflows/settings-integrations.md +2 -2
  256. package/{get-shit-done → gsd-core}/workflows/settings.md +1 -1
  257. package/{get-shit-done → gsd-core}/workflows/ship.md +56 -4
  258. package/{get-shit-done → gsd-core}/workflows/sketch-wrap-up.md +1 -1
  259. package/{get-shit-done → gsd-core}/workflows/sketch.md +5 -5
  260. package/{get-shit-done → gsd-core}/workflows/spec-phase.md +2 -2
  261. package/{get-shit-done → gsd-core}/workflows/spike-wrap-up.md +1 -1
  262. package/{get-shit-done → gsd-core}/workflows/spike.md +1 -1
  263. package/{get-shit-done → gsd-core}/workflows/stats.md +1 -1
  264. package/{get-shit-done → gsd-core}/workflows/sync-skills.md +3 -3
  265. package/{get-shit-done → gsd-core}/workflows/thread.md +1 -1
  266. package/{get-shit-done → gsd-core}/workflows/transition.md +2 -2
  267. package/{get-shit-done → gsd-core}/workflows/ui-phase.md +3 -3
  268. package/{get-shit-done → gsd-core}/workflows/ui-review.md +2 -2
  269. package/{get-shit-done → gsd-core}/workflows/ultraplan-phase.md +1 -1
  270. package/{get-shit-done → gsd-core}/workflows/undo.md +2 -2
  271. package/{get-shit-done → gsd-core}/workflows/update.md +41 -16
  272. package/{get-shit-done → gsd-core}/workflows/validate-phase.md +3 -3
  273. package/{get-shit-done → gsd-core}/workflows/verify-phase.md +4 -4
  274. package/{get-shit-done → gsd-core}/workflows/verify-work.md +5 -5
  275. package/hooks/dist/gsd-check-update-worker.js +16 -3
  276. package/hooks/dist/gsd-check-update.js +7 -5
  277. package/hooks/dist/gsd-context-monitor.js +2 -2
  278. package/hooks/dist/gsd-statusline.js +39 -21
  279. package/hooks/dist/gsd-update-banner.js +6 -2
  280. package/hooks/dist/managed-hooks-registry.cjs +35 -0
  281. package/hooks/gsd-check-update-worker.js +16 -3
  282. package/hooks/gsd-check-update.js +7 -5
  283. package/hooks/gsd-context-monitor.js +2 -2
  284. package/hooks/gsd-statusline.js +39 -21
  285. package/hooks/gsd-update-banner.js +6 -2
  286. package/package.json +9 -6
  287. package/scripts/affected-tests-lib.cjs +1 -1
  288. package/scripts/base64-scan.sh +12 -0
  289. package/scripts/build-hooks.js +4 -1
  290. package/scripts/changeset/cli.cjs +2 -2
  291. package/scripts/changeset/github-release-notes.cjs +1 -1
  292. package/scripts/changeset/lint.cjs +1 -1
  293. package/scripts/check-alias-drift.cjs +8 -8
  294. package/scripts/check-env.cjs +5 -1
  295. package/scripts/ci-test-scope.cjs +25 -10
  296. package/scripts/command-contract-helpers.cjs +1 -1
  297. package/scripts/fix-slash-commands.cjs +5 -5
  298. package/scripts/gen-inventory-manifest.cjs +3 -3
  299. package/scripts/generate-package-identity.cjs +28 -7
  300. package/scripts/lib/allowlist-ratchet.cjs +136 -0
  301. package/scripts/lint-command-contract.cjs +1 -1
  302. package/scripts/lint-legacy-dir-name.cjs +156 -0
  303. package/scripts/lint-package-identity-drift.cjs +8 -8
  304. package/scripts/lint-pr-check-project-dir.cjs +0 -1
  305. package/scripts/lint-shell-command-projection-drift.cjs +1 -1
  306. package/scripts/lint-skill-deps.cjs +1 -1
  307. package/scripts/lint-test-file-count.allowlist.json +129 -32
  308. package/scripts/lint-test-file-count.cjs +85 -30
  309. package/scripts/mutation-matrix.cjs +219 -0
  310. package/scripts/prompt-injection-scan.sh +5 -1
  311. package/scripts/release-notes/format-github-release-notes.cjs +256 -0
  312. package/scripts/release-tarball-smoke.cjs +9 -9
  313. package/scripts/run-tests.cjs +11 -3
  314. package/scripts/strip-prose-atrefs.cjs +2 -2
  315. package/scripts/sync-runtime-launcher.cjs +2 -2
  316. package/scripts/verify-npm-publish.cjs +252 -0
  317. package/get-shit-done/bin/lib/active-workstream-store.cjs +0 -302
  318. package/get-shit-done/bin/lib/adr-parser.cjs +0 -394
  319. package/get-shit-done/bin/lib/agent-command-router.cjs +0 -65
  320. package/get-shit-done/bin/lib/artifacts.cjs +0 -53
  321. package/get-shit-done/bin/lib/audit.cjs +0 -755
  322. package/get-shit-done/bin/lib/check-command-router.cjs +0 -333
  323. package/get-shit-done/bin/lib/cjs-command-router-adapter.cjs +0 -118
  324. package/get-shit-done/bin/lib/clock.cjs +0 -96
  325. package/get-shit-done/bin/lib/clusters.cjs +0 -135
  326. package/get-shit-done/bin/lib/code-review-flags.cjs +0 -74
  327. package/get-shit-done/bin/lib/command-aliases.cjs +0 -831
  328. package/get-shit-done/bin/lib/command-arg-projection.cjs +0 -62
  329. package/get-shit-done/bin/lib/command-routing-hub.cjs +0 -388
  330. package/get-shit-done/bin/lib/commands.cjs +0 -1292
  331. package/get-shit-done/bin/lib/config-schema.cjs +0 -31
  332. package/get-shit-done/bin/lib/config.cjs +0 -728
  333. package/get-shit-done/bin/lib/context-utilization.cjs +0 -47
  334. package/get-shit-done/bin/lib/core.cjs +0 -2513
  335. package/get-shit-done/bin/lib/docs.cjs +0 -270
  336. package/get-shit-done/bin/lib/drift.cjs +0 -388
  337. package/get-shit-done/bin/lib/fallow-runner.cjs +0 -109
  338. package/get-shit-done/bin/lib/frontmatter.cjs +0 -389
  339. package/get-shit-done/bin/lib/gap-checker.cjs +0 -254
  340. package/get-shit-done/bin/lib/graphify.cjs +0 -592
  341. package/get-shit-done/bin/lib/gsd2-import.cjs +0 -514
  342. package/get-shit-done/bin/lib/init-command-router.cjs +0 -58
  343. package/get-shit-done/bin/lib/init.cjs +0 -2112
  344. package/get-shit-done/bin/lib/install-profiles.cjs +0 -603
  345. package/get-shit-done/bin/lib/installer-migration-authoring.cjs +0 -117
  346. package/get-shit-done/bin/lib/installer-migration-report.cjs +0 -355
  347. package/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs +0 -220
  348. package/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs +0 -41
  349. package/get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs +0 -80
  350. package/get-shit-done/bin/lib/installer-migrations.cjs +0 -778
  351. package/get-shit-done/bin/lib/intel.cjs +0 -708
  352. package/get-shit-done/bin/lib/learnings.cjs +0 -421
  353. package/get-shit-done/bin/lib/milestone.cjs +0 -372
  354. package/get-shit-done/bin/lib/model-catalog.cjs +0 -229
  355. package/get-shit-done/bin/lib/model-profiles.cjs +0 -31
  356. package/get-shit-done/bin/lib/observability/event.cjs +0 -82
  357. package/get-shit-done/bin/lib/observability/logger.cjs +0 -174
  358. package/get-shit-done/bin/lib/phase-command-router.cjs +0 -191
  359. package/get-shit-done/bin/lib/phase-lifecycle.cjs +0 -79
  360. package/get-shit-done/bin/lib/phase.cjs +0 -1607
  361. package/get-shit-done/bin/lib/phases-command-router.cjs +0 -39
  362. package/get-shit-done/bin/lib/planning-workspace.cjs +0 -238
  363. package/get-shit-done/bin/lib/profile-output.cjs +0 -1141
  364. package/get-shit-done/bin/lib/profile-pipeline.cjs +0 -539
  365. package/get-shit-done/bin/lib/project-root.cjs +0 -112
  366. package/get-shit-done/bin/lib/prompt-budget.cjs +0 -399
  367. package/get-shit-done/bin/lib/review-reviewer-selection.cjs +0 -126
  368. package/get-shit-done/bin/lib/roadmap-command-router.cjs +0 -162
  369. package/get-shit-done/bin/lib/roadmap-upgrade.cjs +0 -569
  370. package/get-shit-done/bin/lib/roadmap.cjs +0 -651
  371. package/get-shit-done/bin/lib/runtime-artifact-layout.cjs +0 -301
  372. package/get-shit-done/bin/lib/runtime-homes.cjs +0 -222
  373. package/get-shit-done/bin/lib/runtime-name-policy.cjs +0 -83
  374. package/get-shit-done/bin/lib/runtime-slash.cjs +0 -112
  375. package/get-shit-done/bin/lib/secrets.cjs +0 -32
  376. package/get-shit-done/bin/lib/security.cjs +0 -600
  377. package/get-shit-done/bin/lib/shell-command-projection.cjs +0 -535
  378. package/get-shit-done/bin/lib/state-command-router.cjs +0 -252
  379. package/get-shit-done/bin/lib/state.cjs +0 -2073
  380. package/get-shit-done/bin/lib/surface.cjs +0 -470
  381. package/get-shit-done/bin/lib/task-command-router.cjs +0 -81
  382. package/get-shit-done/bin/lib/template.cjs +0 -228
  383. package/get-shit-done/bin/lib/uat.cjs +0 -289
  384. package/get-shit-done/bin/lib/ui-safety-gate.cjs +0 -111
  385. package/get-shit-done/bin/lib/update-context.cjs +0 -209
  386. package/get-shit-done/bin/lib/validate-command-router.cjs +0 -83
  387. package/get-shit-done/bin/lib/validate.cjs +0 -118
  388. package/get-shit-done/bin/lib/verify-command-router.cjs +0 -40
  389. package/get-shit-done/bin/lib/verify.cjs +0 -1614
  390. package/get-shit-done/bin/lib/workstream-inventory.cjs +0 -146
  391. package/get-shit-done/bin/lib/workstream.cjs +0 -389
  392. package/get-shit-done/bin/lib/worktree-safety.cjs +0 -985
  393. package/get-shit-done/workflows/_runtime-launcher.snippet.sh +0 -1
  394. package/scripts/lint-no-source-grep-extras.cjs +0 -81
  395. package/scripts/lint-no-source-grep.cjs +0 -174
  396. /package/{get-shit-done → gsd-core}/bin/shared/config-defaults.manifest.json +0 -0
  397. /package/{get-shit-done → gsd-core}/bin/shared/model-catalog.json +0 -0
  398. /package/{get-shit-done → gsd-core}/bin/shared/runtime-aliases.manifest.json +0 -0
  399. /package/{get-shit-done → gsd-core}/bin/verify-reapply-patches.cjs +0 -0
  400. /package/{get-shit-done → gsd-core}/contexts/dev.md +0 -0
  401. /package/{get-shit-done → gsd-core}/contexts/research.md +0 -0
  402. /package/{get-shit-done → gsd-core}/contexts/review.md +0 -0
  403. /package/{get-shit-done → gsd-core}/references/agent-contracts.md +0 -0
  404. /package/{get-shit-done → gsd-core}/references/ai-evals.md +0 -0
  405. /package/{get-shit-done → gsd-core}/references/ai-frameworks.md +0 -0
  406. /package/{get-shit-done → gsd-core}/references/autonomous-smart-discuss.md +0 -0
  407. /package/{get-shit-done → gsd-core}/references/checkpoints.md +0 -0
  408. /package/{get-shit-done → gsd-core}/references/common-bug-patterns.md +0 -0
  409. /package/{get-shit-done → gsd-core}/references/context-budget.md +0 -0
  410. /package/{get-shit-done → gsd-core}/references/continuation-format.md +0 -0
  411. /package/{get-shit-done → gsd-core}/references/debugger-philosophy.md +0 -0
  412. /package/{get-shit-done → gsd-core}/references/decimal-phase-calculation.md +0 -0
  413. /package/{get-shit-done → gsd-core}/references/doc-conflict-engine.md +0 -0
  414. /package/{get-shit-done → gsd-core}/references/domain-probes.md +0 -0
  415. /package/{get-shit-done → gsd-core}/references/execute-mvp-tdd.md +0 -0
  416. /package/{get-shit-done → gsd-core}/references/executor-examples.md +0 -0
  417. /package/{get-shit-done → gsd-core}/references/gate-prompts.md +0 -0
  418. /package/{get-shit-done → gsd-core}/references/gates.md +0 -0
  419. /package/{get-shit-done → gsd-core}/references/git-integration.md +0 -0
  420. /package/{get-shit-done → gsd-core}/references/git-planning-commit.md +0 -0
  421. /package/{get-shit-done → gsd-core}/references/ios-scaffold.md +0 -0
  422. /package/{get-shit-done → gsd-core}/references/mandatory-initial-read.md +0 -0
  423. /package/{get-shit-done → gsd-core}/references/model-profiles.md +0 -0
  424. /package/{get-shit-done → gsd-core}/references/phase-argument-parsing.md +0 -0
  425. /package/{get-shit-done → gsd-core}/references/planner-antipatterns.md +0 -0
  426. /package/{get-shit-done → gsd-core}/references/planner-chunked.md +0 -0
  427. /package/{get-shit-done → gsd-core}/references/planner-gap-closure.md +0 -0
  428. /package/{get-shit-done → gsd-core}/references/planner-human-verify-mode.md +0 -0
  429. /package/{get-shit-done → gsd-core}/references/planner-interface-context.md +0 -0
  430. /package/{get-shit-done → gsd-core}/references/planner-reviews.md +0 -0
  431. /package/{get-shit-done → gsd-core}/references/planner-revision.md +0 -0
  432. /package/{get-shit-done → gsd-core}/references/planner-source-audit.md +0 -0
  433. /package/{get-shit-done → gsd-core}/references/planning-config.md +0 -0
  434. /package/{get-shit-done → gsd-core}/references/project-skills-discovery.md +0 -0
  435. /package/{get-shit-done → gsd-core}/references/questioning.md +0 -0
  436. /package/{get-shit-done → gsd-core}/references/revision-loop.md +0 -0
  437. /package/{get-shit-done → gsd-core}/references/scout-codebase.md +0 -0
  438. /package/{get-shit-done → gsd-core}/references/skeleton-template.md +0 -0
  439. /package/{get-shit-done → gsd-core}/references/sketch-interactivity.md +0 -0
  440. /package/{get-shit-done → gsd-core}/references/sketch-theme-system.md +0 -0
  441. /package/{get-shit-done → gsd-core}/references/sketch-tooling.md +0 -0
  442. /package/{get-shit-done → gsd-core}/references/sketch-variant-patterns.md +0 -0
  443. /package/{get-shit-done → gsd-core}/references/spidr-splitting.md +0 -0
  444. /package/{get-shit-done → gsd-core}/references/tdd.md +0 -0
  445. /package/{get-shit-done → gsd-core}/references/thinking-models-debug.md +0 -0
  446. /package/{get-shit-done → gsd-core}/references/thinking-models-execution.md +0 -0
  447. /package/{get-shit-done → gsd-core}/references/thinking-models-planning.md +0 -0
  448. /package/{get-shit-done → gsd-core}/references/thinking-models-research.md +0 -0
  449. /package/{get-shit-done → gsd-core}/references/thinking-models-verification.md +0 -0
  450. /package/{get-shit-done → gsd-core}/references/thinking-partner.md +0 -0
  451. /package/{get-shit-done → gsd-core}/references/ui-brand.md +0 -0
  452. /package/{get-shit-done → gsd-core}/references/universal-anti-patterns.md +0 -0
  453. /package/{get-shit-done → gsd-core}/references/user-profiling.md +0 -0
  454. /package/{get-shit-done → gsd-core}/references/user-story-template.md +0 -0
  455. /package/{get-shit-done → gsd-core}/references/verification-overrides.md +0 -0
  456. /package/{get-shit-done → gsd-core}/references/verify-mvp-mode.md +0 -0
  457. /package/{get-shit-done → gsd-core}/references/workstream-flag.md +0 -0
  458. /package/{get-shit-done → gsd-core}/templates/AI-SPEC.md +0 -0
  459. /package/{get-shit-done → gsd-core}/templates/DEBUG.md +0 -0
  460. /package/{get-shit-done → gsd-core}/templates/SECURITY.md +0 -0
  461. /package/{get-shit-done → gsd-core}/templates/UAT.md +0 -0
  462. /package/{get-shit-done → gsd-core}/templates/UI-SPEC.md +0 -0
  463. /package/{get-shit-done → gsd-core}/templates/VALIDATION.md +0 -0
  464. /package/{get-shit-done → gsd-core}/templates/claude-md.md +0 -0
  465. /package/{get-shit-done → gsd-core}/templates/codebase/architecture.md +0 -0
  466. /package/{get-shit-done → gsd-core}/templates/codebase/concerns.md +0 -0
  467. /package/{get-shit-done → gsd-core}/templates/codebase/conventions.md +0 -0
  468. /package/{get-shit-done → gsd-core}/templates/codebase/integrations.md +0 -0
  469. /package/{get-shit-done → gsd-core}/templates/codebase/stack.md +0 -0
  470. /package/{get-shit-done → gsd-core}/templates/codebase/testing.md +0 -0
  471. /package/{get-shit-done → gsd-core}/templates/config.json +0 -0
  472. /package/{get-shit-done → gsd-core}/templates/context.md +0 -0
  473. /package/{get-shit-done → gsd-core}/templates/continue-here.md +0 -0
  474. /package/{get-shit-done → gsd-core}/templates/debug-subagent-prompt.md +0 -0
  475. /package/{get-shit-done → gsd-core}/templates/dev-preferences.md +0 -0
  476. /package/{get-shit-done → gsd-core}/templates/discovery.md +0 -0
  477. /package/{get-shit-done → gsd-core}/templates/discussion-log.md +0 -0
  478. /package/{get-shit-done → gsd-core}/templates/milestone-archive.md +0 -0
  479. /package/{get-shit-done → gsd-core}/templates/milestone.md +0 -0
  480. /package/{get-shit-done → gsd-core}/templates/planner-subagent-prompt.md +0 -0
  481. /package/{get-shit-done → gsd-core}/templates/project.md +0 -0
  482. /package/{get-shit-done → gsd-core}/templates/requirements.md +0 -0
  483. /package/{get-shit-done → gsd-core}/templates/research-project/ARCHITECTURE.md +0 -0
  484. /package/{get-shit-done → gsd-core}/templates/research-project/FEATURES.md +0 -0
  485. /package/{get-shit-done → gsd-core}/templates/research-project/PITFALLS.md +0 -0
  486. /package/{get-shit-done → gsd-core}/templates/research-project/STACK.md +0 -0
  487. /package/{get-shit-done → gsd-core}/templates/research-project/SUMMARY.md +0 -0
  488. /package/{get-shit-done → gsd-core}/templates/research.md +0 -0
  489. /package/{get-shit-done → gsd-core}/templates/retrospective.md +0 -0
  490. /package/{get-shit-done → gsd-core}/templates/roadmap.md +0 -0
  491. /package/{get-shit-done → gsd-core}/templates/spec.md +0 -0
  492. /package/{get-shit-done → gsd-core}/templates/state.md +0 -0
  493. /package/{get-shit-done → gsd-core}/templates/summary-complex.md +0 -0
  494. /package/{get-shit-done → gsd-core}/templates/summary-minimal.md +0 -0
  495. /package/{get-shit-done → gsd-core}/templates/summary-standard.md +0 -0
  496. /package/{get-shit-done → gsd-core}/templates/summary.md +0 -0
  497. /package/{get-shit-done → gsd-core}/templates/user-profile.md +0 -0
  498. /package/{get-shit-done → gsd-core}/templates/user-setup.md +0 -0
  499. /package/{get-shit-done → gsd-core}/templates/verification-report.md +0 -0
  500. /package/{get-shit-done → gsd-core}/workflows/analyze-dependencies.md +0 -0
  501. /package/{get-shit-done → gsd-core}/workflows/discuss-phase/modes/all.md +0 -0
  502. /package/{get-shit-done → gsd-core}/workflows/discuss-phase/modes/analyze.md +0 -0
  503. /package/{get-shit-done → gsd-core}/workflows/discuss-phase/modes/batch.md +0 -0
  504. /package/{get-shit-done → gsd-core}/workflows/discuss-phase/modes/default.md +0 -0
  505. /package/{get-shit-done → gsd-core}/workflows/discuss-phase/modes/text.md +0 -0
  506. /package/{get-shit-done → gsd-core}/workflows/discuss-phase/templates/checkpoint.json +0 -0
  507. /package/{get-shit-done → gsd-core}/workflows/discuss-phase/templates/context.md +0 -0
  508. /package/{get-shit-done → gsd-core}/workflows/discuss-phase/templates/discussion-log.md +0 -0
  509. /package/{get-shit-done → gsd-core}/workflows/discuss-phase-power.md +0 -0
  510. /package/{get-shit-done → gsd-core}/workflows/execute-phase/steps/per-plan-worktree-gate.md +0 -0
  511. /package/{get-shit-done → gsd-core}/workflows/fast.md +0 -0
  512. /package/{get-shit-done → gsd-core}/workflows/help/modes/brief.md +0 -0
  513. /package/{get-shit-done → gsd-core}/workflows/help/modes/default.md +0 -0
  514. /package/{get-shit-done → gsd-core}/workflows/help/modes/topic.md +0 -0
  515. /package/{get-shit-done → gsd-core}/workflows/help.md +0 -0
  516. /package/{get-shit-done → gsd-core}/workflows/inbox.md +0 -0
  517. /package/{get-shit-done → gsd-core}/workflows/list-phase-assumptions.md +0 -0
  518. /package/{get-shit-done → gsd-core}/workflows/node-repair.md +0 -0
  519. /package/{get-shit-done → gsd-core}/workflows/note.md +0 -0
  520. /package/{get-shit-done → gsd-core}/workflows/pr-branch.md +0 -0
  521. /package/{get-shit-done → gsd-core}/workflows/session-report.md +0 -0
@@ -1,2073 +0,0 @@
1
- /**
2
- * State — STATE.md operations and progression engine
3
- */
4
-
5
- const fs = require('fs');
6
- const path = require('path');
7
- const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone, output, error } = require('./core.cjs');
8
- const { platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
9
- const { planningDir, planningPaths } = require('./planning-workspace.cjs');
10
- const { realClock } = require('./clock.cjs');
11
- const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
12
- const scanPhasePlans = require('./plan-scan.cjs');
13
- const {
14
- computeProgressPercent,
15
- normalizeProgressNumbers,
16
- normalizeStateStatus,
17
- shouldPreserveExistingProgress,
18
- stateExtractField,
19
- stateReplaceField,
20
- KNOWN_TEMPLATE_DEFAULTS,
21
- KNOWN_STATUS_PATTERNS,
22
- stateReplaceFieldIfTemplate,
23
- } = require('./state-document.cjs');
24
-
25
- // Cache disk scan results from buildStateFrontmatter per cwd per process (#1967).
26
- // Avoids re-reading N+1 directories on every state write when the phase structure
27
- // hasn't changed within the same gsd-tools invocation.
28
- const _diskScanCache = new Map();
29
-
30
- /** Shorthand — every state command needs this path */
31
- function getStatePath(cwd) {
32
- return planningPaths(cwd).state;
33
- }
34
-
35
- // Track all lock files held by this process so they can be removed on exit.
36
- // process.on('exit') fires even on process.exit(1), unlike try/finally which is
37
- // skipped when error() calls process.exit(1) inside a locked region (#1916).
38
- const _heldStateLocks = new Set();
39
- process.on('exit', () => {
40
- for (const lockPath of _heldStateLocks) {
41
- try { require('fs').unlinkSync(lockPath); } catch { /* already gone */ }
42
- }
43
- });
44
-
45
- // Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match).
46
- const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i;
47
-
48
- function cmdStateLoad(cwd, raw) {
49
- const config = loadConfig(cwd);
50
- const planDir = planningPaths(cwd).planning;
51
-
52
- const stateRaw = platformReadSync(path.join(planDir, 'STATE.md')) || '';
53
-
54
- const configExists = fs.existsSync(path.join(planDir, 'config.json'));
55
- const roadmapExists = fs.existsSync(path.join(planDir, 'ROADMAP.md'));
56
- const stateExists = stateRaw.length > 0;
57
-
58
- const result = {
59
- config,
60
- state_raw: stateRaw,
61
- state_exists: stateExists,
62
- roadmap_exists: roadmapExists,
63
- config_exists: configExists,
64
- };
65
-
66
- // For --raw, output a condensed key=value format
67
- if (raw) {
68
- const c = config;
69
- const lines = [
70
- `model_profile=${c.model_profile}`,
71
- `commit_docs=${c.commit_docs}`,
72
- `branching_strategy=${c.branching_strategy}`,
73
- `phase_branch_template=${c.phase_branch_template}`,
74
- `milestone_branch_template=${c.milestone_branch_template}`,
75
- `parallelization=${c.parallelization}`,
76
- `research=${c.research}`,
77
- `plan_checker=${c.plan_checker}`,
78
- `verifier=${c.verifier}`,
79
- `config_exists=${configExists}`,
80
- `roadmap_exists=${roadmapExists}`,
81
- `state_exists=${stateExists}`,
82
- ];
83
- process.stdout.write(lines.join('\n'));
84
- process.exit(0);
85
- }
86
-
87
- output(result);
88
- }
89
-
90
- function cmdStateGet(cwd, section, raw) {
91
- const statePath = planningPaths(cwd).state;
92
- const content = platformReadSync(statePath);
93
- if (content === null) {
94
- error('STATE.md not found');
95
- return;
96
- }
97
- {
98
-
99
- if (!section) {
100
- output({ content }, raw, content);
101
- return;
102
- }
103
-
104
- // Try to find markdown section or field
105
- const fieldEscaped = escapeRegex(section);
106
-
107
- // Check for **field:** value (bold format)
108
- const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
109
- const boldMatch = content.match(boldPattern);
110
- if (boldMatch) {
111
- output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
112
- return;
113
- }
114
-
115
- // Check for field: value (plain format)
116
- const plainPattern = new RegExp(`^${fieldEscaped}:\\s*(.*)`, 'im');
117
- const plainMatch = content.match(plainPattern);
118
- if (plainMatch) {
119
- output({ [section]: plainMatch[1].trim() }, raw, plainMatch[1].trim());
120
- return;
121
- }
122
-
123
- // Check for ## Section
124
- const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
125
- const sectionMatch = content.match(sectionPattern);
126
- if (sectionMatch) {
127
- output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim());
128
- return;
129
- }
130
-
131
- output({ error: `Section or field "${section}" not found` }, raw, '');
132
- }
133
- }
134
-
135
- function readTextArgOrFile(cwd, value, filePath, label) {
136
- if (!filePath) return value;
137
-
138
- // Path traversal guard: ensure file resolves within project directory
139
- const { validatePath } = require('./security.cjs');
140
- const pathCheck = validatePath(filePath, cwd, { allowAbsolute: true });
141
- if (!pathCheck.safe) {
142
- throw new Error(`${label} path rejected: ${pathCheck.error}`);
143
- }
144
-
145
- try {
146
- return fs.readFileSync(pathCheck.resolved, 'utf-8').trimEnd();
147
- } catch {
148
- throw new Error(`${label} file not found: ${filePath}`);
149
- }
150
- }
151
-
152
- function cmdStatePatch(cwd, patches, raw) {
153
- // Validate all field names before processing
154
- const { validateFieldName } = require('./security.cjs');
155
- for (const field of Object.keys(patches)) {
156
- const fieldCheck = validateFieldName(field);
157
- if (!fieldCheck.valid) {
158
- error(`state patch: ${fieldCheck.error}`);
159
- }
160
- }
161
-
162
- const statePath = planningPaths(cwd).state;
163
- try {
164
- const results = { updated: [], failed: [] };
165
-
166
- // Use atomic read-modify-write to prevent lost updates from concurrent agents
167
- readModifyWriteStateMd(statePath, (content) => {
168
- for (const [field, value] of Object.entries(patches)) {
169
- const result = stateReplaceField(content, field, value);
170
- if (result) {
171
- content = result;
172
- results.updated.push(field);
173
- } else {
174
- results.failed.push(field);
175
- }
176
- }
177
- return content;
178
- }, cwd);
179
-
180
- output(results, raw, results.updated.length > 0 ? 'true' : 'false');
181
- } catch {
182
- error('STATE.md not found');
183
- }
184
- }
185
-
186
- function cmdStateUpdate(cwd, field, value) {
187
- if (!field || value === undefined) {
188
- error('field and value required for state update');
189
- }
190
-
191
- // Validate field name to prevent regex injection via crafted field names
192
- const { validateFieldName } = require('./security.cjs');
193
- const fieldCheck = validateFieldName(field);
194
- if (!fieldCheck.valid) {
195
- error(`state update: ${fieldCheck.error}`);
196
- }
197
-
198
- const statePath = planningPaths(cwd).state;
199
- try {
200
- let updated = false;
201
- const shouldResync = ['Progress', 'Total Plans in Phase', 'Total Phases'].includes(field);
202
- // Preserve curated progress for body-only updates, but allow fields that
203
- // directly project into progress.* frontmatter to rebuild after mutation.
204
- readModifyWriteStateMd(statePath, (content) => {
205
- const body = stripFrontmatter(content);
206
- const result = stateReplaceField(body, field, value);
207
- if (result) {
208
- updated = true;
209
- const existingFm = extractFrontmatter(content);
210
- if (Object.keys(existingFm).length > 0) {
211
- return `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}`;
212
- }
213
- return result;
214
- }
215
- return content;
216
- }, cwd, { resync: shouldResync });
217
- if (updated) {
218
- output({ updated: true });
219
- } else {
220
- output({ updated: false, reason: `Field "${field}" not found in STATE.md` });
221
- }
222
- } catch {
223
- output({ updated: false, reason: 'STATE.md not found' });
224
- }
225
- }
226
-
227
- // ─── State Progression Engine ────────────────────────────────────────────────
228
-
229
- /**
230
- * Replace a STATE.md field with fallback field name support.
231
- * Tries `primary` first, then `fallback` (if provided), returns content unchanged
232
- * if neither matches. This consolidates the replaceWithFallback pattern that was
233
- * previously duplicated inline across phase.cjs, milestone.cjs, and state.cjs.
234
- */
235
- function stateReplaceFieldWithFallback(content, primary, fallback, value) {
236
- let result = stateReplaceField(content, primary, value);
237
- if (result) return result;
238
- if (fallback) {
239
- result = stateReplaceField(content, fallback, value);
240
- if (result) return result;
241
- }
242
- // Neither pattern matched — field may have been reformatted or removed.
243
- // Log diagnostic so template drift is detected early rather than silently swallowed.
244
- process.stderr.write(
245
- `[gsd-tools] WARNING: STATE.md field "${primary}"${fallback ? ` (fallback: "${fallback}")` : ''} not found — update skipped. ` +
246
- `This may indicate STATE.md was externally modified or uses an unexpected format.\n`
247
- );
248
- return content;
249
- }
250
-
251
- /**
252
- * Update fields within the ## Current Position section of STATE.md.
253
- * This keeps the Current Position body in sync with the bold frontmatter fields.
254
- * Only updates fields that already exist in the section; does not add new lines.
255
- * Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
256
- */
257
- function updateCurrentPositionFields(content, fields) {
258
- const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
259
- const posMatch = content.match(posPattern);
260
- if (!posMatch) return content;
261
-
262
- let posBody = posMatch[2];
263
- const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
264
- const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
265
-
266
- if (fields.status && /^Status:/m.test(posBody)) {
267
- // Only replace when the existing Current Position Status is a known template default.
268
- const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
269
- const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
270
- const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
271
- const matchesPattern = existingStatus && KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
272
- const isDefault = !existingStatus || isInList || matchesPattern;
273
- if (isDefault) {
274
- posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
275
- }
276
- }
277
- if (fields.lastActivity && /^Last activity:/im.test(posBody)) {
278
- // Only replace when the existing Current Position Last activity is a known template
279
- // default (a bare ISO date). Executor-authored narrative prose is preserved.
280
- const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
281
- const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
282
- // A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
283
- // A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
284
- // was authored by the executor and must be preserved.
285
- const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
286
- const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
287
- const isDefault = !existingActivity || isDateShape || inList;
288
- if (isDefault) {
289
- posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
290
- }
291
- }
292
- if (fields.plan && /^Plan:/m.test(posBody)) {
293
- posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
294
- }
295
-
296
- return content.replace(posPattern, () => `${posMatch[1]}${posBody}`);
297
- }
298
-
299
- function cmdStateAdvancePlan(cwd, raw) {
300
- const statePath = planningPaths(cwd).state;
301
- if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
302
-
303
- const today = realClock.today();
304
- let result = null;
305
-
306
- readModifyWriteStateMd(statePath, (content) => {
307
- // Try legacy separate fields first, then compound "Plan: X of Y" format
308
- const legacyPlan = stateExtractField(content, 'Current Plan');
309
- const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
310
- const planField = stateExtractField(content, 'Plan');
311
-
312
- let currentPlan, totalPlans;
313
- let useCompoundFormat = false;
314
-
315
- if (legacyPlan && legacyTotal) {
316
- currentPlan = parseInt(legacyPlan, 10);
317
- totalPlans = parseInt(legacyTotal, 10);
318
- } else if (planField) {
319
- // Compound format: "2 of 6 in current phase" or "2 of 6"
320
- currentPlan = parseInt(planField, 10);
321
- const ofMatch = planField.match(/of\s+(\d+)/);
322
- totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
323
- useCompoundFormat = true;
324
- }
325
-
326
- if (isNaN(currentPlan) || isNaN(totalPlans)) {
327
- result = { error: true };
328
- return content;
329
- }
330
-
331
- const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
332
- const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
333
-
334
- if (currentPlan >= totalPlans) {
335
- // Phase-complete branch — only replace Status/Last Activity when the existing
336
- // value is a known template default (Knuth invariant: preserve executor-authored).
337
- content = stateReplaceFieldIfTemplate(content, 'Status', statusDefaults, 'Phase complete — ready for verification');
338
- content = stateReplaceFieldIfTemplate(content, 'Last Activity', lastActivityDefaults, today);
339
- // stateReplaceFieldWithFallback tries 'Last activity' alias too
340
- content = stateReplaceFieldIfTemplate(content, 'Last activity', lastActivityDefaults, today);
341
- content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
342
- result = { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' };
343
- } else {
344
- const newPlan = currentPlan + 1;
345
- let planDisplayValue;
346
- if (useCompoundFormat) {
347
- // Preserve compound format: "X of Y in current phase" → replace X only
348
- planDisplayValue = planField.replace(/^\d+/, String(newPlan));
349
- content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
350
- } else {
351
- planDisplayValue = `${newPlan} of ${totalPlans}`;
352
- content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
353
- }
354
- // Normal advance — only replace Status/Last Activity when the existing value is
355
- // a known template default (Knuth invariant: preserve executor-authored).
356
- content = stateReplaceFieldIfTemplate(content, 'Status', statusDefaults, 'Ready to execute');
357
- content = stateReplaceFieldIfTemplate(content, 'Last Activity', lastActivityDefaults, today);
358
- content = stateReplaceFieldIfTemplate(content, 'Last activity', lastActivityDefaults, today);
359
- content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
360
- result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
361
- }
362
- return content;
363
- }, cwd);
364
-
365
- if (!result || result.error) {
366
- output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw);
367
- return;
368
- }
369
-
370
- if (result.advanced === false) {
371
- output(result, raw, 'false');
372
- } else {
373
- output(result, raw, 'true');
374
- }
375
- }
376
-
377
- function cmdStateRecordMetric(cwd, options, raw) {
378
- const statePath = planningPaths(cwd).state;
379
- if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
380
-
381
- const { phase, plan, duration, tasks, files } = options;
382
-
383
- if (!phase || !plan || !duration) {
384
- output({ error: 'phase, plan, and duration required' }, raw);
385
- return;
386
- }
387
-
388
- let recorded = false;
389
- let created = false;
390
- readModifyWriteStateMd(statePath, (content) => {
391
- // Find Performance Metrics section and its table
392
- const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
393
- const metricsMatch = content.match(metricsPattern);
394
-
395
- const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
396
-
397
- if (metricsMatch) {
398
- let tableBody = metricsMatch[2].trimEnd();
399
-
400
- if (tableBody.trim() === '' || tableBody.includes('None yet')) {
401
- tableBody = newRow;
402
- } else {
403
- tableBody = tableBody + '\n' + newRow;
404
- }
405
-
406
- recorded = true;
407
- return content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
408
- }
409
-
410
- // Section absent — DWIM: auto-create canonical ## Performance Metrics scaffold,
411
- // then append the row. Matches state begin-phase / advance-plan DWIM behavior.
412
- const scaffold = [
413
- '',
414
- '## Performance Metrics',
415
- '',
416
- '| Phase | Plan | Duration | Notes |',
417
- '|-------|------|----------|-------|',
418
- newRow,
419
- '',
420
- ].join('\n');
421
- recorded = true;
422
- created = true;
423
- return content.trimEnd() + '\n' + scaffold;
424
- }, cwd);
425
-
426
- // Auto-create fallback guarantees recorded === true; no else branch needed.
427
- const result = { recorded: true, phase, plan, duration };
428
- if (created) result.created = true;
429
- output(result, raw, 'true');
430
- }
431
-
432
- function cmdStateUpdateProgress(cwd, raw) {
433
- const statePath = planningPaths(cwd).state;
434
- if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
435
-
436
- // Count summaries across current milestone phases only (outside lock — read-only)
437
- const phasesDir = planningPaths(cwd).phases;
438
- let totalPlans = 0;
439
- let totalSummaries = 0;
440
-
441
- if (fs.existsSync(phasesDir)) {
442
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
443
- const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
444
- .filter(e => e.isDirectory()).map(e => e.name)
445
- .filter(isDirInMilestone);
446
- for (const dir of phaseDirs) {
447
- const { planCount, summaryCount } = scanPhasePlans(path.join(phasesDir, dir));
448
- totalPlans += planCount;
449
- totalSummaries += summaryCount;
450
- }
451
- }
452
-
453
- const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
454
- const barWidth = 10;
455
- const filled = Math.round(percent / 100 * barWidth);
456
- const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
457
- const progressStr = `[${bar}] ${percent}%`;
458
-
459
- let updated = false;
460
- const _totalPlans = totalPlans;
461
- const _totalSummaries = totalSummaries;
462
-
463
- readModifyWriteStateMd(statePath, (content) => {
464
- // Try **Progress:** bold format first, then plain Progress: format
465
- const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
466
- const plainProgressPattern = /^(Progress:\s*).*/im;
467
- if (boldProgressPattern.test(content)) {
468
- updated = true;
469
- return content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
470
- } else if (plainProgressPattern.test(content)) {
471
- updated = true;
472
- return content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
473
- }
474
- return content;
475
- }, cwd);
476
-
477
- if (updated) {
478
- output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
479
- } else {
480
- output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
481
- }
482
- }
483
-
484
- function cmdStateAddDecision(cwd, options, raw) {
485
- const statePath = planningPaths(cwd).state;
486
- if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
487
-
488
- const { phase, summary, summary_file, rationale, rationale_file } = options;
489
- let summaryText = null;
490
- let rationaleText = '';
491
-
492
- try {
493
- summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
494
- rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale');
495
- } catch (err) {
496
- output({ added: false, reason: err.message }, raw, 'false');
497
- return;
498
- }
499
-
500
- if (!summaryText) { output({ error: 'summary required' }, raw); return; }
501
-
502
- const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
503
- let added = false;
504
- let created = false;
505
-
506
- readModifyWriteStateMd(statePath, (content) => {
507
- // Find Decisions section (various heading patterns)
508
- const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
509
- const match = content.match(sectionPattern);
510
-
511
- if (match) {
512
- let sectionBody = match[2];
513
- // Remove placeholders
514
- sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
515
- sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
516
- added = true;
517
- return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
518
- }
519
-
520
- // Section absent — DWIM: auto-create canonical ## Decisions scaffold,
521
- // then append the entry. Matches state begin-phase / advance-plan DWIM behavior.
522
- const scaffold = [
523
- '',
524
- '## Decisions',
525
- '',
526
- entry,
527
- '',
528
- ].join('\n');
529
- added = true;
530
- created = true;
531
- return content.trimEnd() + '\n' + scaffold;
532
- }, cwd);
533
-
534
- // Auto-create fallback guarantees added === true; no else branch needed.
535
- const result = { added: true, decision: entry };
536
- if (created) result.created = true;
537
- output(result, raw, 'true');
538
- }
539
-
540
- function cmdStateAddBlocker(cwd, text, raw) {
541
- const statePath = planningPaths(cwd).state;
542
- if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
543
- const blockerOptions = typeof text === 'object' && text !== null ? text : { text };
544
- let blockerText = null;
545
-
546
- try {
547
- blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
548
- } catch (err) {
549
- output({ added: false, reason: err.message }, raw, 'false');
550
- return;
551
- }
552
-
553
- if (!blockerText) { output({ error: 'text required' }, raw); return; }
554
-
555
- const entry = `- ${blockerText}`;
556
- let added = false;
557
- let created = false;
558
-
559
- readModifyWriteStateMd(statePath, (content) => {
560
- const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
561
- const match = content.match(sectionPattern);
562
-
563
- if (match) {
564
- let sectionBody = match[2];
565
- sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
566
- sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
567
- added = true;
568
- return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
569
- }
570
-
571
- // Section absent — DWIM: auto-create canonical ### Blockers scaffold.
572
- const scaffold = [
573
- '',
574
- '### Blockers',
575
- '',
576
- entry,
577
- '',
578
- ].join('\n');
579
- added = true;
580
- created = true;
581
- return content.trimEnd() + '\n' + scaffold;
582
- }, cwd);
583
-
584
- // Auto-create fallback guarantees added === true; no else branch needed.
585
- const result = { added: true, blocker: blockerText };
586
- if (created) result.created = true;
587
- output(result, raw, 'true');
588
- }
589
-
590
- function cmdStateResolveBlocker(cwd, text, raw) {
591
- const statePath = planningPaths(cwd).state;
592
- if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
593
- if (!text) { output({ error: 'text required' }, raw); return; }
594
-
595
- let resolved = false;
596
-
597
- readModifyWriteStateMd(statePath, (content) => {
598
- const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
599
- const match = content.match(sectionPattern);
600
-
601
- if (match) {
602
- const sectionBody = match[2];
603
- const lines = sectionBody.split('\n');
604
- const filtered = lines.filter(line => {
605
- if (!line.startsWith('- ')) return true;
606
- return !line.toLowerCase().includes(text.toLowerCase());
607
- });
608
-
609
- let newBody = filtered.join('\n');
610
- // If section is now empty, add placeholder
611
- if (!newBody.trim() || !newBody.includes('- ')) {
612
- newBody = 'None\n';
613
- }
614
-
615
- resolved = true;
616
- return content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
617
- }
618
- return content;
619
- }, cwd);
620
-
621
- if (resolved) {
622
- output({ resolved: true, blocker: text }, raw, 'true');
623
- } else {
624
- output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
625
- }
626
- }
627
-
628
- function cmdStateRecordSession(cwd, options, raw) {
629
- const statePath = planningPaths(cwd).state;
630
- if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
631
-
632
- const now = realClock.nowIso();
633
- const updated = [];
634
-
635
- readModifyWriteStateMd(statePath, (content) => {
636
- // Update Last session / Last Date
637
- let result = stateReplaceField(content, 'Last session', now);
638
- if (result) { content = result; updated.push('Last session'); }
639
- result = stateReplaceField(content, 'Last Date', now);
640
- if (result) { content = result; updated.push('Last Date'); }
641
-
642
- // Update Stopped at
643
- if (options.stopped_at) {
644
- result = stateReplaceField(content, 'Stopped At', options.stopped_at);
645
- if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at);
646
- if (result) { content = result; updated.push('Stopped At'); }
647
- }
648
-
649
- // Update Resume File — only when the caller explicitly passed a value OR the
650
- // existing value is a known template default. An executor-authored path must
651
- // not be silently replaced with 'None' just because --resume-file was omitted
652
- // (Knuth invariant: handler-owns-transition-between-known-template-defaults).
653
- const resumeFileDefaults = KNOWN_TEMPLATE_DEFAULTS['Resume File'];
654
- if (options.resume_file !== undefined && options.resume_file !== null) {
655
- // Caller explicitly passed a value — always honour it.
656
- result = stateReplaceField(content, 'Resume File', options.resume_file);
657
- if (!result) result = stateReplaceField(content, 'Resume file', options.resume_file);
658
- if (result) { content = result; updated.push('Resume File'); }
659
- } else {
660
- // No explicit value — only set 'None' when existing value is also a known default
661
- // (i.e. not executor-authored).
662
- const newRf = stateReplaceFieldIfTemplate(content, 'Resume File', resumeFileDefaults, 'None');
663
- if (newRf !== content) {
664
- content = newRf;
665
- updated.push('Resume File');
666
- } else {
667
- // Try alternate capitalisation
668
- const newRfAlt = stateReplaceFieldIfTemplate(content, 'Resume file', resumeFileDefaults, 'None');
669
- if (newRfAlt !== content) {
670
- content = newRfAlt;
671
- updated.push('Resume File');
672
- }
673
- }
674
- }
675
-
676
- return content;
677
- }, cwd);
678
-
679
- if (updated.length > 0) {
680
- output({ recorded: true, updated }, raw, 'true');
681
- } else {
682
- output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
683
- }
684
- }
685
-
686
- function cmdStateSnapshot(cwd, raw) {
687
- const statePath = planningPaths(cwd).state;
688
-
689
- if (!fs.existsSync(statePath)) {
690
- output({ error: 'STATE.md not found' }, raw);
691
- return;
692
- }
693
-
694
- const content = fs.readFileSync(statePath, 'utf-8');
695
-
696
- // Bug #3265: prefer YAML frontmatter for canonical scalar fields so that a
697
- // body table cell containing **Status:** Y cannot shadow the authoritative
698
- // frontmatter value. Mirrors the fix in sdk/src/query/state.ts.
699
- const fm = extractFrontmatter(content);
700
- const body = stripFrontmatter(content);
701
-
702
- // Helper: return frontmatter scalar value when present and non-empty.
703
- // Accepts strings, numbers, and booleans — coercing non-string primitives to
704
- // their string representation so callers always receive string | null.
705
- // Returns null for missing, null/undefined, or empty-after-trim values so
706
- // the caller falls back to body extraction.
707
- const fmScalar = (key) => {
708
- const v = fm[key];
709
- if (v === null || v === undefined) return null;
710
- if (typeof v === 'string') return v.trim() || null;
711
- if (typeof v === 'number' || typeof v === 'boolean') return String(v);
712
- return null;
713
- };
714
-
715
- // Extract basic fields — frontmatter keys take precedence over body
716
- const currentPhase = fmScalar('current_phase') ?? stateExtractField(body, 'Current Phase');
717
- const currentPhaseName = fmScalar('current_phase_name') ?? stateExtractField(body, 'Current Phase Name');
718
- const totalPhasesRaw = fmScalar('total_phases') ?? stateExtractField(body, 'Total Phases');
719
- const currentPlan = fmScalar('current_plan') ?? stateExtractField(body, 'Current Plan');
720
- const totalPlansRaw = fmScalar('total_plans_in_phase') ?? stateExtractField(body, 'Total Plans in Phase');
721
- const status = fmScalar('status') ?? stateExtractField(body, 'Status');
722
- const progressRaw = fmScalar('progress') ?? stateExtractField(body, 'Progress');
723
- const lastActivity = fmScalar('last_activity') ?? stateExtractField(body, 'Last Activity');
724
- const lastActivityDesc = fmScalar('last_activity_desc') ?? stateExtractField(body, 'Last Activity Description');
725
- const pausedAt = fmScalar('paused_at') ?? stateExtractField(body, 'Paused At');
726
-
727
- // Parse numeric fields
728
- const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
729
- const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
730
- const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
731
-
732
- // Extract decisions table
733
- const decisions = [];
734
- const decisionsMatch = body.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
735
- if (decisionsMatch) {
736
- const tableBody = decisionsMatch[1];
737
- const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
738
- for (const row of rows) {
739
- const cells = row.split('|').map(c => c.trim()).filter(Boolean);
740
- if (cells.length >= 3) {
741
- decisions.push({
742
- phase: cells[0],
743
- summary: cells[1],
744
- rationale: cells[2],
745
- });
746
- }
747
- }
748
- }
749
-
750
- // Extract blockers list
751
- const blockers = [];
752
- const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
753
- if (blockersMatch) {
754
- const blockersSection = blockersMatch[1];
755
- const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
756
- for (const item of items) {
757
- blockers.push(item.replace(/^-\s+/, '').trim());
758
- }
759
- }
760
-
761
- // Extract session info
762
- const session = {
763
- last_date: null,
764
- stopped_at: null,
765
- resume_file: null,
766
- };
767
-
768
- const sessionMatch = body.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
769
- if (sessionMatch) {
770
- const sessionSection = sessionMatch[1];
771
- const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
772
- || sessionSection.match(/^Last Date:\s*(.+)/im);
773
- const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i)
774
- || sessionSection.match(/^Stopped At:\s*(.+)/im);
775
- const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i)
776
- || sessionSection.match(/^Resume File:\s*(.+)/im);
777
-
778
- if (lastDateMatch) session.last_date = lastDateMatch[1].trim();
779
- if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim();
780
- if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim();
781
- }
782
-
783
- const result = {
784
- current_phase: currentPhase,
785
- current_phase_name: currentPhaseName,
786
- total_phases: totalPhases,
787
- current_plan: currentPlan,
788
- total_plans_in_phase: totalPlansInPhase,
789
- status,
790
- progress_percent: progressPercent,
791
- last_activity: lastActivity,
792
- last_activity_desc: lastActivityDesc,
793
- decisions,
794
- blockers,
795
- paused_at: pausedAt,
796
- session,
797
- };
798
-
799
- output(result, raw);
800
- }
801
-
802
- // ─── State Frontmatter Sync ──────────────────────────────────────────────────
803
-
804
- /**
805
- * Extract machine-readable fields from STATE.md markdown body and build
806
- * a YAML frontmatter object. Allows hooks and scripts to read state
807
- * reliably via `state json` instead of fragile regex parsing.
808
- */
809
- function buildStateFrontmatter(bodyContent, cwd) {
810
- const currentPhase = stateExtractField(bodyContent, 'Current Phase');
811
- const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name');
812
- const currentPlan = stateExtractField(bodyContent, 'Current Plan');
813
- const totalPhasesRaw = stateExtractField(bodyContent, 'Total Phases');
814
- const totalPlansRaw = stateExtractField(bodyContent, 'Total Plans in Phase');
815
- const status = stateExtractField(bodyContent, 'Status');
816
- const progressRaw = stateExtractField(bodyContent, 'Progress');
817
- const lastActivity = stateExtractField(bodyContent, 'Last Activity');
818
- // Bug #2444: scope Stopped At extraction to the ## Session section so that
819
- // historical "Stopped at:" prose elsewhere in the body (e.g. in a
820
- // Session Continuity Archive section) never overwrites the current value.
821
- // Fall back to full-body search only when no ## Session section exists.
822
- const sessionSectionMatch = bodyContent.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
823
- const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent;
824
- const stoppedAt = stateExtractField(sessionBodyScope, 'Stopped At') || stateExtractField(sessionBodyScope, 'Stopped at');
825
- const pausedAt = stateExtractField(bodyContent, 'Paused At');
826
-
827
- let milestone = null;
828
- let milestoneName = null;
829
- if (cwd) {
830
- try {
831
- const info = getMilestoneInfo(cwd);
832
- milestone = info.version;
833
- milestoneName = info.name;
834
- } catch { /* intentionally empty */ }
835
- }
836
-
837
- let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
838
- let completedPhases = null;
839
- let totalPlans = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
840
- let completedPlans = null;
841
-
842
- if (cwd) {
843
- try {
844
- const phasesDir = planningPaths(cwd).phases;
845
- if (fs.existsSync(phasesDir)) {
846
- // Use cached disk scan when available — avoids N+1 readdirSync calls
847
- // on repeated buildStateFrontmatter invocations within the same process (#1967)
848
- let cached = _diskScanCache.get(cwd);
849
- if (!cached) {
850
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
851
- const allMatchingDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
852
- .filter(e => e.isDirectory()).map(e => e.name)
853
- .filter(isDirInMilestone);
854
-
855
- // Bug #2445: when stale phase dirs from a prior milestone remain in
856
- // .planning/phases/ alongside new dirs with the same phase number,
857
- // de-duplicate by normalized phase number keeping the most recently
858
- // modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
859
- const seenPhaseNums = new Map(); // normalizedNum -> dirName
860
- for (const dir of allMatchingDirs) {
861
- const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
862
- const key = m ? m[1].toLowerCase() : dir;
863
- if (!seenPhaseNums.has(key)) {
864
- seenPhaseNums.set(key, dir);
865
- } else {
866
- // Keep the dir that is newer on disk (more likely current milestone)
867
- try {
868
- const existing = path.join(phasesDir, seenPhaseNums.get(key));
869
- const candidate = path.join(phasesDir, dir);
870
- if (fs.statSync(candidate).mtimeMs > fs.statSync(existing).mtimeMs) {
871
- seenPhaseNums.set(key, dir);
872
- }
873
- } catch { /* keep existing on stat error */ }
874
- }
875
- }
876
- const phaseDirs = [...seenPhaseNums.values()];
877
-
878
- let diskTotalPlans = 0;
879
- let diskTotalSummaries = 0;
880
- let diskCompletedPhases = 0;
881
-
882
- for (const dir of phaseDirs) {
883
- const phaseDir = path.join(phasesDir, dir);
884
- const { planCount, summaryCount, completed } = scanPhasePlans(phaseDir);
885
- diskTotalPlans += planCount;
886
- diskTotalSummaries += summaryCount;
887
- if (completed) diskCompletedPhases++;
888
- }
889
- // Count phase headings from ROADMAP using a digit-containing pattern
890
- // that matches both numeric phases (01, 05.1) and project-code phases
891
- // (PROJ-42, CK-05) but excludes pure-word section headers like
892
- // `## Phase Overview:` or `## Phase Details:` — single source of
893
- // truth for total_phases (#549).
894
- let roadmapPhaseCount = 0;
895
- try {
896
- const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
897
- const roadmapRaw = platformReadSync(roadmapPath);
898
- if (roadmapRaw !== null) {
899
- const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
900
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
901
- let m;
902
- while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
903
- // Only count tokens that contain at least one digit — excludes
904
- // pure-word section headings (Overview, Details) while keeping
905
- // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
906
- if (/\d/.test(m[1])) roadmapPhaseCount++;
907
- }
908
- }
909
- } catch { /* fall through: phaseDirs.length used as sole count */ }
910
-
911
- cached = {
912
- totalPhases: roadmapPhaseCount > 0
913
- ? Math.max(phaseDirs.length, roadmapPhaseCount)
914
- : phaseDirs.length,
915
- completedPhases: diskCompletedPhases,
916
- totalPlans: diskTotalPlans,
917
- completedPlans: diskTotalSummaries,
918
- };
919
- _diskScanCache.set(cwd, cached);
920
- }
921
- totalPhases = cached.totalPhases;
922
- completedPhases = cached.completedPhases;
923
- totalPlans = cached.totalPlans;
924
- completedPlans = cached.completedPlans;
925
- }
926
- } catch { /* intentionally empty */ }
927
- }
928
-
929
- // Derive percent from disk counts when available (ground truth).
930
- // Uses min(plan_fraction, phase_fraction) via computeProgressPercent so that
931
- // ROADMAP-declared-but-unrealized future phases cap the reported completion
932
- // instead of a false 100% from plan-only coverage (#3242 Bug B).
933
- // Falls back to the body Progress: field only when no plan files exist on disk.
934
- let progressPercent = computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases);
935
- if (progressPercent === null && progressRaw) {
936
- const pctMatch = progressRaw.match(/(\d+)%/);
937
- if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
938
- }
939
-
940
- const normalizedStatus = normalizeStateStatus(status, pausedAt);
941
-
942
- const fm = { gsd_state_version: '1.0' };
943
-
944
- if (milestone) fm.milestone = milestone;
945
- if (milestoneName) fm.milestone_name = milestoneName;
946
- if (currentPhase) fm.current_phase = currentPhase;
947
- if (currentPhaseName) fm.current_phase_name = currentPhaseName;
948
- if (currentPlan) fm.current_plan = currentPlan;
949
- fm.status = normalizedStatus;
950
- if (stoppedAt) fm.stopped_at = stoppedAt;
951
- if (pausedAt) fm.paused_at = pausedAt;
952
- fm.last_updated = realClock.nowIso();
953
- if (lastActivity) fm.last_activity = lastActivity;
954
-
955
- const progress = {};
956
- if (totalPhases !== null) progress.total_phases = totalPhases;
957
- if (completedPhases !== null) progress.completed_phases = completedPhases;
958
- if (totalPlans !== null) progress.total_plans = totalPlans;
959
- if (completedPlans !== null) progress.completed_plans = completedPlans;
960
- if (progressPercent !== null) progress.percent = progressPercent;
961
- if (Object.keys(progress).length > 0) fm.progress = progress;
962
-
963
- return fm;
964
- }
965
-
966
- function stripFrontmatter(content) {
967
- // Strip ALL frontmatter blocks at the start of the file.
968
- // Handles CRLF line endings and multiple stacked blocks (corruption recovery).
969
- // Greedy: keeps stripping ---...--- blocks separated by optional whitespace.
970
- let result = content;
971
-
972
- while (true) {
973
- const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
974
- if (stripped === result) break;
975
- result = stripped;
976
- }
977
- return result;
978
- }
979
-
980
- function syncStateFrontmatter(content, cwd) {
981
- // Read existing frontmatter BEFORE stripping — it may contain values
982
- // that the body no longer has (e.g., Status field removed by an agent).
983
- const existingFm = extractFrontmatter(content);
984
- const body = stripFrontmatter(content);
985
- const derivedFm = buildStateFrontmatter(body, cwd);
986
-
987
- // Preserve existing frontmatter status when body-derived status is 'unknown'.
988
- // This prevents a missing Status: field in the body from overwriting a
989
- // previously valid status (e.g., 'executing' → 'unknown').
990
- if (derivedFm.status === 'unknown' && existingFm.status && existingFm.status !== 'unknown') {
991
- derivedFm.status = existingFm.status;
992
- }
993
-
994
- const yamlStr = reconstructFrontmatter(derivedFm);
995
- return `---\n${yamlStr}\n---\n\n${body}`;
996
- }
997
-
998
- // Transient errno codes that indicate a temporary filesystem condition under
999
- // concurrent O_EXCL races — Docker overlay-fs (ENOENT/EINVAL/EIO), NFS
1000
- // (ESTALE), and OS-level interrupt/retry signals (EAGAIN/EINTR). These are
1001
- // recoverable; acquireStateLock retries instead of propagating them.
1002
- // Truly fatal codes (EMFILE, ENOSPC, EROFS, EACCES) are NOT in this set and
1003
- // will still throw immediately.
1004
- const ACQUIRE_LOCK_RETRY_ERRNOS = new Set([
1005
- 'EPERM', // Windows / macOS AV scanner holds the file open during delete
1006
- 'EBUSY', // Windows: file in use by another process
1007
- 'EAGAIN', // POSIX: resource temporarily unavailable
1008
- 'EINTR', // POSIX: syscall interrupted by signal
1009
- 'EINVAL', // Docker overlay-fs: transient during concurrent O_EXCL creation
1010
- 'EIO', // Docker overlay-fs / NFS: transient I/O error
1011
- 'ENOENT', // Docker overlay-fs: parent dir transiently missing during race
1012
- 'ESTALE', // NFS: stale file handle (self-resolves on retry)
1013
- ]);
1014
-
1015
- /**
1016
- * Acquire a lockfile for STATE.md operations.
1017
- * Returns the lock path for later release.
1018
- *
1019
- * @param {string} statePath
1020
- * @param {{ now(): number, sleep(ms: number): void }} [clock]
1021
- * Optional clock seam for testing. Defaults to realClock (Date.now + Atomics.wait).
1022
- * Pass a fake clock from tests/helpers/clock.cjs to drive timeout/stale logic
1023
- * without real wall-clock waits.
1024
- */
1025
- function acquireStateLock(statePath, clock) {
1026
- if (clock === undefined) clock = realClock;
1027
- const lockPath = statePath + '.lock';
1028
- const retryDelay = 200; // ms
1029
- const staleThresholdMs = 10000;
1030
- const maxWaitMs = 30000;
1031
- const startedAt = clock.now();
1032
-
1033
-
1034
- while (true) {
1035
- try {
1036
- const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
1037
- fs.writeSync(fd, String(process.pid));
1038
- fs.closeSync(fd);
1039
- // Exit-time cleanup keeps a crashed locked region from leaving a stale file (#1916).
1040
- _heldStateLocks.add(lockPath);
1041
- return lockPath;
1042
- } catch (err) {
1043
- // Transient filesystem errors (Docker overlay-fs, NFS, OS signals, AV scanners)
1044
- // are recoverable — retry the acquisition loop rather than propagating.
1045
- // See ACQUIRE_LOCK_RETRY_ERRNOS for the full list and rationale.
1046
- if (ACQUIRE_LOCK_RETRY_ERRNOS.has(err.code)) { continue; }
1047
- if (err.code !== 'EEXIST') throw err; // propagate — silent bypass causes lost updates
1048
- // Only unlink a lock we did not place when it has crossed the staleness
1049
- // threshold (crashed holder). Nuking a fresh lock held by a slow-but-live
1050
- // writer causes lost updates (#3711 regression).
1051
- try {
1052
- const stat = fs.statSync(lockPath);
1053
- if (clock.now() - stat.mtimeMs > staleThresholdMs) {
1054
- try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
1055
- continue;
1056
- }
1057
- } catch { continue; /* released between EEXIST and stat */ }
1058
- if (clock.now() - startedAt >= maxWaitMs) {
1059
- throw new Error(
1060
- 'acquireStateLock: ' + lockPath + ' held by live process for ' +
1061
- (clock.now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)'
1062
- );
1063
- }
1064
- const jitter = Math.floor(Math.random() * 50);
1065
- clock.sleep(retryDelay + jitter);
1066
- }
1067
- }
1068
- }
1069
-
1070
- function releaseStateLock(lockPath) {
1071
- _heldStateLocks.delete(lockPath);
1072
- try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
1073
- }
1074
-
1075
- function withStateLock(statePath, fn) {
1076
- const lockPath = acquireStateLock(statePath);
1077
- try {
1078
- return fn();
1079
- } finally {
1080
- releaseStateLock(lockPath);
1081
- }
1082
- }
1083
-
1084
- /**
1085
- * Write STATE.md with synchronized YAML frontmatter.
1086
- * All STATE.md writes should use this instead of raw writeFileSync.
1087
- * Uses a simple lockfile to prevent parallel agents from overwriting
1088
- * each other's changes (race condition with read-modify-write cycle).
1089
- *
1090
- * @param {string} statePath
1091
- * @param {string} content
1092
- * @param {string} [cwd]
1093
- * @param {{ now(): number, sleep(ms: number): void }} [clock]
1094
- * Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
1095
- */
1096
- function writeStateMd(statePath, content, cwd, clock) {
1097
- // Invalidate disk scan cache before computing new frontmatter — the write
1098
- // may create new PLAN/SUMMARY files that buildStateFrontmatter must see.
1099
- // Safe for any calling pattern, not just short-lived CLI processes (#1967).
1100
- if (cwd) _diskScanCache.delete(cwd);
1101
- const synced = syncStateFrontmatter(content, cwd);
1102
- const lockPath = acquireStateLock(statePath, clock);
1103
- try {
1104
- platformWriteSync(statePath, synced);
1105
- } finally {
1106
- releaseStateLock(lockPath);
1107
- }
1108
- }
1109
-
1110
- /**
1111
- * Atomic read-modify-write for STATE.md.
1112
- * Holds the lock across the entire read -> transform -> write cycle,
1113
- * preventing the lost-update problem where two agents read the same
1114
- * content and the second write clobbers the first.
1115
- *
1116
- * @param {string} statePath
1117
- * @param {function} transformFn - (content: string) => string
1118
- * @param {string} cwd
1119
- * @param {{ resync?: boolean }} [options]
1120
- * resync: when true (default) rebuilds the entire frontmatter from disk after
1121
- * the transform. Pass { resync: false } for body-only updates (e.g. state.update
1122
- * on a single field) that must not trample manually-curated cross-milestone
1123
- * progress.* counters in the frontmatter (#3242 Bug A).
1124
- * When resync is false, syncStateFrontmatter still runs to maintain/create the
1125
- * frontmatter block, but any existing progress.* sub-keys are preserved from
1126
- * the pre-transform file rather than being rebuilt from disk.
1127
- * @param {{ now(): number, sleep(ms: number): void }} [clock]
1128
- * Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
1129
- */
1130
- function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1131
- const resync = !options || options.resync !== false;
1132
- const lockPath = acquireStateLock(statePath, clock);
1133
- try {
1134
- const content = platformReadSync(statePath) || '';
1135
- // Snapshot the existing progress block BEFORE the transform so we can
1136
- // restore it when resync is false.
1137
- const preFm = resync ? null : extractFrontmatter(content);
1138
- const modified = transformFn(content);
1139
- let synced = syncStateFrontmatter(modified, cwd);
1140
-
1141
- if (!resync && preFm && preFm.progress) {
1142
- // Re-apply the curated progress block that syncStateFrontmatter just
1143
- // overwrote with disk-derived values. Only restore keys that were present
1144
- // in the snapshot — this preserves any new non-progress frontmatter fields
1145
- // (e.g., status, current_phase) that syncStateFrontmatter legitimately
1146
- // derived from the updated body.
1147
- const postFm = extractFrontmatter(synced);
1148
- postFm.progress = preFm.progress;
1149
- const yamlStr = reconstructFrontmatter(postFm);
1150
- const body = stripFrontmatter(synced);
1151
- synced = `---\n${yamlStr}\n---\n\n${body}`;
1152
- }
1153
-
1154
- platformWriteSync(statePath, synced);
1155
- } finally {
1156
- releaseStateLock(lockPath);
1157
- }
1158
- }
1159
-
1160
- function cmdStateJson(cwd, raw) {
1161
- const statePath = planningPaths(cwd).state;
1162
- if (!fs.existsSync(statePath)) {
1163
- output({ error: 'STATE.md not found' }, raw, 'STATE.md not found');
1164
- return;
1165
- }
1166
-
1167
- const content = fs.readFileSync(statePath, 'utf-8');
1168
- const existingFm = extractFrontmatter(content);
1169
- const body = stripFrontmatter(content);
1170
-
1171
- // Always rebuild from body + disk so progress counters reflect current state.
1172
- // Returning cached frontmatter directly causes stale percent/completed_plans
1173
- // when SUMMARY files were added after the last STATE.md write (#1589).
1174
- const built = buildStateFrontmatter(body, cwd);
1175
-
1176
- // Preserve frontmatter-only fields that cannot be recovered from the body.
1177
- if (existingFm && existingFm.stopped_at && !built.stopped_at) {
1178
- built.stopped_at = existingFm.stopped_at;
1179
- }
1180
- if (existingFm && existingFm.paused_at && !built.paused_at) {
1181
- built.paused_at = existingFm.paused_at;
1182
- }
1183
- // Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
1184
- if (built.status === 'unknown' && existingFm && existingFm.status && existingFm.status !== 'unknown') {
1185
- built.status = existingFm.status;
1186
- }
1187
- // Preserve curated cross-milestone aggregates when local disk scanning sees
1188
- // only a narrower realized subset (#3242 Bug A). Stale lower counters still
1189
- // rebuild from disk because they do not exceed the derived scan.
1190
- if (existingFm && shouldPreserveExistingProgress(existingFm.progress, built.progress)) {
1191
- built.progress = normalizeProgressNumbers(existingFm.progress);
1192
- }
1193
-
1194
- output(built, raw, JSON.stringify(built, null, 2));
1195
- }
1196
-
1197
- /**
1198
- * Update STATE.md when a new phase begins execution.
1199
- * Updates body text fields (Current focus, Status, Last Activity, Current Position)
1200
- * and synchronizes frontmatter via writeStateMd.
1201
- * Fixes: #1102 (plan counts), #1103 (status/last_activity), #1104 (body text).
1202
- */
1203
- function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1204
- const statePath = planningPaths(cwd).state;
1205
- if (!fs.existsSync(statePath)) {
1206
- output({ error: 'STATE.md not found' }, raw);
1207
- return;
1208
- }
1209
-
1210
- const today = realClock.today();
1211
- const updated = [];
1212
-
1213
- readModifyWriteStateMd(statePath, (content) => {
1214
- // Idempotency guard (#3127): if the phase is already mid-flight, do NOT
1215
- // overwrite execution-progress fields (Current Plan, plan body line,
1216
- // Last Activity Description). Only update fields that are safe to
1217
- // refresh on resume (Last Activity date, Status if inconsistent).
1218
- // A phase is considered mid-flight when Status contains 'Executing Phase N'
1219
- // for the current phase number.
1220
- const currentStatus = stateExtractField(content, 'Status') || '';
1221
- const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`, 'i').test(currentStatus);
1222
-
1223
- // Update Status field
1224
- const statusValue = `Executing Phase ${phaseNumber}`;
1225
- let result = stateReplaceField(content, 'Status', statusValue);
1226
- if (result) { content = result; updated.push('Status'); }
1227
-
1228
- // Update Last Activity (safe to update on resume — tracks when execute-phase ran)
1229
- result = stateReplaceField(content, 'Last Activity', today);
1230
- if (result) { content = result; updated.push('Last Activity'); }
1231
-
1232
- if (!isAlreadyExecuting) {
1233
- // First-time execution: set all progress fields
1234
-
1235
- // Update Last Activity Description
1236
- const activityDesc = `Phase ${phaseNumber} execution started`;
1237
- result = stateReplaceField(content, 'Last Activity Description', activityDesc);
1238
- if (result) { content = result; updated.push('Last Activity Description'); }
1239
-
1240
- // Update Current Phase
1241
- result = stateReplaceField(content, 'Current Phase', String(phaseNumber));
1242
- if (result) { content = result; updated.push('Current Phase'); }
1243
-
1244
- // Update Current Phase Name
1245
- if (phaseName) {
1246
- result = stateReplaceField(content, 'Current Phase Name', phaseName);
1247
- if (result) { content = result; updated.push('Current Phase Name'); }
1248
- }
1249
-
1250
- // Update Current Plan to 1 (starting from the first plan)
1251
- result = stateReplaceField(content, 'Current Plan', '1');
1252
- if (result) { content = result; updated.push('Current Plan'); }
1253
-
1254
- // Update Total Plans in Phase
1255
- if (planCount) {
1256
- result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
1257
- if (result) { content = result; updated.push('Total Plans in Phase'); }
1258
- }
1259
-
1260
- // Update **Current focus:** body text line (#1104)
1261
- const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
1262
- const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
1263
- if (focusPattern.test(content)) {
1264
- content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
1265
- updated.push('Current focus');
1266
- }
1267
-
1268
- // Update ## Current Position section (#1104, #1365)
1269
- const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1270
- const positionMatch = content.match(positionPattern);
1271
- if (positionMatch) {
1272
- const header = positionMatch[1];
1273
- let posBody = positionMatch[2];
1274
-
1275
- // Update or insert Phase line
1276
- const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
1277
- if (/^Phase:/m.test(posBody)) {
1278
- posBody = posBody.replace(/^Phase:.*$/m, newPhase);
1279
- } else {
1280
- posBody = newPhase + '\n' + posBody;
1281
- }
1282
-
1283
- // Update or insert Plan line
1284
- const newPlan = `Plan: 1 of ${planCount || '?'}`;
1285
- if (/^Plan:/m.test(posBody)) {
1286
- posBody = posBody.replace(/^Plan:.*$/m, newPlan);
1287
- } else {
1288
- posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
1289
- }
1290
-
1291
- // Update Status line if present
1292
- const newStatus = `Status: Executing Phase ${phaseNumber}`;
1293
- if (/^Status:/m.test(posBody)) {
1294
- posBody = posBody.replace(/^Status:.*$/m, newStatus);
1295
- }
1296
-
1297
- // Update Last activity line if present
1298
- const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
1299
- if (/^Last activity:/im.test(posBody)) {
1300
- posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
1301
- }
1302
-
1303
- content = content.replace(positionPattern, () => `${header}${posBody}`);
1304
- updated.push('Current Position');
1305
- }
1306
- } else {
1307
- // Resume path: only update Last activity timestamp in Current Position
1308
- // (do not touch Plan:, stopped_at, progress.percent, or plan counter)
1309
- const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1310
- const positionMatch = content.match(positionPattern);
1311
- if (positionMatch) {
1312
- const header = positionMatch[1];
1313
- let posBody = positionMatch[2];
1314
- const resumeActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution resumed (wave continue)`;
1315
- if (/^Last activity:/im.test(posBody)) {
1316
- posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
1317
- content = content.replace(positionPattern, () => `${header}${posBody}`);
1318
- updated.push('Last activity (resume)');
1319
- }
1320
- }
1321
- }
1322
-
1323
- return content;
1324
- }, cwd);
1325
-
1326
- output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
1327
- }
1328
-
1329
- /**
1330
- * Write a WAITING.json signal file when GSD hits a decision point.
1331
- * External watchers (fswatch, polling, orchestrators) can detect this.
1332
- * File is written to .planning/WAITING.json (or .gsd/WAITING.json if .gsd exists).
1333
- * Fixes #1034.
1334
- */
1335
- function cmdSignalWaiting(cwd, type, question, options, phase, raw) {
1336
- const gsdDir = fs.existsSync(path.join(cwd, '.gsd')) ? path.join(cwd, '.gsd') : planningDir(cwd);
1337
- const waitingPath = path.join(gsdDir, 'WAITING.json');
1338
-
1339
- const signal = {
1340
- status: 'waiting',
1341
- type: type || 'decision_point',
1342
- question: question || null,
1343
- options: options ? options.split('|').map(o => o.trim()) : [],
1344
- since: realClock.nowIso(),
1345
- phase: phase || null,
1346
- };
1347
-
1348
- try {
1349
- platformEnsureDir(gsdDir);
1350
- platformWriteSync(waitingPath, JSON.stringify(signal, null, 2));
1351
- output({ signaled: true, path: waitingPath }, raw, 'true');
1352
- } catch (e) {
1353
- output({ signaled: false, error: e.message }, raw, 'false');
1354
- }
1355
- }
1356
-
1357
- /**
1358
- * Remove the WAITING.json signal file when user answers and agent resumes.
1359
- */
1360
- function cmdSignalResume(cwd, raw) {
1361
- const paths = [
1362
- path.join(cwd, '.gsd', 'WAITING.json'),
1363
- path.join(planningDir(cwd), 'WAITING.json'),
1364
- ];
1365
-
1366
- let removed = false;
1367
- for (const p of paths) {
1368
- if (fs.existsSync(p)) {
1369
- try { fs.unlinkSync(p); removed = true; } catch {}
1370
- }
1371
- }
1372
-
1373
- output({ resumed: true, removed }, raw, removed ? 'true' : 'false');
1374
- }
1375
-
1376
- // ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
1377
-
1378
- /**
1379
- * Update the ## Performance Metrics section in STATE.md content.
1380
- * Increments Velocity totals and upserts a By Phase table row.
1381
- * Returns modified content string.
1382
- */
1383
- function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) {
1384
- // Update Velocity: Total plans completed
1385
- const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/);
1386
- const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0;
1387
- const newTotal = prevTotal + summaryCount;
1388
- content = content.replace(
1389
- /Total plans completed:\s*(\d+|\[N\])/,
1390
- `Total plans completed: ${newTotal}`
1391
- );
1392
-
1393
- // Update By Phase table — upsert row for this phase
1394
- const byPhaseMatch = content.match(byPhaseTablePattern);
1395
- if (byPhaseMatch) {
1396
- let tableBody = byPhaseMatch[2].trim();
1397
- const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm');
1398
- const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
1399
-
1400
- if (phaseRowPattern.test(tableBody)) {
1401
- // Update existing row
1402
- tableBody = tableBody.replace(phaseRowPattern, newRow);
1403
- } else {
1404
- // Remove placeholder row and add new row
1405
- tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
1406
- tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
1407
- }
1408
-
1409
- content = content.replace(byPhaseTablePattern, (_match, tableHeader) => `${tableHeader}${tableBody}\n`);
1410
- }
1411
-
1412
- return content;
1413
- }
1414
-
1415
- /**
1416
- * Gate 3a: Record state after plan-phase completes.
1417
- * Updates Status to "Ready to execute", Total Plans, Last Activity.
1418
- */
1419
- function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
1420
- const statePath = planningPaths(cwd).state;
1421
- if (!fs.existsSync(statePath)) {
1422
- output({ error: 'STATE.md not found' }, raw);
1423
- return;
1424
- }
1425
-
1426
- const today = realClock.today();
1427
- const updated = [];
1428
-
1429
- const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
1430
- const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
1431
-
1432
- // plan-phase updates per-phase body fields only. It must NOT resync the
1433
- // milestone-wide progress.* frontmatter from a half-planned disk snapshot —
1434
- // doing so tramples curated/known-good counters. Route through the body-only
1435
- // write contract (resync:false), the same guard state.update uses. (#500 RC1)
1436
- readModifyWriteStateMd(statePath, (content) => {
1437
- // Update Status — only when the existing value is a known template default
1438
- // (Knuth invariant: preserve executor-authored values).
1439
- const newContent = stateReplaceFieldIfTemplate(content, 'Status', statusDefaults, 'Ready to execute');
1440
- if (newContent !== content) { content = newContent; updated.push('Status'); }
1441
-
1442
- // Update Total Plans in Phase
1443
- if (planCount !== null && planCount !== undefined) {
1444
- const result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
1445
- if (result) { content = result; updated.push('Total Plans in Phase'); }
1446
- }
1447
-
1448
- // Update Last Activity — only when the existing value is a known template default
1449
- {
1450
- const after = stateReplaceFieldIfTemplate(content, 'Last Activity', lastActivityDefaults, today);
1451
- if (after !== content) { content = after; updated.push('Last Activity'); }
1452
- }
1453
-
1454
- // Update Last Activity Description
1455
- {
1456
- const result = stateReplaceField(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
1457
- if (result) { content = result; updated.push('Last Activity Description'); }
1458
- }
1459
-
1460
- // Update Current Position section
1461
- content = updateCurrentPositionFields(content, {
1462
- status: 'Ready to execute',
1463
- lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
1464
- });
1465
-
1466
- return content;
1467
- }, cwd, { resync: false });
1468
-
1469
- output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
1470
- }
1471
-
1472
- /**
1473
- * Bug #2630: reset STATE.md for a new milestone cycle.
1474
- * Stomps frontmatter milestone/milestone_name/status/progress AND rewrites
1475
- * the Current Position body. Preserves Accumulated Context.
1476
- * Symmetric with the SDK `stateMilestoneSwitch` handler.
1477
- */
1478
- function cmdStateMilestoneSwitch(cwd, version, name, raw) {
1479
- if (!version || !String(version).trim()) {
1480
- output({ error: 'milestone required (--milestone <vX.Y>)' }, raw);
1481
- return;
1482
- }
1483
- const resolvedName = (name && String(name).trim()) || 'milestone';
1484
- const statePath = planningPaths(cwd).state;
1485
- const today = realClock.today();
1486
-
1487
- const lockPath = acquireStateLock(statePath);
1488
- try {
1489
- const content = platformReadSync(statePath) || '';
1490
- const existingFm = extractFrontmatter(content);
1491
- const body = stripFrontmatter(content);
1492
-
1493
- const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1494
- const resetPositionBody =
1495
- `\nPhase: Not started (defining requirements)\n` +
1496
- `Plan: —\n` +
1497
- `Status: Defining requirements\n` +
1498
- `Last activity: ${today} — Milestone ${version} started\n\n`;
1499
- let newBody;
1500
- if (positionPattern.test(body)) {
1501
- newBody = body.replace(positionPattern, (_m, header) => `${header}${resetPositionBody}`);
1502
- } else {
1503
- const preface = body.trim().length > 0 ? body : '# Project State\n';
1504
- newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
1505
- }
1506
-
1507
- const fm = {
1508
- gsd_state_version: existingFm.gsd_state_version || '1.0',
1509
- milestone: version,
1510
- milestone_name: resolvedName,
1511
- status: 'planning',
1512
- last_updated: realClock.nowIso(),
1513
- last_activity: today,
1514
- progress: {
1515
- total_phases: 0,
1516
- completed_phases: 0,
1517
- total_plans: 0,
1518
- completed_plans: 0,
1519
- percent: 0,
1520
- },
1521
- };
1522
-
1523
- const yamlStr = reconstructFrontmatter(fm);
1524
- const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
1525
- platformWriteSync(statePath, assembled);
1526
- output(
1527
- { switched: true, version, name: resolvedName, status: 'planning' },
1528
- raw,
1529
- 'true',
1530
- );
1531
- } finally {
1532
- releaseStateLock(lockPath);
1533
- }
1534
- }
1535
-
1536
- /**
1537
- * Gate 1: Validate STATE.md against filesystem.
1538
- * Returns { valid, warnings, drift } JSON.
1539
- */
1540
- function cmdStateValidate(cwd, raw) {
1541
- const statePath = planningPaths(cwd).state;
1542
- if (!fs.existsSync(statePath)) {
1543
- output({ error: 'STATE.md not found' }, raw);
1544
- return;
1545
- }
1546
-
1547
- const content = fs.readFileSync(statePath, 'utf-8');
1548
- const warnings = [];
1549
- const drift = {};
1550
-
1551
- const status = stateExtractField(content, 'Status') || '';
1552
- const currentPhase = stateExtractField(content, 'Current Phase');
1553
- const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase');
1554
- const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1555
-
1556
- const phasesDir = planningPaths(cwd).phases;
1557
-
1558
- // Scan disk for current phase
1559
- if (currentPhase && fs.existsSync(phasesDir)) {
1560
- const normalized = currentPhase.replace(/\s+of\s+\d+.*/, '').trim();
1561
- try {
1562
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1563
- const phaseDir = entries.find(e => e.isDirectory() && e.name.startsWith(normalized.replace(/^0+/, '').padStart(2, '0')));
1564
- if (phaseDir) {
1565
- const phaseDirPath = path.join(phasesDir, phaseDir.name);
1566
- const { planCount: diskPlans, summaryCount: diskSummaries } = scanPhasePlans(phaseDirPath);
1567
-
1568
- // Check plan count mismatch
1569
- if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
1570
- warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`);
1571
- drift.plan_count = { state: totalPlansInPhase, disk: diskPlans };
1572
- }
1573
-
1574
- // Check for VERIFICATION.md
1575
- const files = fs.readdirSync(phaseDirPath);
1576
- const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
1577
- for (const vf of verificationFiles) {
1578
- try {
1579
- const vContent = fs.readFileSync(path.join(phaseDirPath, vf), 'utf-8');
1580
- if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
1581
- warnings.push(`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`);
1582
- drift.verification_status = { state_status: status, verification: 'passed' };
1583
- }
1584
- } catch { /* intentionally empty */ }
1585
- }
1586
-
1587
- // Check if all plans have summaries but status still says executing
1588
- if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
1589
- // Only warn if no verification exists (if verification passed, the above warning covers it)
1590
- if (verificationFiles.length === 0) {
1591
- warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`);
1592
- }
1593
- }
1594
- }
1595
- } catch { /* intentionally empty */ }
1596
- }
1597
-
1598
- const valid = warnings.length === 0;
1599
- output({ valid, warnings, drift }, raw);
1600
- }
1601
-
1602
- /**
1603
- * Gate 2: Sync STATE.md from filesystem ground truth.
1604
- * Scans phase dirs, reconstructs counters, progress, metrics.
1605
- * Supports --verify for dry-run mode.
1606
- */
1607
- function cmdStateSync(cwd, options, raw) {
1608
- const statePath = planningPaths(cwd).state;
1609
- if (!fs.existsSync(statePath)) {
1610
- output({ error: 'STATE.md not found' }, raw);
1611
- return;
1612
- }
1613
-
1614
- const verify = options && options.verify;
1615
- const content = fs.readFileSync(statePath, 'utf-8');
1616
- const changes = [];
1617
- let modified = content;
1618
- const today = realClock.today();
1619
-
1620
- const phasesDir = planningPaths(cwd).phases;
1621
- if (!fs.existsSync(phasesDir)) {
1622
- output({ synced: true, changes: [], dry_run: !!verify }, raw);
1623
- return;
1624
- }
1625
-
1626
- // Scan all phases
1627
- let entries;
1628
- try {
1629
- entries = fs.readdirSync(phasesDir, { withFileTypes: true })
1630
- .filter(e => e.isDirectory())
1631
- .map(e => e.name)
1632
- .sort();
1633
- } catch {
1634
- output({ synced: true, changes: [], dry_run: !!verify }, raw);
1635
- return;
1636
- }
1637
-
1638
- let totalDiskPlans = 0;
1639
- let totalDiskSummaries = 0;
1640
- let diskCompletedPhases = 0;
1641
- let highestIncompletePhase = null;
1642
- let highestIncompletePhaseNum = null;
1643
- let highestIncompletePhaseplanCount = 0;
1644
- let highestIncompletePhaseSummaryCount = 0;
1645
-
1646
- for (const dir of entries) {
1647
- const dirPath = path.join(phasesDir, dir);
1648
- const { planCount: plans, summaryCount: summaries, completed } = scanPhasePlans(dirPath);
1649
- totalDiskPlans += plans;
1650
- totalDiskSummaries += summaries;
1651
- if (completed) diskCompletedPhases++;
1652
-
1653
- // Track the highest phase with incomplete plans (or any plans)
1654
- const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
1655
- if (phaseMatch && plans > 0) {
1656
- if (summaries < plans) {
1657
- // Incomplete phase — this is likely the current one
1658
- highestIncompletePhase = dir;
1659
- highestIncompletePhaseNum = phaseMatch[1];
1660
- highestIncompletePhaseplanCount = plans;
1661
- highestIncompletePhaseSummaryCount = summaries;
1662
- } else if (!highestIncompletePhase) {
1663
- // All complete, track as potential current
1664
- highestIncompletePhase = dir;
1665
- highestIncompletePhaseNum = phaseMatch[1];
1666
- highestIncompletePhaseplanCount = plans;
1667
- highestIncompletePhaseSummaryCount = summaries;
1668
- }
1669
- }
1670
- }
1671
-
1672
- // Determine total phases from ROADMAP (may be larger than realized disk dirs).
1673
- // Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B).
1674
- let syncTotalPhases = null;
1675
- try {
1676
- let roadmapPhaseCount = 0;
1677
- const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
1678
- const roadmapRaw = platformReadSync(roadmapPath);
1679
- if (roadmapRaw !== null) {
1680
- const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1681
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1682
- let m;
1683
- while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
1684
- // Only count tokens that contain at least one digit — excludes
1685
- // pure-word section headings (Overview, Details) while keeping
1686
- // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
1687
- if (/\d/.test(m[1])) roadmapPhaseCount++;
1688
- }
1689
- }
1690
- if (roadmapPhaseCount > 0) {
1691
- syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
1692
- } else {
1693
- syncTotalPhases = entries.length;
1694
- }
1695
- } catch { /* intentionally empty */ }
1696
-
1697
- // Sync Total Plans in Phase
1698
- if (highestIncompletePhase) {
1699
- const currentPlansField = stateExtractField(modified, 'Total Plans in Phase');
1700
- if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
1701
- changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
1702
- const result = stateReplaceField(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
1703
- if (result) modified = result;
1704
- }
1705
- }
1706
-
1707
- // Sync Progress — use shared helper so formula stays in one place (#3242 Bug B).
1708
- // computeProgressPercent applies min(plan_fraction, phase_fraction) so unrealised
1709
- // ROADMAP phases cap the reported percent rather than allowing a false 100%.
1710
- const percent = (() => {
1711
- const p = computeProgressPercent(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
1712
- return p !== null ? p : 0;
1713
- })();
1714
- const currentProgress = stateExtractField(modified, 'Progress');
1715
- if (currentProgress) {
1716
- const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
1717
- if (currentPercent !== percent) {
1718
- const barWidth = 10;
1719
- const filled = Math.round(percent / 100 * barWidth);
1720
- const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
1721
- const progressStr = `[${bar}] ${percent}%`;
1722
- changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
1723
- const result = stateReplaceField(modified, 'Progress', progressStr);
1724
- if (result) modified = result;
1725
- }
1726
- }
1727
-
1728
- // Sync Last Activity
1729
- const result = stateReplaceField(modified, 'Last Activity', today);
1730
- if (result) {
1731
- const oldActivity = stateExtractField(modified, 'Last Activity');
1732
- if (oldActivity !== today) {
1733
- changes.push(`Last Activity: ${oldActivity} -> ${today}`);
1734
- }
1735
- modified = result;
1736
- }
1737
-
1738
- if (verify) {
1739
- output({ synced: false, changes, dry_run: true }, raw);
1740
- return;
1741
- }
1742
-
1743
- if (changes.length > 0 || modified !== content) {
1744
- writeStateMd(statePath, modified, cwd);
1745
- }
1746
-
1747
- output({ synced: true, changes, dry_run: false }, raw);
1748
- }
1749
-
1750
- /**
1751
- * Prune old entries from STATE.md sections that grow unboundedly (#1970).
1752
- * Moves decisions, recently-completed summaries, and resolved blockers
1753
- * older than keepRecent phases to STATE-ARCHIVE.md.
1754
- *
1755
- * Options:
1756
- * keepRecent: number of recent phases to retain (default: 3)
1757
- * dryRun: if true, return what would be pruned without modifying STATE.md
1758
- */
1759
- function cmdStatePrune(cwd, options, raw) {
1760
- const silent = !!options.silent;
1761
- const emit = silent ? () => {} : (result, r, v) => output(result, r, v);
1762
- const statePath = planningPaths(cwd).state;
1763
- if (!fs.existsSync(statePath)) { emit({ error: 'STATE.md not found' }, raw); return; }
1764
-
1765
- const keepRecent = parseInt(options.keepRecent, 10) || 3;
1766
- const dryRun = !!options.dryRun;
1767
- const currentPhaseRaw = stateExtractField(fs.readFileSync(statePath, 'utf-8'), 'Current Phase');
1768
- const currentPhase = parseInt(currentPhaseRaw, 10) || 0;
1769
- const cutoff = currentPhase - keepRecent;
1770
-
1771
- if (cutoff <= 0) {
1772
- emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
1773
- return;
1774
- }
1775
-
1776
- const archivePath = path.join(path.dirname(statePath), 'STATE-ARCHIVE.md');
1777
- const archived = [];
1778
-
1779
- // Shared pruning logic applied to both dry-run and real passes.
1780
- // Returns { newContent, archivedSections }.
1781
- function prunePass(content) {
1782
- const sections = [];
1783
-
1784
- // Prune Decisions section: entries like "- [Phase N]: ..."
1785
- const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1786
- const decMatch = content.match(decisionPattern);
1787
- if (decMatch) {
1788
- const lines = decMatch[2].split('\n');
1789
- const keep = [];
1790
- const archive = [];
1791
- for (const line of lines) {
1792
- const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
1793
- if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
1794
- archive.push(line);
1795
- } else {
1796
- keep.push(line);
1797
- }
1798
- }
1799
- if (archive.length > 0) {
1800
- sections.push({ section: 'Decisions', count: archive.length, lines: archive });
1801
- content = content.replace(decisionPattern, (_m, header) => `${header}${keep.join('\n')}`);
1802
- }
1803
- }
1804
-
1805
- // Prune Recently Completed section: entries mentioning phase numbers
1806
- const recentPattern = /(###?\s*Recently Completed\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1807
- const recMatch = content.match(recentPattern);
1808
- if (recMatch) {
1809
- const lines = recMatch[2].split('\n');
1810
- const keep = [];
1811
- const archive = [];
1812
- for (const line of lines) {
1813
- const phaseMatch = line.match(/Phase\s+(\d+)/i);
1814
- if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
1815
- archive.push(line);
1816
- } else {
1817
- keep.push(line);
1818
- }
1819
- }
1820
- if (archive.length > 0) {
1821
- sections.push({ section: 'Recently Completed', count: archive.length, lines: archive });
1822
- content = content.replace(recentPattern, (_m, header) => `${header}${keep.join('\n')}`);
1823
- }
1824
- }
1825
-
1826
- // Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~
1827
- // or "[RESOLVED]" prefix) with a phase reference older than cutoff
1828
- const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1829
- const blockersMatch = content.match(blockersPattern);
1830
- if (blockersMatch) {
1831
- const lines = blockersMatch[2].split('\n');
1832
- const keep = [];
1833
- const archive = [];
1834
- for (const line of lines) {
1835
- const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
1836
- const phaseMatch = line.match(/Phase\s+(\d+)/i);
1837
- if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
1838
- archive.push(line);
1839
- } else {
1840
- keep.push(line);
1841
- }
1842
- }
1843
- if (archive.length > 0) {
1844
- sections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive });
1845
- content = content.replace(blockersPattern, (_m, header) => `${header}${keep.join('\n')}`);
1846
- }
1847
- }
1848
-
1849
- // Prune Performance Metrics table rows: keep only rows for phases > cutoff.
1850
- // Preserves header rows (| Phase | ... and |---|...) and any prose around the table.
1851
- const metricsPattern = /(###?\s*Performance Metrics\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1852
- const metricsMatch = content.match(metricsPattern);
1853
- if (metricsMatch) {
1854
- const sectionLines = metricsMatch[2].split('\n');
1855
- const keep = [];
1856
- const archive = [];
1857
- for (const line of sectionLines) {
1858
- // Table data row: starts with | followed by a number (phase)
1859
- const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
1860
- if (tableRowMatch) {
1861
- const rowPhase = parseInt(tableRowMatch[1], 10);
1862
- if (rowPhase <= cutoff) {
1863
- archive.push(line);
1864
- } else {
1865
- keep.push(line);
1866
- }
1867
- } else {
1868
- // Header row, separator row, or prose — always keep
1869
- keep.push(line);
1870
- }
1871
- }
1872
- if (archive.length > 0) {
1873
- sections.push({ section: 'Performance Metrics', count: archive.length, lines: archive });
1874
- content = content.replace(metricsPattern, (_m, header) => `${header}${keep.join('\n')}`);
1875
- }
1876
- }
1877
-
1878
- return { newContent: content, archivedSections: sections };
1879
- }
1880
-
1881
- if (dryRun) {
1882
- // Dry-run: compute what would be pruned without writing anything
1883
- const content = fs.readFileSync(statePath, 'utf-8');
1884
- const result = prunePass(content);
1885
- const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
1886
- emit({
1887
- pruned: false,
1888
- dry_run: true,
1889
- cutoff_phase: cutoff,
1890
- keep_recent: keepRecent,
1891
- sections: result.archivedSections.map(s => ({ section: s.section, entries_would_archive: s.count })),
1892
- total_would_archive: totalPruned,
1893
- note: totalPruned > 0 ? 'Run without --dry-run to actually prune' : 'Nothing to prune',
1894
- }, raw, totalPruned > 0 ? 'true' : 'false');
1895
- return;
1896
- }
1897
-
1898
- readModifyWriteStateMd(statePath, (content) => {
1899
- const result = prunePass(content);
1900
- archived.push(...result.archivedSections);
1901
- return result.newContent;
1902
- }, cwd);
1903
-
1904
- // Write archived entries to STATE-ARCHIVE.md
1905
- if (archived.length > 0) {
1906
- const timestamp = realClock.today();
1907
- let archiveContent = platformReadSync(archivePath);
1908
- if (archiveContent === null) {
1909
- archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
1910
- }
1911
- archiveContent += `## Pruned ${timestamp} (phases 1-${cutoff}, kept recent ${keepRecent})\n\n`;
1912
- for (const section of archived) {
1913
- archiveContent += `### ${section.section}\n\n${section.lines.join('\n')}\n\n`;
1914
- }
1915
- platformWriteSync(archivePath, archiveContent);
1916
- }
1917
-
1918
- const totalPruned = archived.reduce((sum, s) => sum + s.count, 0);
1919
- emit({
1920
- pruned: totalPruned > 0,
1921
- cutoff_phase: cutoff,
1922
- keep_recent: keepRecent,
1923
- sections: archived.map(s => ({ section: s.section, entries_archived: s.count })),
1924
- total_archived: totalPruned,
1925
- archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
1926
- }, raw, totalPruned > 0 ? 'true' : 'false');
1927
- }
1928
-
1929
- /**
1930
- * Mark the current phase as COMPLETE in STATE.md.
1931
- * Updates Status, Last Activity, and the Current Position section to reflect
1932
- * that the phase execution is finished and the project is ready for the next phase.
1933
- * Implements the `gsd state complete-phase` subcommand (issue #2735).
1934
- */
1935
- function resolvePhaseIdForCompletePhase(content, overridePhase) {
1936
- const candidate = overridePhase ||
1937
- stateExtractField(content, 'Current Phase') ||
1938
- stateExtractField(content, 'Phase') ||
1939
- '';
1940
-
1941
- // Accept canonical phase token only (e.g. 3, 03, 3A, 3.3, 10.2)
1942
- const phaseMatch = String(candidate).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
1943
- return phaseMatch ? phaseMatch[1] : null;
1944
- }
1945
-
1946
- function cmdStateCompletePhase(cwd, raw, overridePhase) {
1947
- const statePath = planningPaths(cwd).state;
1948
- if (!fs.existsSync(statePath)) {
1949
- output({ error: 'STATE.md not found' }, raw);
1950
- return;
1951
- }
1952
-
1953
- const content = fs.readFileSync(statePath, 'utf-8');
1954
- const resolvedPhase = resolvePhaseIdForCompletePhase(content, overridePhase);
1955
- if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
1956
- output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw);
1957
- return;
1958
- }
1959
-
1960
- // Idempotency guard (#3489). If STATE.md's canonical `Current Phase` field
1961
- // already names a phase distinct from the one we are being asked to mark
1962
- // complete, the project has advanced past the requested phase (e.g. a
1963
- // follow-up phase was inserted, or the next phase began). Re-running
1964
- // `state complete-phase --phase <N>` in that situation previously rolled
1965
- // STATE.md back to <N>'s moment-of-completion — silently clobbering Status,
1966
- // Last Activity, Last Activity Description, and the Current Position body.
1967
- // The handler is now a no-op in that case so re-invocation from downstream
1968
- // workflows cannot regress the project state.
1969
- const existingCurrentPhaseRaw = stateExtractField(content, 'Current Phase') || '';
1970
- const existingCurrentPhaseMatch = String(existingCurrentPhaseRaw).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
1971
- const existingCurrentPhase = existingCurrentPhaseMatch ? existingCurrentPhaseMatch[1] : null;
1972
- if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
1973
- output(
1974
- { updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' },
1975
- raw,
1976
- 'false',
1977
- );
1978
- return;
1979
- }
1980
-
1981
- const today = realClock.today();
1982
- const updated = [];
1983
-
1984
- readModifyWriteStateMd(statePath, (content) => {
1985
- const currentPhase = resolvedPhase;
1986
-
1987
- // Update Status field
1988
- const statusValue = `Phase ${currentPhase} complete`;
1989
- let result = stateReplaceField(content, 'Status', statusValue);
1990
- if (result) { content = result; updated.push('Status'); }
1991
-
1992
- // Update Last Activity date
1993
- result = stateReplaceField(content, 'Last Activity', today);
1994
- if (result) { content = result; updated.push('Last Activity'); }
1995
-
1996
- // Update Last Activity Description
1997
- const activityDesc = `Phase ${currentPhase} marked complete`;
1998
- result = stateReplaceField(content, 'Last Activity Description', activityDesc);
1999
- if (result) { content = result; updated.push('Last Activity Description'); }
2000
-
2001
- // Update ## Current Position section
2002
- const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
2003
- const positionMatch = content.match(positionPattern);
2004
- if (positionMatch) {
2005
- const header = positionMatch[1];
2006
- let posBody = positionMatch[2];
2007
-
2008
- // Update Phase line to show COMPLETE
2009
- const newPhase = `Phase: ${currentPhase} — COMPLETE`;
2010
- if (/^Phase:/m.test(posBody)) {
2011
- posBody = posBody.replace(/^Phase:.*$/m, newPhase);
2012
- }
2013
-
2014
- // Update Status line if present
2015
- const newStatus = `Status: Phase ${currentPhase} complete`;
2016
- if (/^Status:/m.test(posBody)) {
2017
- posBody = posBody.replace(/^Status:.*$/m, newStatus);
2018
- }
2019
-
2020
- // Update Last activity line if present
2021
- const newActivity = `Last activity: ${today} -- Phase ${currentPhase} marked complete`;
2022
- if (/^Last activity:/im.test(posBody)) {
2023
- posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
2024
- }
2025
-
2026
- content = content.replace(positionPattern, () => `${header}${posBody}`);
2027
- updated.push('Current Position');
2028
- }
2029
-
2030
- return content;
2031
- }, cwd);
2032
-
2033
- output(
2034
- { updated, phase: resolvedPhase },
2035
- raw,
2036
- updated.length > 0 ? 'true' : 'false',
2037
- );
2038
- }
2039
-
2040
- module.exports = {
2041
- stateExtractField,
2042
- stateReplaceField,
2043
- stateReplaceFieldWithFallback,
2044
- acquireStateLock,
2045
- releaseStateLock,
2046
- writeStateMd,
2047
- readModifyWriteStateMd,
2048
- syncStateFrontmatter,
2049
- withStateLock,
2050
- updatePerformanceMetricsSection,
2051
- cmdStateLoad,
2052
- cmdStateGet,
2053
- cmdStatePatch,
2054
- cmdStateUpdate,
2055
- cmdStateAdvancePlan,
2056
- cmdStateRecordMetric,
2057
- cmdStateUpdateProgress,
2058
- cmdStateAddDecision,
2059
- cmdStateAddBlocker,
2060
- cmdStateResolveBlocker,
2061
- cmdStateRecordSession,
2062
- cmdStateSnapshot,
2063
- cmdStateJson,
2064
- cmdStateBeginPhase,
2065
- cmdStatePlannedPhase,
2066
- cmdStateCompletePhase,
2067
- cmdStateValidate,
2068
- cmdStateSync,
2069
- cmdStatePrune,
2070
- cmdStateMilestoneSwitch,
2071
- cmdSignalWaiting,
2072
- cmdSignalResume,
2073
- };