gsd-remix 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (554) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +939 -0
  3. package/README.zh-CN.md +876 -0
  4. package/agents/gsd-advisor-researcher.md +127 -0
  5. package/agents/gsd-ai-researcher.md +133 -0
  6. package/agents/gsd-assumptions-analyzer.md +105 -0
  7. package/agents/gsd-code-fixer.md +517 -0
  8. package/agents/gsd-code-reviewer.md +371 -0
  9. package/agents/gsd-codebase-mapper.md +781 -0
  10. package/agents/gsd-debug-session-manager.md +314 -0
  11. package/agents/gsd-debugger.md +1452 -0
  12. package/agents/gsd-doc-classifier.md +168 -0
  13. package/agents/gsd-doc-synthesizer.md +204 -0
  14. package/agents/gsd-doc-verifier.md +217 -0
  15. package/agents/gsd-doc-writer.md +615 -0
  16. package/agents/gsd-domain-researcher.md +153 -0
  17. package/agents/gsd-eval-auditor.md +191 -0
  18. package/agents/gsd-eval-planner.md +154 -0
  19. package/agents/gsd-executor.md +603 -0
  20. package/agents/gsd-framework-selector.md +160 -0
  21. package/agents/gsd-integration-checker.md +470 -0
  22. package/agents/gsd-intel-updater.md +334 -0
  23. package/agents/gsd-nyquist-auditor.md +203 -0
  24. package/agents/gsd-pattern-mapper.md +335 -0
  25. package/agents/gsd-phase-researcher.md +841 -0
  26. package/agents/gsd-plan-checker.md +978 -0
  27. package/agents/gsd-planner.md +1251 -0
  28. package/agents/gsd-project-researcher.md +677 -0
  29. package/agents/gsd-research-synthesizer.md +247 -0
  30. package/agents/gsd-roadmapper.md +688 -0
  31. package/agents/gsd-security-auditor.md +155 -0
  32. package/agents/gsd-ui-auditor.md +495 -0
  33. package/agents/gsd-ui-checker.md +309 -0
  34. package/agents/gsd-ui-researcher.md +380 -0
  35. package/agents/gsd-user-profiler.md +171 -0
  36. package/agents/gsd-verifier.md +830 -0
  37. package/bin/install.js +7062 -0
  38. package/commands/gsd/add-backlog.md +79 -0
  39. package/commands/gsd/add-phase.md +43 -0
  40. package/commands/gsd/add-tests.md +41 -0
  41. package/commands/gsd/add-todo.md +47 -0
  42. package/commands/gsd/ai-integration-phase.md +36 -0
  43. package/commands/gsd/analyze-dependencies.md +34 -0
  44. package/commands/gsd/audit-fix.md +33 -0
  45. package/commands/gsd/audit-milestone.md +36 -0
  46. package/commands/gsd/audit-uat.md +24 -0
  47. package/commands/gsd/autonomous.md +46 -0
  48. package/commands/gsd/check-todos.md +45 -0
  49. package/commands/gsd/cleanup.md +23 -0
  50. package/commands/gsd/code-review-fix.md +52 -0
  51. package/commands/gsd/code-review.md +55 -0
  52. package/commands/gsd/complete-milestone.md +136 -0
  53. package/commands/gsd/debug.md +263 -0
  54. package/commands/gsd/discuss-phase.md +69 -0
  55. package/commands/gsd/do.md +30 -0
  56. package/commands/gsd/docs-update.md +48 -0
  57. package/commands/gsd/eval-review.md +32 -0
  58. package/commands/gsd/execute-phase.md +63 -0
  59. package/commands/gsd/explore.md +27 -0
  60. package/commands/gsd/extract_learnings.md +22 -0
  61. package/commands/gsd/fast.md +30 -0
  62. package/commands/gsd/forensics.md +56 -0
  63. package/commands/gsd/from-gsd2.md +47 -0
  64. package/commands/gsd/graphify.md +201 -0
  65. package/commands/gsd/health.md +22 -0
  66. package/commands/gsd/help.md +24 -0
  67. package/commands/gsd/import.md +37 -0
  68. package/commands/gsd/inbox.md +38 -0
  69. package/commands/gsd/ingest-docs.md +42 -0
  70. package/commands/gsd/insert-phase.md +32 -0
  71. package/commands/gsd/intel.md +179 -0
  72. package/commands/gsd/join-discord.md +19 -0
  73. package/commands/gsd/list-phase-assumptions.md +46 -0
  74. package/commands/gsd/list-workspaces.md +19 -0
  75. package/commands/gsd/manager.md +40 -0
  76. package/commands/gsd/map-codebase.md +71 -0
  77. package/commands/gsd/milestone-summary.md +51 -0
  78. package/commands/gsd/new-milestone.md +44 -0
  79. package/commands/gsd/new-project.md +46 -0
  80. package/commands/gsd/new-workspace.md +44 -0
  81. package/commands/gsd/next.md +28 -0
  82. package/commands/gsd/note.md +34 -0
  83. package/commands/gsd/pause-work.md +38 -0
  84. package/commands/gsd/plan-milestone-gaps.md +34 -0
  85. package/commands/gsd/plan-phase.md +52 -0
  86. package/commands/gsd/plan-review-convergence.md +52 -0
  87. package/commands/gsd/plant-seed.md +28 -0
  88. package/commands/gsd/pr-branch.md +25 -0
  89. package/commands/gsd/profile-user.md +46 -0
  90. package/commands/gsd/progress.md +25 -0
  91. package/commands/gsd/quick.md +173 -0
  92. package/commands/gsd/reapply-patches.md +331 -0
  93. package/commands/gsd/remove-phase.md +31 -0
  94. package/commands/gsd/remove-workspace.md +26 -0
  95. package/commands/gsd/research-phase.md +195 -0
  96. package/commands/gsd/resume-work.md +40 -0
  97. package/commands/gsd/review-backlog.md +62 -0
  98. package/commands/gsd/review.md +40 -0
  99. package/commands/gsd/scan.md +26 -0
  100. package/commands/gsd/secure-phase.md +35 -0
  101. package/commands/gsd/session-report.md +19 -0
  102. package/commands/gsd/set-profile.md +12 -0
  103. package/commands/gsd/settings.md +36 -0
  104. package/commands/gsd/ship.md +23 -0
  105. package/commands/gsd/sketch-wrap-up.md +31 -0
  106. package/commands/gsd/sketch.md +49 -0
  107. package/commands/gsd/spec-phase.md +62 -0
  108. package/commands/gsd/spike-wrap-up.md +31 -0
  109. package/commands/gsd/spike.md +46 -0
  110. package/commands/gsd/stats.md +18 -0
  111. package/commands/gsd/sync-skills.md +19 -0
  112. package/commands/gsd/thread.md +227 -0
  113. package/commands/gsd/ui-phase.md +34 -0
  114. package/commands/gsd/ui-review.md +32 -0
  115. package/commands/gsd/ultraplan-phase.md +33 -0
  116. package/commands/gsd/undo.md +34 -0
  117. package/commands/gsd/update.md +37 -0
  118. package/commands/gsd/validate-phase.md +35 -0
  119. package/commands/gsd/verify-work.md +38 -0
  120. package/commands/gsd/workstreams.md +69 -0
  121. package/get-shit-done/bin/gsd-tools.cjs +1263 -0
  122. package/get-shit-done/bin/lib/artifacts.cjs +52 -0
  123. package/get-shit-done/bin/lib/audit.cjs +757 -0
  124. package/get-shit-done/bin/lib/commands.cjs +1023 -0
  125. package/get-shit-done/bin/lib/config-schema.cjs +79 -0
  126. package/get-shit-done/bin/lib/config.cjs +463 -0
  127. package/get-shit-done/bin/lib/core.cjs +1794 -0
  128. package/get-shit-done/bin/lib/docs.cjs +267 -0
  129. package/get-shit-done/bin/lib/frontmatter.cjs +379 -0
  130. package/get-shit-done/bin/lib/graphify.cjs +494 -0
  131. package/get-shit-done/bin/lib/gsd2-import.cjs +511 -0
  132. package/get-shit-done/bin/lib/init.cjs +1878 -0
  133. package/get-shit-done/bin/lib/intel.cjs +639 -0
  134. package/get-shit-done/bin/lib/learnings.cjs +378 -0
  135. package/get-shit-done/bin/lib/milestone.cjs +283 -0
  136. package/get-shit-done/bin/lib/model-profiles.cjs +71 -0
  137. package/get-shit-done/bin/lib/phase.cjs +1058 -0
  138. package/get-shit-done/bin/lib/profile-output.cjs +1080 -0
  139. package/get-shit-done/bin/lib/profile-pipeline.cjs +539 -0
  140. package/get-shit-done/bin/lib/roadmap.cjs +523 -0
  141. package/get-shit-done/bin/lib/schema-detect.cjs +238 -0
  142. package/get-shit-done/bin/lib/security.cjs +504 -0
  143. package/get-shit-done/bin/lib/state.cjs +1649 -0
  144. package/get-shit-done/bin/lib/template.cjs +226 -0
  145. package/get-shit-done/bin/lib/uat.cjs +288 -0
  146. package/get-shit-done/bin/lib/verify.cjs +1184 -0
  147. package/get-shit-done/bin/lib/workstream.cjs +495 -0
  148. package/get-shit-done/bin/repair-sdk.cjs +177 -0
  149. package/get-shit-done/contexts/dev.md +21 -0
  150. package/get-shit-done/contexts/research.md +22 -0
  151. package/get-shit-done/contexts/review.md +22 -0
  152. package/get-shit-done/references/agent-contracts.md +79 -0
  153. package/get-shit-done/references/ai-evals.md +156 -0
  154. package/get-shit-done/references/ai-frameworks.md +186 -0
  155. package/get-shit-done/references/artifact-types.md +131 -0
  156. package/get-shit-done/references/autonomous-smart-discuss.md +277 -0
  157. package/get-shit-done/references/checkpoints.md +808 -0
  158. package/get-shit-done/references/common-bug-patterns.md +114 -0
  159. package/get-shit-done/references/context-budget.md +49 -0
  160. package/get-shit-done/references/continuation-format.md +253 -0
  161. package/get-shit-done/references/debugger-philosophy.md +76 -0
  162. package/get-shit-done/references/decimal-phase-calculation.md +64 -0
  163. package/get-shit-done/references/doc-conflict-engine.md +91 -0
  164. package/get-shit-done/references/domain-probes.md +125 -0
  165. package/get-shit-done/references/executor-examples.md +110 -0
  166. package/get-shit-done/references/few-shot-examples/plan-checker.md +73 -0
  167. package/get-shit-done/references/few-shot-examples/verifier.md +109 -0
  168. package/get-shit-done/references/gate-prompts.md +100 -0
  169. package/get-shit-done/references/gates.md +70 -0
  170. package/get-shit-done/references/git-integration.md +295 -0
  171. package/get-shit-done/references/git-planning-commit.md +40 -0
  172. package/get-shit-done/references/ios-scaffold.md +123 -0
  173. package/get-shit-done/references/mandatory-initial-read.md +2 -0
  174. package/get-shit-done/references/model-profile-resolution.md +38 -0
  175. package/get-shit-done/references/model-profiles.md +145 -0
  176. package/get-shit-done/references/phase-argument-parsing.md +61 -0
  177. package/get-shit-done/references/planner-antipatterns.md +89 -0
  178. package/get-shit-done/references/planner-gap-closure.md +62 -0
  179. package/get-shit-done/references/planner-reviews.md +39 -0
  180. package/get-shit-done/references/planner-revision.md +87 -0
  181. package/get-shit-done/references/planner-source-audit.md +73 -0
  182. package/get-shit-done/references/planning-config.md +460 -0
  183. package/get-shit-done/references/project-skills-discovery.md +19 -0
  184. package/get-shit-done/references/questioning.md +162 -0
  185. package/get-shit-done/references/revision-loop.md +97 -0
  186. package/get-shit-done/references/sketch-interactivity.md +41 -0
  187. package/get-shit-done/references/sketch-theme-system.md +94 -0
  188. package/get-shit-done/references/sketch-tooling.md +45 -0
  189. package/get-shit-done/references/sketch-variant-patterns.md +81 -0
  190. package/get-shit-done/references/tdd.md +330 -0
  191. package/get-shit-done/references/thinking-models-debug.md +44 -0
  192. package/get-shit-done/references/thinking-models-execution.md +50 -0
  193. package/get-shit-done/references/thinking-models-planning.md +62 -0
  194. package/get-shit-done/references/thinking-models-research.md +50 -0
  195. package/get-shit-done/references/thinking-models-verification.md +55 -0
  196. package/get-shit-done/references/thinking-partner.md +96 -0
  197. package/get-shit-done/references/ui-brand.md +160 -0
  198. package/get-shit-done/references/universal-anti-patterns.md +63 -0
  199. package/get-shit-done/references/user-profiling.md +681 -0
  200. package/get-shit-done/references/verification-overrides.md +227 -0
  201. package/get-shit-done/references/verification-patterns.md +612 -0
  202. package/get-shit-done/references/workstream-flag.md +111 -0
  203. package/get-shit-done/templates/AI-SPEC.md +246 -0
  204. package/get-shit-done/templates/DEBUG.md +169 -0
  205. package/get-shit-done/templates/README.md +76 -0
  206. package/get-shit-done/templates/SECURITY.md +61 -0
  207. package/get-shit-done/templates/UAT.md +265 -0
  208. package/get-shit-done/templates/UI-SPEC.md +100 -0
  209. package/get-shit-done/templates/VALIDATION.md +76 -0
  210. package/get-shit-done/templates/claude-md.md +145 -0
  211. package/get-shit-done/templates/codebase/architecture.md +255 -0
  212. package/get-shit-done/templates/codebase/concerns.md +310 -0
  213. package/get-shit-done/templates/codebase/conventions.md +307 -0
  214. package/get-shit-done/templates/codebase/integrations.md +280 -0
  215. package/get-shit-done/templates/codebase/stack.md +186 -0
  216. package/get-shit-done/templates/codebase/structure.md +285 -0
  217. package/get-shit-done/templates/codebase/testing.md +480 -0
  218. package/get-shit-done/templates/config.json +56 -0
  219. package/get-shit-done/templates/context.md +352 -0
  220. package/get-shit-done/templates/continue-here.md +78 -0
  221. package/get-shit-done/templates/copilot-instructions.md +7 -0
  222. package/get-shit-done/templates/debug-subagent-prompt.md +91 -0
  223. package/get-shit-done/templates/dev-preferences.md +21 -0
  224. package/get-shit-done/templates/discovery.md +146 -0
  225. package/get-shit-done/templates/discussion-log.md +63 -0
  226. package/get-shit-done/templates/milestone-archive.md +123 -0
  227. package/get-shit-done/templates/milestone.md +115 -0
  228. package/get-shit-done/templates/phase-prompt.md +610 -0
  229. package/get-shit-done/templates/planner-subagent-prompt.md +117 -0
  230. package/get-shit-done/templates/project.md +186 -0
  231. package/get-shit-done/templates/requirements.md +231 -0
  232. package/get-shit-done/templates/research-project/ARCHITECTURE.md +204 -0
  233. package/get-shit-done/templates/research-project/FEATURES.md +147 -0
  234. package/get-shit-done/templates/research-project/PITFALLS.md +200 -0
  235. package/get-shit-done/templates/research-project/STACK.md +120 -0
  236. package/get-shit-done/templates/research-project/SUMMARY.md +170 -0
  237. package/get-shit-done/templates/research.md +592 -0
  238. package/get-shit-done/templates/retrospective.md +54 -0
  239. package/get-shit-done/templates/roadmap.md +202 -0
  240. package/get-shit-done/templates/spec.md +307 -0
  241. package/get-shit-done/templates/state.md +184 -0
  242. package/get-shit-done/templates/summary-complex.md +59 -0
  243. package/get-shit-done/templates/summary-minimal.md +41 -0
  244. package/get-shit-done/templates/summary-standard.md +48 -0
  245. package/get-shit-done/templates/summary.md +248 -0
  246. package/get-shit-done/templates/user-profile.md +146 -0
  247. package/get-shit-done/templates/user-setup.md +311 -0
  248. package/get-shit-done/templates/verification-report.md +322 -0
  249. package/get-shit-done/workflows/add-phase.md +112 -0
  250. package/get-shit-done/workflows/add-tests.md +354 -0
  251. package/get-shit-done/workflows/add-todo.md +160 -0
  252. package/get-shit-done/workflows/ai-integration-phase.md +284 -0
  253. package/get-shit-done/workflows/analyze-dependencies.md +96 -0
  254. package/get-shit-done/workflows/audit-fix.md +175 -0
  255. package/get-shit-done/workflows/audit-milestone.md +340 -0
  256. package/get-shit-done/workflows/audit-uat.md +109 -0
  257. package/get-shit-done/workflows/autonomous.md +789 -0
  258. package/get-shit-done/workflows/check-todos.md +179 -0
  259. package/get-shit-done/workflows/cleanup.md +154 -0
  260. package/get-shit-done/workflows/code-review-fix.md +497 -0
  261. package/get-shit-done/workflows/code-review.md +515 -0
  262. package/get-shit-done/workflows/complete-milestone.md +847 -0
  263. package/get-shit-done/workflows/diagnose-issues.md +238 -0
  264. package/get-shit-done/workflows/discovery-phase.md +291 -0
  265. package/get-shit-done/workflows/discuss-phase-assumptions.md +670 -0
  266. package/get-shit-done/workflows/discuss-phase-power.md +308 -0
  267. package/get-shit-done/workflows/discuss-phase.md +1378 -0
  268. package/get-shit-done/workflows/do.md +110 -0
  269. package/get-shit-done/workflows/docs-update.md +1155 -0
  270. package/get-shit-done/workflows/eval-review.md +155 -0
  271. package/get-shit-done/workflows/execute-phase.md +1677 -0
  272. package/get-shit-done/workflows/execute-plan.md +533 -0
  273. package/get-shit-done/workflows/explore.md +141 -0
  274. package/get-shit-done/workflows/extract_learnings.md +242 -0
  275. package/get-shit-done/workflows/fast.md +105 -0
  276. package/get-shit-done/workflows/forensics.md +265 -0
  277. package/get-shit-done/workflows/graduation.md +195 -0
  278. package/get-shit-done/workflows/health.md +314 -0
  279. package/get-shit-done/workflows/help.md +667 -0
  280. package/get-shit-done/workflows/import.md +246 -0
  281. package/get-shit-done/workflows/inbox.md +387 -0
  282. package/get-shit-done/workflows/ingest-docs.md +328 -0
  283. package/get-shit-done/workflows/insert-phase.md +130 -0
  284. package/get-shit-done/workflows/list-phase-assumptions.md +178 -0
  285. package/get-shit-done/workflows/list-workspaces.md +56 -0
  286. package/get-shit-done/workflows/manager.md +365 -0
  287. package/get-shit-done/workflows/map-codebase.md +393 -0
  288. package/get-shit-done/workflows/milestone-summary.md +223 -0
  289. package/get-shit-done/workflows/new-milestone.md +611 -0
  290. package/get-shit-done/workflows/new-project.md +1391 -0
  291. package/get-shit-done/workflows/new-workspace.md +239 -0
  292. package/get-shit-done/workflows/next.md +220 -0
  293. package/get-shit-done/workflows/node-repair.md +92 -0
  294. package/get-shit-done/workflows/note.md +158 -0
  295. package/get-shit-done/workflows/pause-work.md +243 -0
  296. package/get-shit-done/workflows/plan-milestone-gaps.md +273 -0
  297. package/get-shit-done/workflows/plan-phase.md +1349 -0
  298. package/get-shit-done/workflows/plan-review-convergence.md +254 -0
  299. package/get-shit-done/workflows/plant-seed.md +172 -0
  300. package/get-shit-done/workflows/pr-branch.md +157 -0
  301. package/get-shit-done/workflows/profile-user.md +452 -0
  302. package/get-shit-done/workflows/progress.md +619 -0
  303. package/get-shit-done/workflows/quick.md +970 -0
  304. package/get-shit-done/workflows/remove-phase.md +155 -0
  305. package/get-shit-done/workflows/remove-workspace.md +92 -0
  306. package/get-shit-done/workflows/research-phase.md +89 -0
  307. package/get-shit-done/workflows/resume-project.md +326 -0
  308. package/get-shit-done/workflows/review.md +344 -0
  309. package/get-shit-done/workflows/scan.md +102 -0
  310. package/get-shit-done/workflows/secure-phase.md +166 -0
  311. package/get-shit-done/workflows/session-report.md +146 -0
  312. package/get-shit-done/workflows/settings.md +319 -0
  313. package/get-shit-done/workflows/ship.md +302 -0
  314. package/get-shit-done/workflows/sketch-wrap-up.md +283 -0
  315. package/get-shit-done/workflows/sketch.md +286 -0
  316. package/get-shit-done/workflows/spec-phase.md +262 -0
  317. package/get-shit-done/workflows/spike-wrap-up.md +281 -0
  318. package/get-shit-done/workflows/spike.md +362 -0
  319. package/get-shit-done/workflows/stats.md +60 -0
  320. package/get-shit-done/workflows/sync-skills.md +182 -0
  321. package/get-shit-done/workflows/transition.md +693 -0
  322. package/get-shit-done/workflows/ui-phase.md +323 -0
  323. package/get-shit-done/workflows/ui-review.md +190 -0
  324. package/get-shit-done/workflows/ultraplan-phase.md +189 -0
  325. package/get-shit-done/workflows/undo.md +314 -0
  326. package/get-shit-done/workflows/update.md +587 -0
  327. package/get-shit-done/workflows/validate-phase.md +176 -0
  328. package/get-shit-done/workflows/verify-phase.md +465 -0
  329. package/get-shit-done/workflows/verify-work.md +740 -0
  330. package/hooks/dist/gsd-check-update-worker.js +108 -0
  331. package/hooks/dist/gsd-check-update.js +64 -0
  332. package/hooks/dist/gsd-context-monitor.js +192 -0
  333. package/hooks/dist/gsd-phase-boundary.sh +28 -0
  334. package/hooks/dist/gsd-prompt-guard.js +97 -0
  335. package/hooks/dist/gsd-read-guard.js +82 -0
  336. package/hooks/dist/gsd-read-injection-scanner.js +152 -0
  337. package/hooks/dist/gsd-session-state.sh +34 -0
  338. package/hooks/dist/gsd-statusline.js +293 -0
  339. package/hooks/dist/gsd-validate-commit.sh +48 -0
  340. package/hooks/dist/gsd-workflow-guard.js +94 -0
  341. package/hooks/gsd-check-update-worker.js +108 -0
  342. package/hooks/gsd-check-update.js +64 -0
  343. package/hooks/gsd-context-monitor.js +192 -0
  344. package/hooks/gsd-phase-boundary.sh +28 -0
  345. package/hooks/gsd-prompt-guard.js +97 -0
  346. package/hooks/gsd-read-guard.js +82 -0
  347. package/hooks/gsd-read-injection-scanner.js +152 -0
  348. package/hooks/gsd-session-state.sh +34 -0
  349. package/hooks/gsd-statusline.js +293 -0
  350. package/hooks/gsd-validate-commit.sh +48 -0
  351. package/hooks/gsd-workflow-guard.js +94 -0
  352. package/package.json +59 -0
  353. package/scripts/base64-scan.sh +262 -0
  354. package/scripts/build-hooks.js +95 -0
  355. package/scripts/gen-inventory-manifest.cjs +109 -0
  356. package/scripts/prompt-injection-scan.sh +201 -0
  357. package/scripts/run-tests.cjs +33 -0
  358. package/scripts/secret-scan.sh +227 -0
  359. package/sdk/package-lock.json +1998 -0
  360. package/sdk/package.json +52 -0
  361. package/sdk/prompts/agents/gsd-executor.md +110 -0
  362. package/sdk/prompts/agents/gsd-phase-researcher.md +158 -0
  363. package/sdk/prompts/agents/gsd-plan-checker.md +160 -0
  364. package/sdk/prompts/agents/gsd-planner.md +214 -0
  365. package/sdk/prompts/agents/gsd-project-researcher.md +323 -0
  366. package/sdk/prompts/agents/gsd-research-synthesizer.md +237 -0
  367. package/sdk/prompts/agents/gsd-roadmapper.md +670 -0
  368. package/sdk/prompts/agents/gsd-verifier.md +159 -0
  369. package/sdk/prompts/templates/project.md +186 -0
  370. package/sdk/prompts/templates/requirements.md +231 -0
  371. package/sdk/prompts/templates/research-project/ARCHITECTURE.md +204 -0
  372. package/sdk/prompts/templates/research-project/FEATURES.md +147 -0
  373. package/sdk/prompts/templates/research-project/PITFALLS.md +200 -0
  374. package/sdk/prompts/templates/research-project/STACK.md +120 -0
  375. package/sdk/prompts/templates/research-project/SUMMARY.md +170 -0
  376. package/sdk/prompts/templates/roadmap.md +202 -0
  377. package/sdk/prompts/templates/state.md +175 -0
  378. package/sdk/prompts/workflows/discuss-phase.md +126 -0
  379. package/sdk/prompts/workflows/execute-plan.md +106 -0
  380. package/sdk/prompts/workflows/plan-phase.md +84 -0
  381. package/sdk/prompts/workflows/research-phase.md +45 -0
  382. package/sdk/prompts/workflows/verify-phase.md +142 -0
  383. package/sdk/src/assembled-prompts.test.ts +349 -0
  384. package/sdk/src/cli-transport.test.ts +388 -0
  385. package/sdk/src/cli-transport.ts +130 -0
  386. package/sdk/src/cli.test.ts +383 -0
  387. package/sdk/src/cli.ts +670 -0
  388. package/sdk/src/config.test.ts +168 -0
  389. package/sdk/src/config.ts +177 -0
  390. package/sdk/src/context-engine.test.ts +295 -0
  391. package/sdk/src/context-engine.ts +170 -0
  392. package/sdk/src/context-truncation.test.ts +163 -0
  393. package/sdk/src/context-truncation.ts +233 -0
  394. package/sdk/src/e2e.integration.test.ts +178 -0
  395. package/sdk/src/errors.ts +72 -0
  396. package/sdk/src/event-stream.test.ts +661 -0
  397. package/sdk/src/event-stream.ts +441 -0
  398. package/sdk/src/failure-memory.test.ts +457 -0
  399. package/sdk/src/failure-memory.ts +1324 -0
  400. package/sdk/src/golden/capture.ts +95 -0
  401. package/sdk/src/golden/fixtures/generate-slug.golden.json +1 -0
  402. package/sdk/src/golden/fixtures/profile-sample-sessions/demo-project/sample.jsonl +3 -0
  403. package/sdk/src/golden/fixtures/summary-extract-sample.md +26 -0
  404. package/sdk/src/golden/fixtures/uat-render-checkpoint-sample.md +15 -0
  405. package/sdk/src/golden/golden-integration-covered.ts +30 -0
  406. package/sdk/src/golden/golden-mutation-covered.ts +7 -0
  407. package/sdk/src/golden/golden-policy.test.ts +8 -0
  408. package/sdk/src/golden/golden-policy.ts +112 -0
  409. package/sdk/src/golden/golden.integration.test.ts +373 -0
  410. package/sdk/src/golden/init-golden-normalize.ts +15 -0
  411. package/sdk/src/golden/read-only-golden-rows.ts +77 -0
  412. package/sdk/src/golden/read-only-parity.integration.test.ts +125 -0
  413. package/sdk/src/golden/registry-canonical-commands.ts +31 -0
  414. package/sdk/src/gsd-tools.test.ts +409 -0
  415. package/sdk/src/gsd-tools.ts +595 -0
  416. package/sdk/src/headless-prompts.test.ts +159 -0
  417. package/sdk/src/index.ts +333 -0
  418. package/sdk/src/init-e2e.integration.test.ts +136 -0
  419. package/sdk/src/init-runner.test.ts +783 -0
  420. package/sdk/src/init-runner.ts +735 -0
  421. package/sdk/src/lifecycle-e2e.integration.test.ts +258 -0
  422. package/sdk/src/logger.test.ts +149 -0
  423. package/sdk/src/logger.ts +113 -0
  424. package/sdk/src/milestone-runner.test.ts +421 -0
  425. package/sdk/src/phase-prompt.test.ts +538 -0
  426. package/sdk/src/phase-prompt.ts +264 -0
  427. package/sdk/src/phase-runner-types.test.ts +421 -0
  428. package/sdk/src/phase-runner.integration.test.ts +377 -0
  429. package/sdk/src/phase-runner.test.ts +2333 -0
  430. package/sdk/src/phase-runner.ts +1203 -0
  431. package/sdk/src/plan-parser.test.ts +528 -0
  432. package/sdk/src/plan-parser.ts +427 -0
  433. package/sdk/src/prompt-builder.test.ts +306 -0
  434. package/sdk/src/prompt-builder.ts +193 -0
  435. package/sdk/src/prompt-sanitizer.test.ts +260 -0
  436. package/sdk/src/prompt-sanitizer.ts +71 -0
  437. package/sdk/src/query/QUERY-HANDLERS.md +317 -0
  438. package/sdk/src/query/audit-open.ts +722 -0
  439. package/sdk/src/query/check-auto-mode.test.ts +77 -0
  440. package/sdk/src/query/check-auto-mode.ts +50 -0
  441. package/sdk/src/query/check-completion.test.ts +113 -0
  442. package/sdk/src/query/check-completion.ts +182 -0
  443. package/sdk/src/query/check-gates.test.ts +103 -0
  444. package/sdk/src/query/check-gates.ts +112 -0
  445. package/sdk/src/query/check-ship-ready.test.ts +77 -0
  446. package/sdk/src/query/check-ship-ready.ts +103 -0
  447. package/sdk/src/query/check-verification-status.test.ts +143 -0
  448. package/sdk/src/query/check-verification-status.ts +160 -0
  449. package/sdk/src/query/commit.test.ts +202 -0
  450. package/sdk/src/query/commit.ts +301 -0
  451. package/sdk/src/query/config-gates.test.ts +89 -0
  452. package/sdk/src/query/config-gates.ts +69 -0
  453. package/sdk/src/query/config-mutation.test.ts +365 -0
  454. package/sdk/src/query/config-mutation.ts +497 -0
  455. package/sdk/src/query/config-query.test.ts +161 -0
  456. package/sdk/src/query/config-query.ts +190 -0
  457. package/sdk/src/query/context-history.test.ts +165 -0
  458. package/sdk/src/query/context-history.ts +467 -0
  459. package/sdk/src/query/decomposed-handlers.test.ts +365 -0
  460. package/sdk/src/query/detect-custom-files.ts +97 -0
  461. package/sdk/src/query/detect-phase-type.test.ts +105 -0
  462. package/sdk/src/query/detect-phase-type.ts +141 -0
  463. package/sdk/src/query/docs-init.ts +257 -0
  464. package/sdk/src/query/failure-capture.ts +58 -0
  465. package/sdk/src/query/frontmatter-array.test.ts +14 -0
  466. package/sdk/src/query/frontmatter-mutation.test.ts +259 -0
  467. package/sdk/src/query/frontmatter-mutation.ts +343 -0
  468. package/sdk/src/query/frontmatter.test.ts +281 -0
  469. package/sdk/src/query/frontmatter.ts +397 -0
  470. package/sdk/src/query/helpers.test.ts +426 -0
  471. package/sdk/src/query/helpers.ts +482 -0
  472. package/sdk/src/query/index.ts +586 -0
  473. package/sdk/src/query/init-complex.test.ts +232 -0
  474. package/sdk/src/query/init-complex.ts +578 -0
  475. package/sdk/src/query/init.test.ts +522 -0
  476. package/sdk/src/query/init.ts +1046 -0
  477. package/sdk/src/query/intel.test.ts +90 -0
  478. package/sdk/src/query/intel.ts +404 -0
  479. package/sdk/src/query/normalize-query-command.test.ts +50 -0
  480. package/sdk/src/query/normalize-query-command.ts +56 -0
  481. package/sdk/src/query/phase-lifecycle.test.ts +1126 -0
  482. package/sdk/src/query/phase-lifecycle.ts +1799 -0
  483. package/sdk/src/query/phase-list-queries.test.ts +88 -0
  484. package/sdk/src/query/phase-list-queries.ts +152 -0
  485. package/sdk/src/query/phase-ready.test.ts +65 -0
  486. package/sdk/src/query/phase-ready.ts +158 -0
  487. package/sdk/src/query/phase.test.ts +307 -0
  488. package/sdk/src/query/phase.ts +340 -0
  489. package/sdk/src/query/pipeline.test.ts +169 -0
  490. package/sdk/src/query/pipeline.ts +243 -0
  491. package/sdk/src/query/plan-execution-route.test.ts +166 -0
  492. package/sdk/src/query/plan-execution-route.ts +209 -0
  493. package/sdk/src/query/plan-task-structure.test.ts +65 -0
  494. package/sdk/src/query/plan-task-structure.ts +63 -0
  495. package/sdk/src/query/profile-extract-messages.ts +247 -0
  496. package/sdk/src/query/profile-output.ts +908 -0
  497. package/sdk/src/query/profile-questionnaire-data.ts +181 -0
  498. package/sdk/src/query/profile-sample.ts +184 -0
  499. package/sdk/src/query/profile-scan-sessions.ts +174 -0
  500. package/sdk/src/query/profile.test.ts +74 -0
  501. package/sdk/src/query/profile.ts +337 -0
  502. package/sdk/src/query/progress.test.ts +156 -0
  503. package/sdk/src/query/progress.ts +566 -0
  504. package/sdk/src/query/registry.test.ts +216 -0
  505. package/sdk/src/query/registry.ts +174 -0
  506. package/sdk/src/query/requirements-extract-from-plans.test.ts +58 -0
  507. package/sdk/src/query/requirements-extract-from-plans.ts +86 -0
  508. package/sdk/src/query/roadmap-update-plan-progress.ts +132 -0
  509. package/sdk/src/query/roadmap.test.ts +359 -0
  510. package/sdk/src/query/roadmap.ts +591 -0
  511. package/sdk/src/query/route-next-action.test.ts +61 -0
  512. package/sdk/src/query/route-next-action.ts +345 -0
  513. package/sdk/src/query/runtime-health.ts +7 -0
  514. package/sdk/src/query/schema-detect.ts +189 -0
  515. package/sdk/src/query/skill-manifest.ts +214 -0
  516. package/sdk/src/query/skills.test.ts +80 -0
  517. package/sdk/src/query/skills.ts +62 -0
  518. package/sdk/src/query/state-mutation.test.ts +450 -0
  519. package/sdk/src/query/state-mutation.ts +1444 -0
  520. package/sdk/src/query/state-project-load.ts +109 -0
  521. package/sdk/src/query/state.test.ts +347 -0
  522. package/sdk/src/query/state.ts +397 -0
  523. package/sdk/src/query/summary.test.ts +95 -0
  524. package/sdk/src/query/summary.ts +296 -0
  525. package/sdk/src/query/template.test.ts +180 -0
  526. package/sdk/src/query/template.ts +242 -0
  527. package/sdk/src/query/uat.test.ts +77 -0
  528. package/sdk/src/query/uat.ts +314 -0
  529. package/sdk/src/query/utils.test.ts +82 -0
  530. package/sdk/src/query/utils.ts +92 -0
  531. package/sdk/src/query/validate.test.ts +656 -0
  532. package/sdk/src/query/validate.ts +807 -0
  533. package/sdk/src/query/verify.test.ts +414 -0
  534. package/sdk/src/query/verify.ts +645 -0
  535. package/sdk/src/query/websearch.test.ts +31 -0
  536. package/sdk/src/query/websearch.ts +82 -0
  537. package/sdk/src/query/workspace.test.ts +119 -0
  538. package/sdk/src/query/workspace.ts +131 -0
  539. package/sdk/src/query/workstream.test.ts +51 -0
  540. package/sdk/src/query/workstream.ts +434 -0
  541. package/sdk/src/research-gate.test.ts +190 -0
  542. package/sdk/src/research-gate.ts +94 -0
  543. package/sdk/src/runtime-health.test.ts +176 -0
  544. package/sdk/src/runtime-health.ts +387 -0
  545. package/sdk/src/session-runner.test.ts +98 -0
  546. package/sdk/src/session-runner.ts +299 -0
  547. package/sdk/src/tool-scoping.test.ts +160 -0
  548. package/sdk/src/tool-scoping.ts +61 -0
  549. package/sdk/src/types.ts +917 -0
  550. package/sdk/src/workstream-utils.ts +33 -0
  551. package/sdk/src/ws-flag.test.ts +285 -0
  552. package/sdk/src/ws-transport.test.ts +161 -0
  553. package/sdk/src/ws-transport.ts +93 -0
  554. package/sdk/tsconfig.json +20 -0
@@ -0,0 +1,1649 @@
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, normalizeMd, planningDir, planningPaths, output, error, atomicWriteFileSync } = require('./core.cjs');
8
+ const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
9
+
10
+ // Cache disk scan results from buildStateFrontmatter per cwd per process (#1967).
11
+ // Avoids re-reading N+1 directories on every state write when the phase structure
12
+ // hasn't changed within the same gsd-tools invocation.
13
+ const _diskScanCache = new Map();
14
+
15
+ /** Shorthand — every state command needs this path */
16
+ function getStatePath(cwd) {
17
+ return planningPaths(cwd).state;
18
+ }
19
+
20
+ // Track all lock files held by this process so they can be removed on exit.
21
+ // process.on('exit') fires even on process.exit(1), unlike try/finally which is
22
+ // skipped when error() calls process.exit(1) inside a locked region (#1916).
23
+ const _heldStateLocks = new Set();
24
+ process.on('exit', () => {
25
+ for (const lockPath of _heldStateLocks) {
26
+ try { require('fs').unlinkSync(lockPath); } catch { /* already gone */ }
27
+ }
28
+ });
29
+
30
+ // Shared helper: extract a field value from STATE.md content.
31
+ // Supports both **Field:** bold and plain Field: format.
32
+ // Horizontal whitespace only after ':' so YAML keys like `progress:` do not match as `Progress:` (parity with sdk/helpers stateExtractField).
33
+ function stateExtractField(content, fieldName) {
34
+ const escaped = escapeRegex(fieldName);
35
+ const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
36
+ const boldMatch = content.match(boldPattern);
37
+ if (boldMatch) return boldMatch[1].trim();
38
+ const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
39
+ const plainMatch = content.match(plainPattern);
40
+ return plainMatch ? plainMatch[1].trim() : null;
41
+ }
42
+
43
+ function cmdStateLoad(cwd, raw) {
44
+ const config = loadConfig(cwd);
45
+ const planDir = planningPaths(cwd).planning;
46
+
47
+ let stateRaw = '';
48
+ try {
49
+ stateRaw = fs.readFileSync(path.join(planDir, 'STATE.md'), 'utf-8');
50
+ } catch { /* intentionally empty */ }
51
+
52
+ const configExists = fs.existsSync(path.join(planDir, 'config.json'));
53
+ const roadmapExists = fs.existsSync(path.join(planDir, 'ROADMAP.md'));
54
+ const stateExists = stateRaw.length > 0;
55
+
56
+ const result = {
57
+ config,
58
+ state_raw: stateRaw,
59
+ state_exists: stateExists,
60
+ roadmap_exists: roadmapExists,
61
+ config_exists: configExists,
62
+ };
63
+
64
+ // For --raw, output a condensed key=value format
65
+ if (raw) {
66
+ const c = config;
67
+ const lines = [
68
+ `model_profile=${c.model_profile}`,
69
+ `commit_docs=${c.commit_docs}`,
70
+ `branching_strategy=${c.branching_strategy}`,
71
+ `phase_branch_template=${c.phase_branch_template}`,
72
+ `milestone_branch_template=${c.milestone_branch_template}`,
73
+ `parallelization=${c.parallelization}`,
74
+ `research=${c.research}`,
75
+ `plan_checker=${c.plan_checker}`,
76
+ `verifier=${c.verifier}`,
77
+ `config_exists=${configExists}`,
78
+ `roadmap_exists=${roadmapExists}`,
79
+ `state_exists=${stateExists}`,
80
+ ];
81
+ process.stdout.write(lines.join('\n'));
82
+ process.exit(0);
83
+ }
84
+
85
+ output(result);
86
+ }
87
+
88
+ function cmdStateGet(cwd, section, raw) {
89
+ const statePath = planningPaths(cwd).state;
90
+ try {
91
+ const content = fs.readFileSync(statePath, 'utf-8');
92
+
93
+ if (!section) {
94
+ output({ content }, raw, content);
95
+ return;
96
+ }
97
+
98
+ // Try to find markdown section or field
99
+ const fieldEscaped = escapeRegex(section);
100
+
101
+ // Check for **field:** value (bold format)
102
+ const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
103
+ const boldMatch = content.match(boldPattern);
104
+ if (boldMatch) {
105
+ output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
106
+ return;
107
+ }
108
+
109
+ // Check for field: value (plain format)
110
+ const plainPattern = new RegExp(`^${fieldEscaped}:\\s*(.*)`, 'im');
111
+ const plainMatch = content.match(plainPattern);
112
+ if (plainMatch) {
113
+ output({ [section]: plainMatch[1].trim() }, raw, plainMatch[1].trim());
114
+ return;
115
+ }
116
+
117
+ // Check for ## Section
118
+ const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
119
+ const sectionMatch = content.match(sectionPattern);
120
+ if (sectionMatch) {
121
+ output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim());
122
+ return;
123
+ }
124
+
125
+ output({ error: `Section or field "${section}" not found` }, raw, '');
126
+ } catch {
127
+ error('STATE.md not found');
128
+ }
129
+ }
130
+
131
+ function readTextArgOrFile(cwd, value, filePath, label) {
132
+ if (!filePath) return value;
133
+
134
+ // Path traversal guard: ensure file resolves within project directory
135
+ const { validatePath } = require('./security.cjs');
136
+ const pathCheck = validatePath(filePath, cwd, { allowAbsolute: true });
137
+ if (!pathCheck.safe) {
138
+ throw new Error(`${label} path rejected: ${pathCheck.error}`);
139
+ }
140
+
141
+ try {
142
+ return fs.readFileSync(pathCheck.resolved, 'utf-8').trimEnd();
143
+ } catch {
144
+ throw new Error(`${label} file not found: ${filePath}`);
145
+ }
146
+ }
147
+
148
+ function cmdStatePatch(cwd, patches, raw) {
149
+ // Validate all field names before processing
150
+ const { validateFieldName } = require('./security.cjs');
151
+ for (const field of Object.keys(patches)) {
152
+ const fieldCheck = validateFieldName(field);
153
+ if (!fieldCheck.valid) {
154
+ error(`state patch: ${fieldCheck.error}`);
155
+ }
156
+ }
157
+
158
+ const statePath = planningPaths(cwd).state;
159
+ try {
160
+ const results = { updated: [], failed: [] };
161
+
162
+ // Use atomic read-modify-write to prevent lost updates from concurrent agents
163
+ readModifyWriteStateMd(statePath, (content) => {
164
+ for (const [field, value] of Object.entries(patches)) {
165
+ const fieldEscaped = escapeRegex(field);
166
+ // Try **Field:** bold format first, then plain Field: format
167
+ const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
168
+ const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
169
+
170
+ if (boldPattern.test(content)) {
171
+ content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
172
+ results.updated.push(field);
173
+ } else if (plainPattern.test(content)) {
174
+ content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
175
+ results.updated.push(field);
176
+ } else {
177
+ results.failed.push(field);
178
+ }
179
+ }
180
+ return content;
181
+ }, cwd);
182
+
183
+ output(results, raw, results.updated.length > 0 ? 'true' : 'false');
184
+ } catch {
185
+ error('STATE.md not found');
186
+ }
187
+ }
188
+
189
+ function cmdStateUpdate(cwd, field, value) {
190
+ if (!field || value === undefined) {
191
+ error('field and value required for state update');
192
+ }
193
+
194
+ // Validate field name to prevent regex injection via crafted field names
195
+ const { validateFieldName } = require('./security.cjs');
196
+ const fieldCheck = validateFieldName(field);
197
+ if (!fieldCheck.valid) {
198
+ error(`state update: ${fieldCheck.error}`);
199
+ }
200
+
201
+ const statePath = planningPaths(cwd).state;
202
+ try {
203
+ let updated = false;
204
+ readModifyWriteStateMd(statePath, (content) => {
205
+ const fieldEscaped = escapeRegex(field);
206
+ // Try **Field:** bold format first, then plain Field: format
207
+ const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
208
+ const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
209
+ if (boldPattern.test(content)) {
210
+ updated = true;
211
+ return content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
212
+ } else if (plainPattern.test(content)) {
213
+ updated = true;
214
+ return content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
215
+ }
216
+ return content;
217
+ }, cwd);
218
+ if (updated) {
219
+ output({ updated: true });
220
+ } else {
221
+ output({ updated: false, reason: `Field "${field}" not found in STATE.md` });
222
+ }
223
+ } catch {
224
+ output({ updated: false, reason: 'STATE.md not found' });
225
+ }
226
+ }
227
+
228
+ // ─── State Progression Engine ────────────────────────────────────────────────
229
+ // stateExtractField is defined above (shared helper) — do not duplicate.
230
+
231
+ function stateReplaceField(content, fieldName, newValue) {
232
+ const escaped = escapeRegex(fieldName);
233
+ // Try **Field:** bold format first, then plain Field: format
234
+ const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
235
+ if (boldPattern.test(content)) {
236
+ return content.replace(boldPattern, (_match, prefix) => `${prefix}${newValue}`);
237
+ }
238
+ const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
239
+ if (plainPattern.test(content)) {
240
+ return content.replace(plainPattern, (_match, prefix) => `${prefix}${newValue}`);
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /**
246
+ * Replace a STATE.md field with fallback field name support.
247
+ * Tries `primary` first, then `fallback` (if provided), returns content unchanged
248
+ * if neither matches. This consolidates the replaceWithFallback pattern that was
249
+ * previously duplicated inline across phase.cjs, milestone.cjs, and state.cjs.
250
+ */
251
+ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
252
+ let result = stateReplaceField(content, primary, value);
253
+ if (result) return result;
254
+ if (fallback) {
255
+ result = stateReplaceField(content, fallback, value);
256
+ if (result) return result;
257
+ }
258
+ // Neither pattern matched — field may have been reformatted or removed.
259
+ // Log diagnostic so template drift is detected early rather than silently swallowed.
260
+ process.stderr.write(
261
+ `[gsd-tools] WARNING: STATE.md field "${primary}"${fallback ? ` (fallback: "${fallback}")` : ''} not found — update skipped. ` +
262
+ `This may indicate STATE.md was externally modified or uses an unexpected format.\n`
263
+ );
264
+ return content;
265
+ }
266
+
267
+ /**
268
+ * Update fields within the ## Current Position section of STATE.md.
269
+ * This keeps the Current Position body in sync with the bold frontmatter fields.
270
+ * Only updates fields that already exist in the section; does not add new lines.
271
+ * Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
272
+ */
273
+ function updateCurrentPositionFields(content, fields) {
274
+ const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
275
+ const posMatch = content.match(posPattern);
276
+ if (!posMatch) return content;
277
+
278
+ let posBody = posMatch[2];
279
+
280
+ if (fields.status && /^Status:/m.test(posBody)) {
281
+ posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
282
+ }
283
+ if (fields.lastActivity && /^Last activity:/im.test(posBody)) {
284
+ posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
285
+ }
286
+ if (fields.plan && /^Plan:/m.test(posBody)) {
287
+ posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
288
+ }
289
+
290
+ return content.replace(posPattern, `${posMatch[1]}${posBody}`);
291
+ }
292
+
293
+ function cmdStateAdvancePlan(cwd, raw) {
294
+ const statePath = planningPaths(cwd).state;
295
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
296
+
297
+ const today = new Date().toISOString().split('T')[0];
298
+ let result = null;
299
+
300
+ readModifyWriteStateMd(statePath, (content) => {
301
+ // Try legacy separate fields first, then compound "Plan: X of Y" format
302
+ const legacyPlan = stateExtractField(content, 'Current Plan');
303
+ const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
304
+ const planField = stateExtractField(content, 'Plan');
305
+
306
+ let currentPlan, totalPlans;
307
+ let useCompoundFormat = false;
308
+
309
+ if (legacyPlan && legacyTotal) {
310
+ currentPlan = parseInt(legacyPlan, 10);
311
+ totalPlans = parseInt(legacyTotal, 10);
312
+ } else if (planField) {
313
+ // Compound format: "2 of 6 in current phase" or "2 of 6"
314
+ currentPlan = parseInt(planField, 10);
315
+ const ofMatch = planField.match(/of\s+(\d+)/);
316
+ totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
317
+ useCompoundFormat = true;
318
+ }
319
+
320
+ if (isNaN(currentPlan) || isNaN(totalPlans)) {
321
+ result = { error: true };
322
+ return content;
323
+ }
324
+
325
+ if (currentPlan >= totalPlans) {
326
+ content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification');
327
+ content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
328
+ content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
329
+ result = { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' };
330
+ } else {
331
+ const newPlan = currentPlan + 1;
332
+ let planDisplayValue;
333
+ if (useCompoundFormat) {
334
+ // Preserve compound format: "X of Y in current phase" → replace X only
335
+ planDisplayValue = planField.replace(/^\d+/, String(newPlan));
336
+ content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
337
+ } else {
338
+ planDisplayValue = `${newPlan} of ${totalPlans}`;
339
+ content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
340
+ }
341
+ content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute');
342
+ content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
343
+ content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
344
+ result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
345
+ }
346
+ return content;
347
+ }, cwd);
348
+
349
+ if (!result || result.error) {
350
+ output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw);
351
+ return;
352
+ }
353
+
354
+ if (result.advanced === false) {
355
+ output(result, raw, 'false');
356
+ } else {
357
+ output(result, raw, 'true');
358
+ }
359
+ }
360
+
361
+ function cmdStateRecordMetric(cwd, options, raw) {
362
+ const statePath = planningPaths(cwd).state;
363
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
364
+
365
+ const { phase, plan, duration, tasks, files } = options;
366
+
367
+ if (!phase || !plan || !duration) {
368
+ output({ error: 'phase, plan, and duration required' }, raw);
369
+ return;
370
+ }
371
+
372
+ let recorded = false;
373
+ readModifyWriteStateMd(statePath, (content) => {
374
+ // Find Performance Metrics section and its table
375
+ const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
376
+ const metricsMatch = content.match(metricsPattern);
377
+
378
+ if (metricsMatch) {
379
+ let tableBody = metricsMatch[2].trimEnd();
380
+ const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
381
+
382
+ if (tableBody.trim() === '' || tableBody.includes('None yet')) {
383
+ tableBody = newRow;
384
+ } else {
385
+ tableBody = tableBody + '\n' + newRow;
386
+ }
387
+
388
+ recorded = true;
389
+ return content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
390
+ }
391
+ return content;
392
+ }, cwd);
393
+
394
+ if (recorded) {
395
+ output({ recorded: true, phase, plan, duration }, raw, 'true');
396
+ } else {
397
+ output({ recorded: false, reason: 'Performance Metrics section not found in STATE.md' }, raw, 'false');
398
+ }
399
+ }
400
+
401
+ function cmdStateUpdateProgress(cwd, raw) {
402
+ const statePath = planningPaths(cwd).state;
403
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
404
+
405
+ // Count summaries across current milestone phases only (outside lock — read-only)
406
+ const phasesDir = planningPaths(cwd).phases;
407
+ let totalPlans = 0;
408
+ let totalSummaries = 0;
409
+
410
+ if (fs.existsSync(phasesDir)) {
411
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
412
+ const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
413
+ .filter(e => e.isDirectory()).map(e => e.name)
414
+ .filter(isDirInMilestone);
415
+ for (const dir of phaseDirs) {
416
+ const files = fs.readdirSync(path.join(phasesDir, dir));
417
+ totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length;
418
+ totalSummaries += files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
419
+ }
420
+ }
421
+
422
+ const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
423
+ const barWidth = 10;
424
+ const filled = Math.round(percent / 100 * barWidth);
425
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
426
+ const progressStr = `[${bar}] ${percent}%`;
427
+
428
+ let updated = false;
429
+ const _totalPlans = totalPlans;
430
+ const _totalSummaries = totalSummaries;
431
+
432
+ readModifyWriteStateMd(statePath, (content) => {
433
+ // Try **Progress:** bold format first, then plain Progress: format
434
+ const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
435
+ const plainProgressPattern = /^(Progress:\s*).*/im;
436
+ if (boldProgressPattern.test(content)) {
437
+ updated = true;
438
+ return content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
439
+ } else if (plainProgressPattern.test(content)) {
440
+ updated = true;
441
+ return content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
442
+ }
443
+ return content;
444
+ }, cwd);
445
+
446
+ if (updated) {
447
+ output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
448
+ } else {
449
+ output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
450
+ }
451
+ }
452
+
453
+ function cmdStateAddDecision(cwd, options, raw) {
454
+ const statePath = planningPaths(cwd).state;
455
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
456
+
457
+ const { phase, summary, summary_file, rationale, rationale_file } = options;
458
+ let summaryText = null;
459
+ let rationaleText = '';
460
+
461
+ try {
462
+ summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
463
+ rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale');
464
+ } catch (err) {
465
+ output({ added: false, reason: err.message }, raw, 'false');
466
+ return;
467
+ }
468
+
469
+ if (!summaryText) { output({ error: 'summary required' }, raw); return; }
470
+
471
+ const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
472
+ let added = false;
473
+
474
+ readModifyWriteStateMd(statePath, (content) => {
475
+ // Find Decisions section (various heading patterns)
476
+ const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
477
+ const match = content.match(sectionPattern);
478
+
479
+ if (match) {
480
+ let sectionBody = match[2];
481
+ // Remove placeholders
482
+ sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
483
+ sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
484
+ added = true;
485
+ return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
486
+ }
487
+ return content;
488
+ }, cwd);
489
+
490
+ if (added) {
491
+ output({ added: true, decision: entry }, raw, 'true');
492
+ } else {
493
+ output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false');
494
+ }
495
+ }
496
+
497
+ function cmdStateAddBlocker(cwd, text, raw) {
498
+ const statePath = planningPaths(cwd).state;
499
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
500
+ const blockerOptions = typeof text === 'object' && text !== null ? text : { text };
501
+ let blockerText = null;
502
+
503
+ try {
504
+ blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
505
+ } catch (err) {
506
+ output({ added: false, reason: err.message }, raw, 'false');
507
+ return;
508
+ }
509
+
510
+ if (!blockerText) { output({ error: 'text required' }, raw); return; }
511
+
512
+ const entry = `- ${blockerText}`;
513
+ let added = false;
514
+
515
+ readModifyWriteStateMd(statePath, (content) => {
516
+ const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
517
+ const match = content.match(sectionPattern);
518
+
519
+ if (match) {
520
+ let sectionBody = match[2];
521
+ sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
522
+ sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
523
+ added = true;
524
+ return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
525
+ }
526
+ return content;
527
+ }, cwd);
528
+
529
+ if (added) {
530
+ output({ added: true, blocker: blockerText }, raw, 'true');
531
+ } else {
532
+ output({ added: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
533
+ }
534
+ }
535
+
536
+ function cmdStateResolveBlocker(cwd, text, raw) {
537
+ const statePath = planningPaths(cwd).state;
538
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
539
+ if (!text) { output({ error: 'text required' }, raw); return; }
540
+
541
+ let resolved = false;
542
+
543
+ readModifyWriteStateMd(statePath, (content) => {
544
+ const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
545
+ const match = content.match(sectionPattern);
546
+
547
+ if (match) {
548
+ const sectionBody = match[2];
549
+ const lines = sectionBody.split('\n');
550
+ const filtered = lines.filter(line => {
551
+ if (!line.startsWith('- ')) return true;
552
+ return !line.toLowerCase().includes(text.toLowerCase());
553
+ });
554
+
555
+ let newBody = filtered.join('\n');
556
+ // If section is now empty, add placeholder
557
+ if (!newBody.trim() || !newBody.includes('- ')) {
558
+ newBody = 'None\n';
559
+ }
560
+
561
+ resolved = true;
562
+ return content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
563
+ }
564
+ return content;
565
+ }, cwd);
566
+
567
+ if (resolved) {
568
+ output({ resolved: true, blocker: text }, raw, 'true');
569
+ } else {
570
+ output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
571
+ }
572
+ }
573
+
574
+ function cmdStateRecordSession(cwd, options, raw) {
575
+ const statePath = planningPaths(cwd).state;
576
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
577
+
578
+ const now = new Date().toISOString();
579
+ const updated = [];
580
+
581
+ readModifyWriteStateMd(statePath, (content) => {
582
+ // Update Last session / Last Date
583
+ let result = stateReplaceField(content, 'Last session', now);
584
+ if (result) { content = result; updated.push('Last session'); }
585
+ result = stateReplaceField(content, 'Last Date', now);
586
+ if (result) { content = result; updated.push('Last Date'); }
587
+
588
+ // Update Stopped at
589
+ if (options.stopped_at) {
590
+ result = stateReplaceField(content, 'Stopped At', options.stopped_at);
591
+ if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at);
592
+ if (result) { content = result; updated.push('Stopped At'); }
593
+ }
594
+
595
+ // Update Resume file
596
+ const resumeFile = options.resume_file || 'None';
597
+ result = stateReplaceField(content, 'Resume File', resumeFile);
598
+ if (!result) result = stateReplaceField(content, 'Resume file', resumeFile);
599
+ if (result) { content = result; updated.push('Resume File'); }
600
+
601
+ return content;
602
+ }, cwd);
603
+
604
+ if (updated.length > 0) {
605
+ output({ recorded: true, updated }, raw, 'true');
606
+ } else {
607
+ output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
608
+ }
609
+ }
610
+
611
+ function cmdStateSnapshot(cwd, raw) {
612
+ const statePath = planningPaths(cwd).state;
613
+
614
+ if (!fs.existsSync(statePath)) {
615
+ output({ error: 'STATE.md not found' }, raw);
616
+ return;
617
+ }
618
+
619
+ const content = fs.readFileSync(statePath, 'utf-8');
620
+
621
+ // Extract basic fields
622
+ const currentPhase = stateExtractField(content, 'Current Phase');
623
+ const currentPhaseName = stateExtractField(content, 'Current Phase Name');
624
+ const totalPhasesRaw = stateExtractField(content, 'Total Phases');
625
+ const currentPlan = stateExtractField(content, 'Current Plan');
626
+ const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase');
627
+ const status = stateExtractField(content, 'Status');
628
+ const progressRaw = stateExtractField(content, 'Progress');
629
+ const lastActivity = stateExtractField(content, 'Last Activity');
630
+ const lastActivityDesc = stateExtractField(content, 'Last Activity Description');
631
+ const pausedAt = stateExtractField(content, 'Paused At');
632
+
633
+ // Parse numeric fields
634
+ const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
635
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
636
+ const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
637
+
638
+ // Extract decisions table
639
+ const decisions = [];
640
+ const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
641
+ if (decisionsMatch) {
642
+ const tableBody = decisionsMatch[1];
643
+ const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
644
+ for (const row of rows) {
645
+ const cells = row.split('|').map(c => c.trim()).filter(Boolean);
646
+ if (cells.length >= 3) {
647
+ decisions.push({
648
+ phase: cells[0],
649
+ summary: cells[1],
650
+ rationale: cells[2],
651
+ });
652
+ }
653
+ }
654
+ }
655
+
656
+ // Extract blockers list
657
+ const blockers = [];
658
+ const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
659
+ if (blockersMatch) {
660
+ const blockersSection = blockersMatch[1];
661
+ const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
662
+ for (const item of items) {
663
+ blockers.push(item.replace(/^-\s+/, '').trim());
664
+ }
665
+ }
666
+
667
+ // Extract session info
668
+ const session = {
669
+ last_date: null,
670
+ stopped_at: null,
671
+ resume_file: null,
672
+ };
673
+
674
+ const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
675
+ if (sessionMatch) {
676
+ const sessionSection = sessionMatch[1];
677
+ const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
678
+ || sessionSection.match(/^Last Date:\s*(.+)/im);
679
+ const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i)
680
+ || sessionSection.match(/^Stopped At:\s*(.+)/im);
681
+ const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i)
682
+ || sessionSection.match(/^Resume File:\s*(.+)/im);
683
+
684
+ if (lastDateMatch) session.last_date = lastDateMatch[1].trim();
685
+ if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim();
686
+ if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim();
687
+ }
688
+
689
+ const result = {
690
+ current_phase: currentPhase,
691
+ current_phase_name: currentPhaseName,
692
+ total_phases: totalPhases,
693
+ current_plan: currentPlan,
694
+ total_plans_in_phase: totalPlansInPhase,
695
+ status,
696
+ progress_percent: progressPercent,
697
+ last_activity: lastActivity,
698
+ last_activity_desc: lastActivityDesc,
699
+ decisions,
700
+ blockers,
701
+ paused_at: pausedAt,
702
+ session,
703
+ };
704
+
705
+ output(result, raw);
706
+ }
707
+
708
+ // ─── State Frontmatter Sync ──────────────────────────────────────────────────
709
+
710
+ /**
711
+ * Extract machine-readable fields from STATE.md markdown body and build
712
+ * a YAML frontmatter object. Allows hooks and scripts to read state
713
+ * reliably via `state json` instead of fragile regex parsing.
714
+ */
715
+ function buildStateFrontmatter(bodyContent, cwd) {
716
+ const currentPhase = stateExtractField(bodyContent, 'Current Phase');
717
+ const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name');
718
+ const currentPlan = stateExtractField(bodyContent, 'Current Plan');
719
+ const totalPhasesRaw = stateExtractField(bodyContent, 'Total Phases');
720
+ const totalPlansRaw = stateExtractField(bodyContent, 'Total Plans in Phase');
721
+ const status = stateExtractField(bodyContent, 'Status');
722
+ const progressRaw = stateExtractField(bodyContent, 'Progress');
723
+ const lastActivity = stateExtractField(bodyContent, 'Last Activity');
724
+ // Bug #2444: scope Stopped At extraction to the ## Session section so that
725
+ // historical "Stopped at:" prose elsewhere in the body (e.g. in a
726
+ // Session Continuity Archive section) never overwrites the current value.
727
+ // Fall back to full-body search only when no ## Session section exists.
728
+ const sessionSectionMatch = bodyContent.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
729
+ const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent;
730
+ const stoppedAt = stateExtractField(sessionBodyScope, 'Stopped At') || stateExtractField(sessionBodyScope, 'Stopped at');
731
+ const pausedAt = stateExtractField(bodyContent, 'Paused At');
732
+
733
+ let milestone = null;
734
+ let milestoneName = null;
735
+ if (cwd) {
736
+ try {
737
+ const info = getMilestoneInfo(cwd);
738
+ milestone = info.version;
739
+ milestoneName = info.name;
740
+ } catch { /* intentionally empty */ }
741
+ }
742
+
743
+ let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
744
+ let completedPhases = null;
745
+ let totalPlans = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
746
+ let completedPlans = null;
747
+
748
+ if (cwd) {
749
+ try {
750
+ const phasesDir = planningPaths(cwd).phases;
751
+ if (fs.existsSync(phasesDir)) {
752
+ // Use cached disk scan when available — avoids N+1 readdirSync calls
753
+ // on repeated buildStateFrontmatter invocations within the same process (#1967)
754
+ let cached = _diskScanCache.get(cwd);
755
+ if (!cached) {
756
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
757
+ const allMatchingDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
758
+ .filter(e => e.isDirectory()).map(e => e.name)
759
+ .filter(isDirInMilestone);
760
+
761
+ // Bug #2445: when stale phase dirs from a prior milestone remain in
762
+ // .planning/phases/ alongside new dirs with the same phase number,
763
+ // de-duplicate by normalized phase number keeping the most recently
764
+ // modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
765
+ const seenPhaseNums = new Map(); // normalizedNum -> dirName
766
+ for (const dir of allMatchingDirs) {
767
+ const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
768
+ const key = m ? m[1].toLowerCase() : dir;
769
+ if (!seenPhaseNums.has(key)) {
770
+ seenPhaseNums.set(key, dir);
771
+ } else {
772
+ // Keep the dir that is newer on disk (more likely current milestone)
773
+ try {
774
+ const existing = path.join(phasesDir, seenPhaseNums.get(key));
775
+ const candidate = path.join(phasesDir, dir);
776
+ if (fs.statSync(candidate).mtimeMs > fs.statSync(existing).mtimeMs) {
777
+ seenPhaseNums.set(key, dir);
778
+ }
779
+ } catch { /* keep existing on stat error */ }
780
+ }
781
+ }
782
+ const phaseDirs = [...seenPhaseNums.values()];
783
+
784
+ let diskTotalPlans = 0;
785
+ let diskTotalSummaries = 0;
786
+ let diskCompletedPhases = 0;
787
+
788
+ for (const dir of phaseDirs) {
789
+ const files = fs.readdirSync(path.join(phasesDir, dir));
790
+ const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
791
+ const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
792
+ diskTotalPlans += plans;
793
+ diskTotalSummaries += summaries;
794
+ if (plans > 0 && summaries >= plans) diskCompletedPhases++;
795
+ }
796
+ cached = {
797
+ totalPhases: isDirInMilestone.phaseCount > 0
798
+ ? Math.max(phaseDirs.length, isDirInMilestone.phaseCount)
799
+ : phaseDirs.length,
800
+ completedPhases: diskCompletedPhases,
801
+ totalPlans: diskTotalPlans,
802
+ completedPlans: diskTotalSummaries,
803
+ };
804
+ _diskScanCache.set(cwd, cached);
805
+ }
806
+ totalPhases = cached.totalPhases;
807
+ completedPhases = cached.completedPhases;
808
+ totalPlans = cached.totalPlans;
809
+ completedPlans = cached.completedPlans;
810
+ }
811
+ } catch { /* intentionally empty */ }
812
+ }
813
+
814
+ // Derive percent from disk counts when available (ground truth).
815
+ // Only falls back to the body Progress: field when no plan files exist on disk
816
+ // (phases directory empty or absent), which means disk has no authoritative data.
817
+ // This prevents a stale body "0%" from overriding the real 100% completion state.
818
+ let progressPercent = null;
819
+ if (totalPlans !== null && totalPlans > 0 && completedPlans !== null) {
820
+ progressPercent = Math.min(100, Math.round(completedPlans / totalPlans * 100));
821
+ } else if (progressRaw) {
822
+ const pctMatch = progressRaw.match(/(\d+)%/);
823
+ if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
824
+ }
825
+
826
+ // Normalize status to one of: planning, discussing, executing, verifying, paused, completed, unknown
827
+ let normalizedStatus = status || 'unknown';
828
+ const statusLower = (status || '').toLowerCase();
829
+ if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
830
+ normalizedStatus = 'paused';
831
+ } else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
832
+ normalizedStatus = 'executing';
833
+ } else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
834
+ normalizedStatus = 'planning';
835
+ } else if (statusLower.includes('discussing')) {
836
+ normalizedStatus = 'discussing';
837
+ } else if (statusLower.includes('verif')) {
838
+ normalizedStatus = 'verifying';
839
+ } else if (statusLower.includes('complete') || statusLower.includes('done')) {
840
+ normalizedStatus = 'completed';
841
+ } else if (statusLower.includes('ready to execute')) {
842
+ normalizedStatus = 'executing';
843
+ }
844
+
845
+ const fm = { gsd_state_version: '1.0' };
846
+
847
+ if (milestone) fm.milestone = milestone;
848
+ if (milestoneName) fm.milestone_name = milestoneName;
849
+ if (currentPhase) fm.current_phase = currentPhase;
850
+ if (currentPhaseName) fm.current_phase_name = currentPhaseName;
851
+ if (currentPlan) fm.current_plan = currentPlan;
852
+ fm.status = normalizedStatus;
853
+ if (stoppedAt) fm.stopped_at = stoppedAt;
854
+ if (pausedAt) fm.paused_at = pausedAt;
855
+ fm.last_updated = new Date().toISOString();
856
+ if (lastActivity) fm.last_activity = lastActivity;
857
+
858
+ const progress = {};
859
+ if (totalPhases !== null) progress.total_phases = totalPhases;
860
+ if (completedPhases !== null) progress.completed_phases = completedPhases;
861
+ if (totalPlans !== null) progress.total_plans = totalPlans;
862
+ if (completedPlans !== null) progress.completed_plans = completedPlans;
863
+ if (progressPercent !== null) progress.percent = progressPercent;
864
+ if (Object.keys(progress).length > 0) fm.progress = progress;
865
+
866
+ return fm;
867
+ }
868
+
869
+ function stripFrontmatter(content) {
870
+ // Strip ALL frontmatter blocks at the start of the file.
871
+ // Handles CRLF line endings and multiple stacked blocks (corruption recovery).
872
+ // Greedy: keeps stripping ---...--- blocks separated by optional whitespace.
873
+ let result = content;
874
+ // eslint-disable-next-line no-constant-condition
875
+ while (true) {
876
+ const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
877
+ if (stripped === result) break;
878
+ result = stripped;
879
+ }
880
+ return result;
881
+ }
882
+
883
+ function syncStateFrontmatter(content, cwd) {
884
+ // Read existing frontmatter BEFORE stripping — it may contain values
885
+ // that the body no longer has (e.g., Status field removed by an agent).
886
+ const existingFm = extractFrontmatter(content);
887
+ const body = stripFrontmatter(content);
888
+ const derivedFm = buildStateFrontmatter(body, cwd);
889
+
890
+ // Preserve existing frontmatter status when body-derived status is 'unknown'.
891
+ // This prevents a missing Status: field in the body from overwriting a
892
+ // previously valid status (e.g., 'executing' → 'unknown').
893
+ if (derivedFm.status === 'unknown' && existingFm.status && existingFm.status !== 'unknown') {
894
+ derivedFm.status = existingFm.status;
895
+ }
896
+
897
+ const yamlStr = reconstructFrontmatter(derivedFm);
898
+ return `---\n${yamlStr}\n---\n\n${body}`;
899
+ }
900
+
901
+ /**
902
+ * Acquire a lockfile for STATE.md operations.
903
+ * Returns the lock path for later release.
904
+ */
905
+ function acquireStateLock(statePath) {
906
+ const lockPath = statePath + '.lock';
907
+ const maxRetries = 10;
908
+ const retryDelay = 200; // ms
909
+
910
+ for (let i = 0; i < maxRetries; i++) {
911
+ try {
912
+ const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
913
+ fs.writeSync(fd, String(process.pid));
914
+ fs.closeSync(fd);
915
+ // Register for exit-time cleanup so process.exit(1) inside a locked region
916
+ // cannot leave a stale lock file (#1916).
917
+ _heldStateLocks.add(lockPath);
918
+ return lockPath;
919
+ } catch (err) {
920
+ if (err.code === 'EEXIST') {
921
+ try {
922
+ const stat = fs.statSync(lockPath);
923
+ if (Date.now() - stat.mtimeMs > 10000) {
924
+ fs.unlinkSync(lockPath);
925
+ continue;
926
+ }
927
+ } catch { /* lock was released between check — retry */ }
928
+
929
+ if (i === maxRetries - 1) {
930
+ try { fs.unlinkSync(lockPath); } catch {}
931
+ return lockPath;
932
+ }
933
+ const jitter = Math.floor(Math.random() * 50);
934
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelay + jitter);
935
+ continue;
936
+ }
937
+ return lockPath; // non-EEXIST error — proceed without lock
938
+ }
939
+ }
940
+ return statePath + '.lock';
941
+ }
942
+
943
+ function releaseStateLock(lockPath) {
944
+ _heldStateLocks.delete(lockPath);
945
+ try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
946
+ }
947
+
948
+ /**
949
+ * Write STATE.md with synchronized YAML frontmatter.
950
+ * All STATE.md writes should use this instead of raw writeFileSync.
951
+ * Uses a simple lockfile to prevent parallel agents from overwriting
952
+ * each other's changes (race condition with read-modify-write cycle).
953
+ */
954
+ function writeStateMd(statePath, content, cwd) {
955
+ // Invalidate disk scan cache before computing new frontmatter — the write
956
+ // may create new PLAN/SUMMARY files that buildStateFrontmatter must see.
957
+ // Safe for any calling pattern, not just short-lived CLI processes (#1967).
958
+ if (cwd) _diskScanCache.delete(cwd);
959
+ const synced = syncStateFrontmatter(content, cwd);
960
+ const lockPath = acquireStateLock(statePath);
961
+ try {
962
+ atomicWriteFileSync(statePath, normalizeMd(synced), 'utf-8');
963
+ } finally {
964
+ releaseStateLock(lockPath);
965
+ }
966
+ }
967
+
968
+ /**
969
+ * Atomic read-modify-write for STATE.md.
970
+ * Holds the lock across the entire read -> transform -> write cycle,
971
+ * preventing the lost-update problem where two agents read the same
972
+ * content and the second write clobbers the first.
973
+ */
974
+ function readModifyWriteStateMd(statePath, transformFn, cwd) {
975
+ const lockPath = acquireStateLock(statePath);
976
+ try {
977
+ const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
978
+ const modified = transformFn(content);
979
+ const synced = syncStateFrontmatter(modified, cwd);
980
+ atomicWriteFileSync(statePath, normalizeMd(synced), 'utf-8');
981
+ } finally {
982
+ releaseStateLock(lockPath);
983
+ }
984
+ }
985
+
986
+ function cmdStateJson(cwd, raw) {
987
+ const statePath = planningPaths(cwd).state;
988
+ if (!fs.existsSync(statePath)) {
989
+ output({ error: 'STATE.md not found' }, raw, 'STATE.md not found');
990
+ return;
991
+ }
992
+
993
+ const content = fs.readFileSync(statePath, 'utf-8');
994
+ const existingFm = extractFrontmatter(content);
995
+ const body = stripFrontmatter(content);
996
+
997
+ // Always rebuild from body + disk so progress counters reflect current state.
998
+ // Returning cached frontmatter directly causes stale percent/completed_plans
999
+ // when SUMMARY files were added after the last STATE.md write (#1589).
1000
+ const built = buildStateFrontmatter(body, cwd);
1001
+
1002
+ // Preserve frontmatter-only fields that cannot be recovered from the body.
1003
+ if (existingFm && existingFm.stopped_at && !built.stopped_at) {
1004
+ built.stopped_at = existingFm.stopped_at;
1005
+ }
1006
+ if (existingFm && existingFm.paused_at && !built.paused_at) {
1007
+ built.paused_at = existingFm.paused_at;
1008
+ }
1009
+ // Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
1010
+ if (built.status === 'unknown' && existingFm && existingFm.status && existingFm.status !== 'unknown') {
1011
+ built.status = existingFm.status;
1012
+ }
1013
+
1014
+ output(built, raw, JSON.stringify(built, null, 2));
1015
+ }
1016
+
1017
+ /**
1018
+ * Update STATE.md when a new phase begins execution.
1019
+ * Updates body text fields (Current focus, Status, Last Activity, Current Position)
1020
+ * and synchronizes frontmatter via writeStateMd.
1021
+ * Fixes: #1102 (plan counts), #1103 (status/last_activity), #1104 (body text).
1022
+ */
1023
+ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1024
+ const statePath = planningPaths(cwd).state;
1025
+ if (!fs.existsSync(statePath)) {
1026
+ output({ error: 'STATE.md not found' }, raw);
1027
+ return;
1028
+ }
1029
+
1030
+ const today = new Date().toISOString().split('T')[0];
1031
+ const updated = [];
1032
+
1033
+ readModifyWriteStateMd(statePath, (content) => {
1034
+ // Update Status field
1035
+ const statusValue = `Executing Phase ${phaseNumber}`;
1036
+ let result = stateReplaceField(content, 'Status', statusValue);
1037
+ if (result) { content = result; updated.push('Status'); }
1038
+
1039
+ // Update Last Activity
1040
+ result = stateReplaceField(content, 'Last Activity', today);
1041
+ if (result) { content = result; updated.push('Last Activity'); }
1042
+
1043
+ // Update Last Activity Description if it exists
1044
+ const activityDesc = `Phase ${phaseNumber} execution started`;
1045
+ result = stateReplaceField(content, 'Last Activity Description', activityDesc);
1046
+ if (result) { content = result; updated.push('Last Activity Description'); }
1047
+
1048
+ // Update Current Phase
1049
+ result = stateReplaceField(content, 'Current Phase', String(phaseNumber));
1050
+ if (result) { content = result; updated.push('Current Phase'); }
1051
+
1052
+ // Update Current Phase Name
1053
+ if (phaseName) {
1054
+ result = stateReplaceField(content, 'Current Phase Name', phaseName);
1055
+ if (result) { content = result; updated.push('Current Phase Name'); }
1056
+ }
1057
+
1058
+ // Update Current Plan to 1 (starting from the first plan)
1059
+ result = stateReplaceField(content, 'Current Plan', '1');
1060
+ if (result) { content = result; updated.push('Current Plan'); }
1061
+
1062
+ // Update Total Plans in Phase
1063
+ if (planCount) {
1064
+ result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
1065
+ if (result) { content = result; updated.push('Total Plans in Phase'); }
1066
+ }
1067
+
1068
+ // Update **Current focus:** body text line (#1104)
1069
+ const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
1070
+ const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
1071
+ if (focusPattern.test(content)) {
1072
+ content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
1073
+ updated.push('Current focus');
1074
+ }
1075
+
1076
+ // Update ## Current Position section (#1104, #1365)
1077
+ // Update individual fields within Current Position instead of replacing the
1078
+ // entire section, so that Status, Last activity, and Progress are preserved.
1079
+ const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1080
+ const positionMatch = content.match(positionPattern);
1081
+ if (positionMatch) {
1082
+ const header = positionMatch[1];
1083
+ let posBody = positionMatch[2];
1084
+
1085
+ // Update or insert Phase line
1086
+ const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
1087
+ if (/^Phase:/m.test(posBody)) {
1088
+ posBody = posBody.replace(/^Phase:.*$/m, newPhase);
1089
+ } else {
1090
+ posBody = newPhase + '\n' + posBody;
1091
+ }
1092
+
1093
+ // Update or insert Plan line
1094
+ const newPlan = `Plan: 1 of ${planCount || '?'}`;
1095
+ if (/^Plan:/m.test(posBody)) {
1096
+ posBody = posBody.replace(/^Plan:.*$/m, newPlan);
1097
+ } else {
1098
+ posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
1099
+ }
1100
+
1101
+ // Update Status line if present
1102
+ const newStatus = `Status: Executing Phase ${phaseNumber}`;
1103
+ if (/^Status:/m.test(posBody)) {
1104
+ posBody = posBody.replace(/^Status:.*$/m, newStatus);
1105
+ }
1106
+
1107
+ // Update Last activity line if present
1108
+ const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
1109
+ if (/^Last activity:/im.test(posBody)) {
1110
+ posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
1111
+ }
1112
+
1113
+ content = content.replace(positionPattern, `${header}${posBody}`);
1114
+ updated.push('Current Position');
1115
+ }
1116
+
1117
+ return content;
1118
+ }, cwd);
1119
+
1120
+ output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
1121
+ }
1122
+
1123
+ /**
1124
+ * Write a WAITING.json signal file when GSD hits a decision point.
1125
+ * External watchers (fswatch, polling, orchestrators) can detect this.
1126
+ * File is written to .planning/WAITING.json (or .gsd/WAITING.json if .gsd exists).
1127
+ * Fixes #1034.
1128
+ */
1129
+ function cmdSignalWaiting(cwd, type, question, options, phase, raw) {
1130
+ const gsdDir = fs.existsSync(path.join(cwd, '.gsd')) ? path.join(cwd, '.gsd') : planningDir(cwd);
1131
+ const waitingPath = path.join(gsdDir, 'WAITING.json');
1132
+
1133
+ const signal = {
1134
+ status: 'waiting',
1135
+ type: type || 'decision_point',
1136
+ question: question || null,
1137
+ options: options ? options.split('|').map(o => o.trim()) : [],
1138
+ since: new Date().toISOString(),
1139
+ phase: phase || null,
1140
+ };
1141
+
1142
+ try {
1143
+ fs.mkdirSync(gsdDir, { recursive: true });
1144
+ fs.writeFileSync(waitingPath, JSON.stringify(signal, null, 2), 'utf-8');
1145
+ output({ signaled: true, path: waitingPath }, raw, 'true');
1146
+ } catch (e) {
1147
+ output({ signaled: false, error: e.message }, raw, 'false');
1148
+ }
1149
+ }
1150
+
1151
+ /**
1152
+ * Remove the WAITING.json signal file when user answers and agent resumes.
1153
+ */
1154
+ function cmdSignalResume(cwd, raw) {
1155
+ const paths = [
1156
+ path.join(cwd, '.gsd', 'WAITING.json'),
1157
+ path.join(planningDir(cwd), 'WAITING.json'),
1158
+ ];
1159
+
1160
+ let removed = false;
1161
+ for (const p of paths) {
1162
+ if (fs.existsSync(p)) {
1163
+ try { fs.unlinkSync(p); removed = true; } catch {}
1164
+ }
1165
+ }
1166
+
1167
+ output({ resumed: true, removed }, raw, removed ? 'true' : 'false');
1168
+ }
1169
+
1170
+ // ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
1171
+
1172
+ /**
1173
+ * Update the ## Performance Metrics section in STATE.md content.
1174
+ * Increments Velocity totals and upserts a By Phase table row.
1175
+ * Returns modified content string.
1176
+ */
1177
+ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) {
1178
+ // Update Velocity: Total plans completed
1179
+ const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/);
1180
+ const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0;
1181
+ const newTotal = prevTotal + summaryCount;
1182
+ content = content.replace(
1183
+ /Total plans completed:\s*(\d+|\[N\])/,
1184
+ `Total plans completed: ${newTotal}`
1185
+ );
1186
+
1187
+ // Update By Phase table — upsert row for this phase
1188
+ 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;
1189
+ const byPhaseMatch = content.match(byPhaseTablePattern);
1190
+ if (byPhaseMatch) {
1191
+ let tableBody = byPhaseMatch[2].trim();
1192
+ const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm');
1193
+ const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
1194
+
1195
+ if (phaseRowPattern.test(tableBody)) {
1196
+ // Update existing row
1197
+ tableBody = tableBody.replace(phaseRowPattern, newRow);
1198
+ } else {
1199
+ // Remove placeholder row and add new row
1200
+ tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
1201
+ tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
1202
+ }
1203
+
1204
+ content = content.replace(byPhaseTablePattern, `$1${tableBody}\n`);
1205
+ }
1206
+
1207
+ return content;
1208
+ }
1209
+
1210
+ /**
1211
+ * Gate 3a: Record state after plan-phase completes.
1212
+ * Updates Status to "Ready to execute", Total Plans, Last Activity.
1213
+ */
1214
+ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
1215
+ const statePath = planningPaths(cwd).state;
1216
+ if (!fs.existsSync(statePath)) {
1217
+ output({ error: 'STATE.md not found' }, raw);
1218
+ return;
1219
+ }
1220
+
1221
+ let content = fs.readFileSync(statePath, 'utf-8');
1222
+ const today = new Date().toISOString().split('T')[0];
1223
+ const updated = [];
1224
+
1225
+ // Update Status
1226
+ let result = stateReplaceField(content, 'Status', 'Ready to execute');
1227
+ if (result) { content = result; updated.push('Status'); }
1228
+
1229
+ // Update Total Plans in Phase
1230
+ if (planCount !== null && planCount !== undefined) {
1231
+ result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
1232
+ if (result) { content = result; updated.push('Total Plans in Phase'); }
1233
+ }
1234
+
1235
+ // Update Last Activity
1236
+ result = stateReplaceField(content, 'Last Activity', today);
1237
+ if (result) { content = result; updated.push('Last Activity'); }
1238
+
1239
+ // Update Last Activity Description
1240
+ result = stateReplaceField(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
1241
+ if (result) { content = result; updated.push('Last Activity Description'); }
1242
+
1243
+ // Update Current Position section
1244
+ content = updateCurrentPositionFields(content, {
1245
+ status: 'Ready to execute',
1246
+ lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
1247
+ });
1248
+
1249
+ if (updated.length > 0) {
1250
+ writeStateMd(statePath, content, cwd);
1251
+ }
1252
+
1253
+ output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
1254
+ }
1255
+
1256
+ /**
1257
+ * Gate 1: Validate STATE.md against filesystem.
1258
+ * Returns { valid, warnings, drift } JSON.
1259
+ */
1260
+ function cmdStateValidate(cwd, raw) {
1261
+ const statePath = planningPaths(cwd).state;
1262
+ if (!fs.existsSync(statePath)) {
1263
+ output({ error: 'STATE.md not found' }, raw);
1264
+ return;
1265
+ }
1266
+
1267
+ const content = fs.readFileSync(statePath, 'utf-8');
1268
+ const warnings = [];
1269
+ const drift = {};
1270
+
1271
+ const status = stateExtractField(content, 'Status') || '';
1272
+ const currentPhase = stateExtractField(content, 'Current Phase');
1273
+ const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase');
1274
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1275
+
1276
+ const phasesDir = planningPaths(cwd).phases;
1277
+
1278
+ // Scan disk for current phase
1279
+ if (currentPhase && fs.existsSync(phasesDir)) {
1280
+ const normalized = currentPhase.replace(/\s+of\s+\d+.*/, '').trim();
1281
+ try {
1282
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1283
+ const phaseDir = entries.find(e => e.isDirectory() && e.name.startsWith(normalized.replace(/^0+/, '').padStart(2, '0')));
1284
+ if (phaseDir) {
1285
+ const phaseDirPath = path.join(phasesDir, phaseDir.name);
1286
+ const files = fs.readdirSync(phaseDirPath);
1287
+ const diskPlans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
1288
+ const diskSummaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
1289
+
1290
+ // Check plan count mismatch
1291
+ if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
1292
+ warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`);
1293
+ drift.plan_count = { state: totalPlansInPhase, disk: diskPlans };
1294
+ }
1295
+
1296
+ // Check for VERIFICATION.md
1297
+ const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
1298
+ for (const vf of verificationFiles) {
1299
+ try {
1300
+ const vContent = fs.readFileSync(path.join(phaseDirPath, vf), 'utf-8');
1301
+ if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
1302
+ warnings.push(`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`);
1303
+ drift.verification_status = { state_status: status, verification: 'passed' };
1304
+ }
1305
+ } catch { /* intentionally empty */ }
1306
+ }
1307
+
1308
+ // Check if all plans have summaries but status still says executing
1309
+ if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
1310
+ // Only warn if no verification exists (if verification passed, the above warning covers it)
1311
+ if (verificationFiles.length === 0) {
1312
+ warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`);
1313
+ }
1314
+ }
1315
+ }
1316
+ } catch { /* intentionally empty */ }
1317
+ }
1318
+
1319
+ const valid = warnings.length === 0;
1320
+ output({ valid, warnings, drift }, raw);
1321
+ }
1322
+
1323
+ /**
1324
+ * Gate 2: Sync STATE.md from filesystem ground truth.
1325
+ * Scans phase dirs, reconstructs counters, progress, metrics.
1326
+ * Supports --verify for dry-run mode.
1327
+ */
1328
+ function cmdStateSync(cwd, options, raw) {
1329
+ const statePath = planningPaths(cwd).state;
1330
+ if (!fs.existsSync(statePath)) {
1331
+ output({ error: 'STATE.md not found' }, raw);
1332
+ return;
1333
+ }
1334
+
1335
+ const verify = options && options.verify;
1336
+ const content = fs.readFileSync(statePath, 'utf-8');
1337
+ const changes = [];
1338
+ let modified = content;
1339
+ const today = new Date().toISOString().split('T')[0];
1340
+
1341
+ const phasesDir = planningPaths(cwd).phases;
1342
+ if (!fs.existsSync(phasesDir)) {
1343
+ output({ synced: true, changes: [], dry_run: !!verify }, raw);
1344
+ return;
1345
+ }
1346
+
1347
+ // Scan all phases
1348
+ let entries;
1349
+ try {
1350
+ entries = fs.readdirSync(phasesDir, { withFileTypes: true })
1351
+ .filter(e => e.isDirectory())
1352
+ .map(e => e.name)
1353
+ .sort();
1354
+ } catch {
1355
+ output({ synced: true, changes: [], dry_run: !!verify }, raw);
1356
+ return;
1357
+ }
1358
+
1359
+ let totalDiskPlans = 0;
1360
+ let totalDiskSummaries = 0;
1361
+ let highestIncompletePhase = null;
1362
+ let highestIncompletePhaseNum = null;
1363
+ let highestIncompletePhaseplanCount = 0;
1364
+ let highestIncompletePhaseSummaryCount = 0;
1365
+
1366
+ for (const dir of entries) {
1367
+ const dirPath = path.join(phasesDir, dir);
1368
+ const files = fs.readdirSync(dirPath);
1369
+ const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
1370
+ const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
1371
+ totalDiskPlans += plans;
1372
+ totalDiskSummaries += summaries;
1373
+
1374
+ // Track the highest phase with incomplete plans (or any plans)
1375
+ const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
1376
+ if (phaseMatch && plans > 0) {
1377
+ if (summaries < plans) {
1378
+ // Incomplete phase — this is likely the current one
1379
+ highestIncompletePhase = dir;
1380
+ highestIncompletePhaseNum = phaseMatch[1];
1381
+ highestIncompletePhaseplanCount = plans;
1382
+ highestIncompletePhaseSummaryCount = summaries;
1383
+ } else if (!highestIncompletePhase) {
1384
+ // All complete, track as potential current
1385
+ highestIncompletePhase = dir;
1386
+ highestIncompletePhaseNum = phaseMatch[1];
1387
+ highestIncompletePhaseplanCount = plans;
1388
+ highestIncompletePhaseSummaryCount = summaries;
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ // Sync Total Plans in Phase
1394
+ if (highestIncompletePhase) {
1395
+ const currentPlansField = stateExtractField(modified, 'Total Plans in Phase');
1396
+ if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
1397
+ changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
1398
+ const result = stateReplaceField(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
1399
+ if (result) modified = result;
1400
+ }
1401
+ }
1402
+
1403
+ // Sync Progress
1404
+ const percent = totalDiskPlans > 0 ? Math.min(100, Math.round(totalDiskSummaries / totalDiskPlans * 100)) : 0;
1405
+ const currentProgress = stateExtractField(modified, 'Progress');
1406
+ if (currentProgress) {
1407
+ const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
1408
+ if (currentPercent !== percent) {
1409
+ const barWidth = 10;
1410
+ const filled = Math.round(percent / 100 * barWidth);
1411
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
1412
+ const progressStr = `[${bar}] ${percent}%`;
1413
+ changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
1414
+ const result = stateReplaceField(modified, 'Progress', progressStr);
1415
+ if (result) modified = result;
1416
+ }
1417
+ }
1418
+
1419
+ // Sync Last Activity
1420
+ const result = stateReplaceField(modified, 'Last Activity', today);
1421
+ if (result) {
1422
+ const oldActivity = stateExtractField(modified, 'Last Activity');
1423
+ if (oldActivity !== today) {
1424
+ changes.push(`Last Activity: ${oldActivity} -> ${today}`);
1425
+ }
1426
+ modified = result;
1427
+ }
1428
+
1429
+ if (verify) {
1430
+ output({ synced: false, changes, dry_run: true }, raw);
1431
+ return;
1432
+ }
1433
+
1434
+ if (changes.length > 0 || modified !== content) {
1435
+ writeStateMd(statePath, modified, cwd);
1436
+ }
1437
+
1438
+ output({ synced: true, changes, dry_run: false }, raw);
1439
+ }
1440
+
1441
+ /**
1442
+ * Prune old entries from STATE.md sections that grow unboundedly (#1970).
1443
+ * Moves decisions, recently-completed summaries, and resolved blockers
1444
+ * older than keepRecent phases to STATE-ARCHIVE.md.
1445
+ *
1446
+ * Options:
1447
+ * keepRecent: number of recent phases to retain (default: 3)
1448
+ * dryRun: if true, return what would be pruned without modifying STATE.md
1449
+ */
1450
+ function cmdStatePrune(cwd, options, raw) {
1451
+ const silent = !!options.silent;
1452
+ const emit = silent ? () => {} : (result, r, v) => output(result, r, v);
1453
+ const statePath = planningPaths(cwd).state;
1454
+ if (!fs.existsSync(statePath)) { emit({ error: 'STATE.md not found' }, raw); return; }
1455
+
1456
+ const keepRecent = parseInt(options.keepRecent, 10) || 3;
1457
+ const dryRun = !!options.dryRun;
1458
+ const currentPhaseRaw = stateExtractField(fs.readFileSync(statePath, 'utf-8'), 'Current Phase');
1459
+ const currentPhase = parseInt(currentPhaseRaw, 10) || 0;
1460
+ const cutoff = currentPhase - keepRecent;
1461
+
1462
+ if (cutoff <= 0) {
1463
+ emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
1464
+ return;
1465
+ }
1466
+
1467
+ const archivePath = path.join(path.dirname(statePath), 'STATE-ARCHIVE.md');
1468
+ const archived = [];
1469
+
1470
+ // Shared pruning logic applied to both dry-run and real passes.
1471
+ // Returns { newContent, archivedSections }.
1472
+ function prunePass(content) {
1473
+ const sections = [];
1474
+
1475
+ // Prune Decisions section: entries like "- [Phase N]: ..."
1476
+ const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1477
+ const decMatch = content.match(decisionPattern);
1478
+ if (decMatch) {
1479
+ const lines = decMatch[2].split('\n');
1480
+ const keep = [];
1481
+ const archive = [];
1482
+ for (const line of lines) {
1483
+ const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
1484
+ if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
1485
+ archive.push(line);
1486
+ } else {
1487
+ keep.push(line);
1488
+ }
1489
+ }
1490
+ if (archive.length > 0) {
1491
+ sections.push({ section: 'Decisions', count: archive.length, lines: archive });
1492
+ content = content.replace(decisionPattern, (_m, header) => `${header}${keep.join('\n')}`);
1493
+ }
1494
+ }
1495
+
1496
+ // Prune Recently Completed section: entries mentioning phase numbers
1497
+ const recentPattern = /(###?\s*Recently Completed\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1498
+ const recMatch = content.match(recentPattern);
1499
+ if (recMatch) {
1500
+ const lines = recMatch[2].split('\n');
1501
+ const keep = [];
1502
+ const archive = [];
1503
+ for (const line of lines) {
1504
+ const phaseMatch = line.match(/Phase\s+(\d+)/i);
1505
+ if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
1506
+ archive.push(line);
1507
+ } else {
1508
+ keep.push(line);
1509
+ }
1510
+ }
1511
+ if (archive.length > 0) {
1512
+ sections.push({ section: 'Recently Completed', count: archive.length, lines: archive });
1513
+ content = content.replace(recentPattern, (_m, header) => `${header}${keep.join('\n')}`);
1514
+ }
1515
+ }
1516
+
1517
+ // Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~
1518
+ // or "[RESOLVED]" prefix) with a phase reference older than cutoff
1519
+ const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1520
+ const blockersMatch = content.match(blockersPattern);
1521
+ if (blockersMatch) {
1522
+ const lines = blockersMatch[2].split('\n');
1523
+ const keep = [];
1524
+ const archive = [];
1525
+ for (const line of lines) {
1526
+ const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
1527
+ const phaseMatch = line.match(/Phase\s+(\d+)/i);
1528
+ if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
1529
+ archive.push(line);
1530
+ } else {
1531
+ keep.push(line);
1532
+ }
1533
+ }
1534
+ if (archive.length > 0) {
1535
+ sections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive });
1536
+ content = content.replace(blockersPattern, (_m, header) => `${header}${keep.join('\n')}`);
1537
+ }
1538
+ }
1539
+
1540
+ // Prune Performance Metrics table rows: keep only rows for phases > cutoff.
1541
+ // Preserves header rows (| Phase | ... and |---|...) and any prose around the table.
1542
+ const metricsPattern = /(###?\s*Performance Metrics\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1543
+ const metricsMatch = content.match(metricsPattern);
1544
+ if (metricsMatch) {
1545
+ const sectionLines = metricsMatch[2].split('\n');
1546
+ const keep = [];
1547
+ const archive = [];
1548
+ for (const line of sectionLines) {
1549
+ // Table data row: starts with | followed by a number (phase)
1550
+ const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
1551
+ if (tableRowMatch) {
1552
+ const rowPhase = parseInt(tableRowMatch[1], 10);
1553
+ if (rowPhase <= cutoff) {
1554
+ archive.push(line);
1555
+ } else {
1556
+ keep.push(line);
1557
+ }
1558
+ } else {
1559
+ // Header row, separator row, or prose — always keep
1560
+ keep.push(line);
1561
+ }
1562
+ }
1563
+ if (archive.length > 0) {
1564
+ sections.push({ section: 'Performance Metrics', count: archive.length, lines: archive });
1565
+ content = content.replace(metricsPattern, (_m, header) => `${header}${keep.join('\n')}`);
1566
+ }
1567
+ }
1568
+
1569
+ return { newContent: content, archivedSections: sections };
1570
+ }
1571
+
1572
+ if (dryRun) {
1573
+ // Dry-run: compute what would be pruned without writing anything
1574
+ const content = fs.readFileSync(statePath, 'utf-8');
1575
+ const result = prunePass(content);
1576
+ const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
1577
+ emit({
1578
+ pruned: false,
1579
+ dry_run: true,
1580
+ cutoff_phase: cutoff,
1581
+ keep_recent: keepRecent,
1582
+ sections: result.archivedSections.map(s => ({ section: s.section, entries_would_archive: s.count })),
1583
+ total_would_archive: totalPruned,
1584
+ note: totalPruned > 0 ? 'Run without --dry-run to actually prune' : 'Nothing to prune',
1585
+ }, raw, totalPruned > 0 ? 'true' : 'false');
1586
+ return;
1587
+ }
1588
+
1589
+ readModifyWriteStateMd(statePath, (content) => {
1590
+ const result = prunePass(content);
1591
+ archived.push(...result.archivedSections);
1592
+ return result.newContent;
1593
+ }, cwd);
1594
+
1595
+ // Write archived entries to STATE-ARCHIVE.md
1596
+ if (archived.length > 0) {
1597
+ const timestamp = new Date().toISOString().split('T')[0];
1598
+ let archiveContent = '';
1599
+ if (fs.existsSync(archivePath)) {
1600
+ archiveContent = fs.readFileSync(archivePath, 'utf-8');
1601
+ } else {
1602
+ archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
1603
+ }
1604
+ archiveContent += `## Pruned ${timestamp} (phases 1-${cutoff}, kept recent ${keepRecent})\n\n`;
1605
+ for (const section of archived) {
1606
+ archiveContent += `### ${section.section}\n\n${section.lines.join('\n')}\n\n`;
1607
+ }
1608
+ atomicWriteFileSync(archivePath, archiveContent);
1609
+ }
1610
+
1611
+ const totalPruned = archived.reduce((sum, s) => sum + s.count, 0);
1612
+ emit({
1613
+ pruned: totalPruned > 0,
1614
+ cutoff_phase: cutoff,
1615
+ keep_recent: keepRecent,
1616
+ sections: archived.map(s => ({ section: s.section, entries_archived: s.count })),
1617
+ total_archived: totalPruned,
1618
+ archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
1619
+ }, raw, totalPruned > 0 ? 'true' : 'false');
1620
+ }
1621
+
1622
+ module.exports = {
1623
+ stateExtractField,
1624
+ stateReplaceField,
1625
+ stateReplaceFieldWithFallback,
1626
+ writeStateMd,
1627
+ readModifyWriteStateMd,
1628
+ updatePerformanceMetricsSection,
1629
+ cmdStateLoad,
1630
+ cmdStateGet,
1631
+ cmdStatePatch,
1632
+ cmdStateUpdate,
1633
+ cmdStateAdvancePlan,
1634
+ cmdStateRecordMetric,
1635
+ cmdStateUpdateProgress,
1636
+ cmdStateAddDecision,
1637
+ cmdStateAddBlocker,
1638
+ cmdStateResolveBlocker,
1639
+ cmdStateRecordSession,
1640
+ cmdStateSnapshot,
1641
+ cmdStateJson,
1642
+ cmdStateBeginPhase,
1643
+ cmdStatePlannedPhase,
1644
+ cmdStateValidate,
1645
+ cmdStateSync,
1646
+ cmdStatePrune,
1647
+ cmdSignalWaiting,
1648
+ cmdSignalResume,
1649
+ };