@pareto4/pp-core 1.5.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 (618) hide show
  1. package/.claude-plugin/plugin.json +23 -0
  2. package/GEMINI.md +53 -0
  3. package/LICENSE +22 -0
  4. package/README.md +100 -0
  5. package/agents/pp-advisor-researcher.md +108 -0
  6. package/agents/pp-ai-researcher.md +114 -0
  7. package/agents/pp-assumptions-analyzer.md +105 -0
  8. package/agents/pp-code-fixer.md +669 -0
  9. package/agents/pp-code-reviewer.md +387 -0
  10. package/agents/pp-codebase-mapper.md +853 -0
  11. package/agents/pp-debug-session-manager.md +315 -0
  12. package/agents/pp-debugger.md +1453 -0
  13. package/agents/pp-doc-classifier.md +168 -0
  14. package/agents/pp-doc-synthesizer.md +204 -0
  15. package/agents/pp-doc-verifier.md +217 -0
  16. package/agents/pp-doc-writer.md +616 -0
  17. package/agents/pp-domain-researcher.md +147 -0
  18. package/agents/pp-eval-auditor.md +191 -0
  19. package/agents/pp-eval-planner.md +154 -0
  20. package/agents/pp-executor.md +808 -0
  21. package/agents/pp-framework-selector.md +160 -0
  22. package/agents/pp-integration-checker.md +470 -0
  23. package/agents/pp-intel-updater.md +339 -0
  24. package/agents/pp-mempalace-curator.md +47 -0
  25. package/agents/pp-nyquist-auditor.md +204 -0
  26. package/agents/pp-pattern-mapper.md +335 -0
  27. package/agents/pp-phase-researcher.md +867 -0
  28. package/agents/pp-plan-checker.md +988 -0
  29. package/agents/pp-planner.md +1052 -0
  30. package/agents/pp-project-researcher.md +612 -0
  31. package/agents/pp-research-synthesizer.md +260 -0
  32. package/agents/pp-roadmapper.md +741 -0
  33. package/agents/pp-security-auditor.md +156 -0
  34. package/agents/pp-ui-auditor.md +495 -0
  35. package/agents/pp-ui-checker.md +309 -0
  36. package/agents/pp-ui-researcher.md +375 -0
  37. package/agents/pp-user-profiler.md +171 -0
  38. package/agents/pp-verifier.md +967 -0
  39. package/assets/gsd-logo-2000-transparent.png +0 -0
  40. package/assets/gsd-logo-2000-transparent.svg +17 -0
  41. package/assets/gsd-logo-2000.png +0 -0
  42. package/assets/gsd-logo-2000.svg +21 -0
  43. package/assets/terminal.svg +68 -0
  44. package/bin/install.js +12370 -0
  45. package/bin/lib/ui-safety-gate.cjs +107 -0
  46. package/commands/pp-add-tests.md +42 -0
  47. package/commands/pp-ai-integration-phase.md +37 -0
  48. package/commands/pp-audit-fix.md +34 -0
  49. package/commands/pp-audit-milestone.md +37 -0
  50. package/commands/pp-audit-uat.md +24 -0
  51. package/commands/pp-autonomous.md +51 -0
  52. package/commands/pp-capture.md +62 -0
  53. package/commands/pp-cleanup.md +24 -0
  54. package/commands/pp-code-review.md +59 -0
  55. package/commands/pp-complete-milestone.md +143 -0
  56. package/commands/pp-config.md +56 -0
  57. package/commands/pp-debug.md +52 -0
  58. package/commands/pp-discovery.md +34 -0
  59. package/commands/pp-discuss-phase.md +77 -0
  60. package/commands/pp-docs-update.md +49 -0
  61. package/commands/pp-eval-review.md +33 -0
  62. package/commands/pp-execute-phase.md +65 -0
  63. package/commands/pp-explore.md +27 -0
  64. package/commands/pp-extract-learnings.md +23 -0
  65. package/commands/pp-fast.md +31 -0
  66. package/commands/pp-forensics.md +57 -0
  67. package/commands/pp-graphify.md +204 -0
  68. package/commands/pp-health.md +31 -0
  69. package/commands/pp-help.md +28 -0
  70. package/commands/pp-import.md +45 -0
  71. package/commands/pp-inbox.md +39 -0
  72. package/commands/pp-ingest-docs.md +42 -0
  73. package/commands/pp-manager.md +45 -0
  74. package/commands/pp-map-codebase.md +83 -0
  75. package/commands/pp-mempalace-capture.md +71 -0
  76. package/commands/pp-mempalace-recall.md +102 -0
  77. package/commands/pp-milestone-summary.md +51 -0
  78. package/commands/pp-mvp-phase.md +45 -0
  79. package/commands/pp-new-milestone.md +45 -0
  80. package/commands/pp-new-project.md +47 -0
  81. package/commands/pp-ns-context.md +25 -0
  82. package/commands/pp-ns-ideate.md +24 -0
  83. package/commands/pp-ns-manage.md +36 -0
  84. package/commands/pp-ns-project.md +27 -0
  85. package/commands/pp-ns-review.md +29 -0
  86. package/commands/pp-ns-workflow.md +35 -0
  87. package/commands/pp-pause-work.md +43 -0
  88. package/commands/pp-phase.md +56 -0
  89. package/commands/pp-plan-phase.md +63 -0
  90. package/commands/pp-plan-review-convergence.md +60 -0
  91. package/commands/pp-pr-branch.md +26 -0
  92. package/commands/pp-profile-user.md +46 -0
  93. package/commands/pp-progress.md +49 -0
  94. package/commands/pp-quick.md +174 -0
  95. package/commands/pp-resume-work.md +30 -0
  96. package/commands/pp-review-backlog.md +63 -0
  97. package/commands/pp-review.md +42 -0
  98. package/commands/pp-secure-phase.md +36 -0
  99. package/commands/pp-settings.md +29 -0
  100. package/commands/pp-ship.md +24 -0
  101. package/commands/pp-sketch.md +60 -0
  102. package/commands/pp-spec-phase.md +63 -0
  103. package/commands/pp-spike.md +57 -0
  104. package/commands/pp-stats.md +20 -0
  105. package/commands/pp-surface.md +162 -0
  106. package/commands/pp-thread.md +24 -0
  107. package/commands/pp-ui-phase.md +35 -0
  108. package/commands/pp-ui-review.md +33 -0
  109. package/commands/pp-ultraplan-phase.md +34 -0
  110. package/commands/pp-undo.md +35 -0
  111. package/commands/pp-update.md +49 -0
  112. package/commands/pp-validate-phase.md +36 -0
  113. package/commands/pp-verify-work.md +39 -0
  114. package/commands/pp-workspace.md +52 -0
  115. package/commands/pp-workstreams.md +70 -0
  116. package/gemini-extension.json +6 -0
  117. package/hooks/dist/lib/git-cmd.js +150 -0
  118. package/hooks/dist/lib/pp-graphify-rebuild.sh +65 -0
  119. package/hooks/dist/managed-hooks-registry.cjs +39 -0
  120. package/hooks/dist/pp-check-update-worker.js +108 -0
  121. package/hooks/dist/pp-check-update.js +66 -0
  122. package/hooks/dist/pp-config-reload.js +133 -0
  123. package/hooks/dist/pp-context-monitor.js +196 -0
  124. package/hooks/dist/pp-cursor-post-tool.js +75 -0
  125. package/hooks/dist/pp-cursor-session-start.js +52 -0
  126. package/hooks/dist/pp-ensure-canonical-path.js +305 -0
  127. package/hooks/dist/pp-graphify-update.sh +158 -0
  128. package/hooks/dist/pp-phase-boundary.sh +47 -0
  129. package/hooks/dist/pp-prompt-guard.js +97 -0
  130. package/hooks/dist/pp-read-guard.js +101 -0
  131. package/hooks/dist/pp-read-injection-scanner.js +203 -0
  132. package/hooks/dist/pp-session-state.sh +59 -0
  133. package/hooks/dist/pp-statusline.js +566 -0
  134. package/hooks/dist/pp-update-banner.js +138 -0
  135. package/hooks/dist/pp-validate-commit.sh +57 -0
  136. package/hooks/dist/pp-workflow-guard.js +167 -0
  137. package/hooks/dist/pp-worktree-path-guard.js +185 -0
  138. package/hooks/hooks.json +70 -0
  139. package/hooks/lib/git-cmd.js +150 -0
  140. package/hooks/lib/pp-graphify-rebuild.sh +65 -0
  141. package/hooks/managed-hooks-registry.cjs +39 -0
  142. package/hooks/pp-check-update-worker.js +108 -0
  143. package/hooks/pp-check-update.js +66 -0
  144. package/hooks/pp-config-reload.js +133 -0
  145. package/hooks/pp-context-monitor.js +196 -0
  146. package/hooks/pp-cursor-post-tool.js +75 -0
  147. package/hooks/pp-cursor-session-start.js +52 -0
  148. package/hooks/pp-ensure-canonical-path.js +305 -0
  149. package/hooks/pp-graphify-update.sh +158 -0
  150. package/hooks/pp-phase-boundary.sh +47 -0
  151. package/hooks/pp-prompt-guard.js +97 -0
  152. package/hooks/pp-read-guard.js +101 -0
  153. package/hooks/pp-read-injection-scanner.js +203 -0
  154. package/hooks/pp-session-state.sh +59 -0
  155. package/hooks/pp-statusline.js +566 -0
  156. package/hooks/pp-update-banner.js +138 -0
  157. package/hooks/pp-validate-commit.sh +57 -0
  158. package/hooks/pp-workflow-guard.js +167 -0
  159. package/hooks/pp-worktree-path-guard.js +185 -0
  160. package/package.json +125 -0
  161. package/pp-core/bin/check-latest-version.cjs +161 -0
  162. package/pp-core/bin/lib/active-workstream-store.cjs +297 -0
  163. package/pp-core/bin/lib/adr-parser.cjs +399 -0
  164. package/pp-core/bin/lib/agent-command-router.cjs +68 -0
  165. package/pp-core/bin/lib/agent-install-check.cjs +143 -0
  166. package/pp-core/bin/lib/artifacts.cjs +51 -0
  167. package/pp-core/bin/lib/audit-command-router.cjs +61 -0
  168. package/pp-core/bin/lib/audit.cjs +743 -0
  169. package/pp-core/bin/lib/capability-activation.cjs +113 -0
  170. package/pp-core/bin/lib/capability-registry.cjs +3727 -0
  171. package/pp-core/bin/lib/capability-state.cjs +497 -0
  172. package/pp-core/bin/lib/capability-writer.cjs +355 -0
  173. package/pp-core/bin/lib/check-command-router.cjs +800 -0
  174. package/pp-core/bin/lib/cjs-command-router-adapter.cjs +81 -0
  175. package/pp-core/bin/lib/cli-exit.cjs +61 -0
  176. package/pp-core/bin/lib/clock.cjs +95 -0
  177. package/pp-core/bin/lib/clusters.cjs +131 -0
  178. package/pp-core/bin/lib/code-review-flags.cjs +59 -0
  179. package/pp-core/bin/lib/command-aliases.cjs +809 -0
  180. package/pp-core/bin/lib/command-arg-projection.cjs +55 -0
  181. package/pp-core/bin/lib/command-roster.cjs +19 -0
  182. package/pp-core/bin/lib/command-routing-hub.cjs +300 -0
  183. package/pp-core/bin/lib/commands.cjs +1244 -0
  184. package/pp-core/bin/lib/config-loader.cjs +714 -0
  185. package/pp-core/bin/lib/config-schema.cjs +58 -0
  186. package/pp-core/bin/lib/config-types.cjs +20 -0
  187. package/pp-core/bin/lib/config.cjs +796 -0
  188. package/pp-core/bin/lib/configuration.cjs +209 -0
  189. package/pp-core/bin/lib/context-utilization.cjs +48 -0
  190. package/pp-core/bin/lib/core-utils.cjs +198 -0
  191. package/pp-core/bin/lib/decisions.cjs +136 -0
  192. package/pp-core/bin/lib/docs.cjs +264 -0
  193. package/pp-core/bin/lib/drift.cjs +364 -0
  194. package/pp-core/bin/lib/edge-probe.cjs +196 -0
  195. package/pp-core/bin/lib/fallow-runner.cjs +153 -0
  196. package/pp-core/bin/lib/federated-config.cjs +182 -0
  197. package/pp-core/bin/lib/frontmatter.cjs +494 -0
  198. package/pp-core/bin/lib/gap-checker.cjs +260 -0
  199. package/pp-core/bin/lib/git-base-branch.cjs +220 -0
  200. package/pp-core/bin/lib/graphify-command-router.cjs +72 -0
  201. package/pp-core/bin/lib/graphify.cjs +472 -0
  202. package/pp-core/bin/lib/gsd2-import.cjs +456 -0
  203. package/pp-core/bin/lib/init-command-router.cjs +62 -0
  204. package/pp-core/bin/lib/init.cjs +1912 -0
  205. package/pp-core/bin/lib/install-profiles.cjs +814 -0
  206. package/pp-core/bin/lib/installer-migration-authoring.cjs +122 -0
  207. package/pp-core/bin/lib/installer-migration-report.cjs +351 -0
  208. package/pp-core/bin/lib/installer-migrations/000-first-time-baseline.cjs +225 -0
  209. package/pp-core/bin/lib/installer-migrations/001-legacy-orphan-files.cjs +48 -0
  210. package/pp-core/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs +94 -0
  211. package/pp-core/bin/lib/installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs +108 -0
  212. package/pp-core/bin/lib/installer-migrations/004-prune-stale-pristine-snapshots.cjs +123 -0
  213. package/pp-core/bin/lib/installer-migrations/005-rename-gsd-core-to-pp-core.cjs +109 -0
  214. package/pp-core/bin/lib/installer-migrations/006-rename-gsd-command-namespace.cjs +135 -0
  215. package/pp-core/bin/lib/installer-migrations.cjs +823 -0
  216. package/pp-core/bin/lib/intel-command-router.cjs +119 -0
  217. package/pp-core/bin/lib/intel.cjs +584 -0
  218. package/pp-core/bin/lib/io.cjs +220 -0
  219. package/pp-core/bin/lib/learnings.cjs +270 -0
  220. package/pp-core/bin/lib/legacy-cleanup.cjs +253 -0
  221. package/pp-core/bin/lib/loop-host-contract.cjs +105 -0
  222. package/pp-core/bin/lib/loop-resolver.cjs +479 -0
  223. package/pp-core/bin/lib/milestone.cjs +381 -0
  224. package/pp-core/bin/lib/model-catalog.cjs +154 -0
  225. package/pp-core/bin/lib/model-profiles.cjs +24 -0
  226. package/pp-core/bin/lib/model-resolver.cjs +467 -0
  227. package/pp-core/bin/lib/observability/event.cjs +51 -0
  228. package/pp-core/bin/lib/observability/logger.cjs +146 -0
  229. package/pp-core/bin/lib/observability/redaction.cjs +48 -0
  230. package/pp-core/bin/lib/package-identity.cjs +35 -0
  231. package/pp-core/bin/lib/package-legitimacy.cjs +368 -0
  232. package/pp-core/bin/lib/phase-command-router.cjs +209 -0
  233. package/pp-core/bin/lib/phase-id.cjs +213 -0
  234. package/pp-core/bin/lib/phase-lifecycle.cjs +74 -0
  235. package/pp-core/bin/lib/phase-locator.cjs +145 -0
  236. package/pp-core/bin/lib/phase.cjs +1446 -0
  237. package/pp-core/bin/lib/phases-command-router.cjs +43 -0
  238. package/pp-core/bin/lib/plan-drift-guard.cjs +117 -0
  239. package/pp-core/bin/lib/plan-scan.cjs +91 -0
  240. package/pp-core/bin/lib/planning-workspace.cjs +245 -0
  241. package/pp-core/bin/lib/probe-core.cjs +402 -0
  242. package/pp-core/bin/lib/profile-output.cjs +1164 -0
  243. package/pp-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
  244. package/pp-core/bin/lib/profile-pipeline.cjs +519 -0
  245. package/pp-core/bin/lib/prohibition-enforcement.cjs +660 -0
  246. package/pp-core/bin/lib/project-root.cjs +119 -0
  247. package/pp-core/bin/lib/prompt-budget.cjs +305 -0
  248. package/pp-core/bin/lib/research-provider.cjs +137 -0
  249. package/pp-core/bin/lib/research-store.cjs +167 -0
  250. package/pp-core/bin/lib/review-reviewer-selection.cjs +121 -0
  251. package/pp-core/bin/lib/roadmap-command-router.cjs +166 -0
  252. package/pp-core/bin/lib/roadmap-parser.cjs +456 -0
  253. package/pp-core/bin/lib/roadmap-upgrade.cjs +476 -0
  254. package/pp-core/bin/lib/roadmap.cjs +738 -0
  255. package/pp-core/bin/lib/runtime-artifact-conversion.cjs +1966 -0
  256. package/pp-core/bin/lib/runtime-artifact-layout.cjs +342 -0
  257. package/pp-core/bin/lib/runtime-config-adapter-registry.cjs +103 -0
  258. package/pp-core/bin/lib/runtime-homes.cjs +272 -0
  259. package/pp-core/bin/lib/runtime-hooks-surface.cjs +1460 -0
  260. package/pp-core/bin/lib/runtime-name-policy.cjs +97 -0
  261. package/pp-core/bin/lib/runtime-slash.cjs +137 -0
  262. package/pp-core/bin/lib/schema-detect.cjs +159 -0
  263. package/pp-core/bin/lib/secrets.cjs +34 -0
  264. package/pp-core/bin/lib/security.cjs +480 -0
  265. package/pp-core/bin/lib/semver-compare.cjs +42 -0
  266. package/pp-core/bin/lib/shell-command-projection.cjs +550 -0
  267. package/pp-core/bin/lib/state-command-router.cjs +172 -0
  268. package/pp-core/bin/lib/state-document.cjs +312 -0
  269. package/pp-core/bin/lib/state.cjs +2587 -0
  270. package/pp-core/bin/lib/surface.cjs +505 -0
  271. package/pp-core/bin/lib/task-command-router.cjs +85 -0
  272. package/pp-core/bin/lib/teams-status.cjs +74 -0
  273. package/pp-core/bin/lib/template.cjs +246 -0
  274. package/pp-core/bin/lib/uat-predicate.cjs +329 -0
  275. package/pp-core/bin/lib/uat.cjs +359 -0
  276. package/pp-core/bin/lib/ui-safety-gate.cjs +98 -0
  277. package/pp-core/bin/lib/update-context.cjs +221 -0
  278. package/pp-core/bin/lib/validate-command-router.cjs +91 -0
  279. package/pp-core/bin/lib/validate.cjs +122 -0
  280. package/pp-core/bin/lib/verification-command-router.cjs +31 -0
  281. package/pp-core/bin/lib/verification.cjs +196 -0
  282. package/pp-core/bin/lib/verify-command-router.cjs +44 -0
  283. package/pp-core/bin/lib/verify.cjs +1932 -0
  284. package/pp-core/bin/lib/workstream-inventory-builder.cjs +81 -0
  285. package/pp-core/bin/lib/workstream-inventory.cjs +147 -0
  286. package/pp-core/bin/lib/workstream-name-policy.cjs +91 -0
  287. package/pp-core/bin/lib/workstream.cjs +386 -0
  288. package/pp-core/bin/lib/worktree-base-ref.cjs +350 -0
  289. package/pp-core/bin/lib/worktree-safety.cjs +985 -0
  290. package/pp-core/bin/pp-tools.cjs +2239 -0
  291. package/pp-core/bin/pp_run +20 -0
  292. package/pp-core/bin/shared/config-defaults.manifest.json +97 -0
  293. package/pp-core/bin/shared/config-schema.manifest.json +176 -0
  294. package/pp-core/bin/shared/model-catalog.json +160 -0
  295. package/pp-core/bin/shared/runtime-aliases.manifest.json +79 -0
  296. package/pp-core/bin/verify-reapply-patches.cjs +400 -0
  297. package/pp-core/contexts/dev.md +21 -0
  298. package/pp-core/contexts/research.md +22 -0
  299. package/pp-core/contexts/review.md +23 -0
  300. package/pp-core/references/agent-contracts.md +79 -0
  301. package/pp-core/references/ai-evals.md +156 -0
  302. package/pp-core/references/ai-frameworks.md +186 -0
  303. package/pp-core/references/artifact-types.md +131 -0
  304. package/pp-core/references/autonomous-smart-discuss.md +277 -0
  305. package/pp-core/references/checkpoints.md +814 -0
  306. package/pp-core/references/common-bug-patterns.md +114 -0
  307. package/pp-core/references/context-budget.md +85 -0
  308. package/pp-core/references/continuation-format.md +253 -0
  309. package/pp-core/references/debugger-philosophy.md +76 -0
  310. package/pp-core/references/decimal-phase-calculation.md +64 -0
  311. package/pp-core/references/doc-conflict-engine.md +91 -0
  312. package/pp-core/references/domain-probes.md +125 -0
  313. package/pp-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
  314. package/pp-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
  315. package/pp-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
  316. package/pp-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
  317. package/pp-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
  318. package/pp-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
  319. package/pp-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
  320. package/pp-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
  321. package/pp-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
  322. package/pp-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
  323. package/pp-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
  324. package/pp-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
  325. package/pp-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
  326. package/pp-core/references/edge-probe.md +272 -0
  327. package/pp-core/references/execute-mvp-tdd.md +81 -0
  328. package/pp-core/references/executor-examples.md +110 -0
  329. package/pp-core/references/few-shot-examples/plan-checker.md +73 -0
  330. package/pp-core/references/few-shot-examples/verifier.md +109 -0
  331. package/pp-core/references/gate-prompts.md +100 -0
  332. package/pp-core/references/gates.md +70 -0
  333. package/pp-core/references/git-integration.md +298 -0
  334. package/pp-core/references/git-planning-commit.md +40 -0
  335. package/pp-core/references/ios-scaffold.md +123 -0
  336. package/pp-core/references/loop-hook-dispatch.md +61 -0
  337. package/pp-core/references/mandatory-initial-read.md +2 -0
  338. package/pp-core/references/model-profile-resolution.md +38 -0
  339. package/pp-core/references/model-profiles.md +245 -0
  340. package/pp-core/references/mvp-concepts.md +49 -0
  341. package/pp-core/references/phase-argument-parsing.md +61 -0
  342. package/pp-core/references/planner-antipatterns.md +176 -0
  343. package/pp-core/references/planner-chunked.md +49 -0
  344. package/pp-core/references/planner-gap-closure.md +62 -0
  345. package/pp-core/references/planner-graphify-auto-update.md +67 -0
  346. package/pp-core/references/planner-guidance.md +186 -0
  347. package/pp-core/references/planner-human-verify-mode.md +57 -0
  348. package/pp-core/references/planner-interface-context.md +62 -0
  349. package/pp-core/references/planner-load-graph-context.md +36 -0
  350. package/pp-core/references/planner-mvp-mode.md +53 -0
  351. package/pp-core/references/planner-reviews.md +42 -0
  352. package/pp-core/references/planner-revision.md +87 -0
  353. package/pp-core/references/planner-source-audit.md +73 -0
  354. package/pp-core/references/planning-config.md +477 -0
  355. package/pp-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
  356. package/pp-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
  357. package/pp-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
  358. package/pp-core/references/prohibition-probe.md +326 -0
  359. package/pp-core/references/project-skills-discovery.md +19 -0
  360. package/pp-core/references/questioning.md +162 -0
  361. package/pp-core/references/research-documentation-lookup.md +29 -0
  362. package/pp-core/references/research-philosophy.md +29 -0
  363. package/pp-core/references/research-verification-protocol.md +27 -0
  364. package/pp-core/references/revision-loop.md +97 -0
  365. package/pp-core/references/scout-codebase.md +51 -0
  366. package/pp-core/references/skeleton-template.md +48 -0
  367. package/pp-core/references/sketch-interactivity.md +41 -0
  368. package/pp-core/references/sketch-theme-system.md +94 -0
  369. package/pp-core/references/sketch-tooling.md +45 -0
  370. package/pp-core/references/sketch-variant-patterns.md +81 -0
  371. package/pp-core/references/spidr-splitting.md +69 -0
  372. package/pp-core/references/tdd.md +330 -0
  373. package/pp-core/references/thinking-models-debug.md +44 -0
  374. package/pp-core/references/thinking-models-execution.md +50 -0
  375. package/pp-core/references/thinking-models-planning.md +62 -0
  376. package/pp-core/references/thinking-models-research.md +50 -0
  377. package/pp-core/references/thinking-models-verification.md +55 -0
  378. package/pp-core/references/thinking-partner.md +96 -0
  379. package/pp-core/references/ui-brand.md +162 -0
  380. package/pp-core/references/universal-anti-patterns.md +63 -0
  381. package/pp-core/references/user-profiling.md +681 -0
  382. package/pp-core/references/user-story-template.md +58 -0
  383. package/pp-core/references/verification-overrides.md +227 -0
  384. package/pp-core/references/verification-patterns.md +612 -0
  385. package/pp-core/references/verify-mvp-mode.md +85 -0
  386. package/pp-core/references/workstream-flag.md +111 -0
  387. package/pp-core/references/worktree-branch-check.md +44 -0
  388. package/pp-core/references/worktree-path-safety.md +67 -0
  389. package/pp-core/templates/AI-SPEC.md +246 -0
  390. package/pp-core/templates/DEBUG.md +169 -0
  391. package/pp-core/templates/README.md +77 -0
  392. package/pp-core/templates/SECURITY.md +61 -0
  393. package/pp-core/templates/UAT.md +265 -0
  394. package/pp-core/templates/UI-SPEC.md +100 -0
  395. package/pp-core/templates/VALIDATION.md +76 -0
  396. package/pp-core/templates/claude-md.md +145 -0
  397. package/pp-core/templates/codebase/architecture.md +255 -0
  398. package/pp-core/templates/codebase/concerns.md +310 -0
  399. package/pp-core/templates/codebase/conventions.md +307 -0
  400. package/pp-core/templates/codebase/integrations.md +280 -0
  401. package/pp-core/templates/codebase/stack.md +186 -0
  402. package/pp-core/templates/codebase/structure.md +285 -0
  403. package/pp-core/templates/codebase/testing.md +480 -0
  404. package/pp-core/templates/config.json +62 -0
  405. package/pp-core/templates/context.md +352 -0
  406. package/pp-core/templates/continue-here.md +78 -0
  407. package/pp-core/templates/copilot-instructions.md +7 -0
  408. package/pp-core/templates/debug-subagent-prompt.md +91 -0
  409. package/pp-core/templates/dev-preferences.md +21 -0
  410. package/pp-core/templates/discovery.md +146 -0
  411. package/pp-core/templates/discussion-log.md +63 -0
  412. package/pp-core/templates/milestone-archive.md +123 -0
  413. package/pp-core/templates/milestone.md +115 -0
  414. package/pp-core/templates/phase-prompt.md +610 -0
  415. package/pp-core/templates/planner-subagent-prompt.md +117 -0
  416. package/pp-core/templates/project.md +203 -0
  417. package/pp-core/templates/requirements.md +231 -0
  418. package/pp-core/templates/research-project/ARCHITECTURE.md +204 -0
  419. package/pp-core/templates/research-project/FEATURES.md +147 -0
  420. package/pp-core/templates/research-project/PITFALLS.md +200 -0
  421. package/pp-core/templates/research-project/STACK.md +120 -0
  422. package/pp-core/templates/research-project/SUMMARY.md +170 -0
  423. package/pp-core/templates/research.md +592 -0
  424. package/pp-core/templates/retrospective.md +54 -0
  425. package/pp-core/templates/roadmap.md +202 -0
  426. package/pp-core/templates/spec.md +333 -0
  427. package/pp-core/templates/state.md +195 -0
  428. package/pp-core/templates/summary-complex.md +60 -0
  429. package/pp-core/templates/summary-minimal.md +42 -0
  430. package/pp-core/templates/summary-standard.md +49 -0
  431. package/pp-core/templates/summary.md +249 -0
  432. package/pp-core/templates/user-profile.md +146 -0
  433. package/pp-core/templates/user-setup.md +311 -0
  434. package/pp-core/templates/verification-report.md +335 -0
  435. package/pp-core/workflows/_runtime-launcher.snippet.sh +1 -0
  436. package/pp-core/workflows/add-backlog.md +91 -0
  437. package/pp-core/workflows/add-phase.md +113 -0
  438. package/pp-core/workflows/add-tests.md +355 -0
  439. package/pp-core/workflows/add-todo.md +161 -0
  440. package/pp-core/workflows/ai-integration-phase.md +295 -0
  441. package/pp-core/workflows/analyze-dependencies.md +96 -0
  442. package/pp-core/workflows/audit-fix.md +178 -0
  443. package/pp-core/workflows/audit-milestone.md +362 -0
  444. package/pp-core/workflows/audit-uat.md +110 -0
  445. package/pp-core/workflows/autonomous.md +886 -0
  446. package/pp-core/workflows/check-todos.md +180 -0
  447. package/pp-core/workflows/cleanup.md +195 -0
  448. package/pp-core/workflows/code-review-fix.md +506 -0
  449. package/pp-core/workflows/code-review.md +696 -0
  450. package/pp-core/workflows/complete-milestone.md +857 -0
  451. package/pp-core/workflows/debug.md +237 -0
  452. package/pp-core/workflows/diagnose-issues.md +245 -0
  453. package/pp-core/workflows/discovery-phase.md +291 -0
  454. package/pp-core/workflows/discovery-sprint.md +1546 -0
  455. package/pp-core/workflows/discuss-phase/modes/advisor.md +176 -0
  456. package/pp-core/workflows/discuss-phase/modes/all.md +28 -0
  457. package/pp-core/workflows/discuss-phase/modes/analyze.md +44 -0
  458. package/pp-core/workflows/discuss-phase/modes/auto.md +57 -0
  459. package/pp-core/workflows/discuss-phase/modes/batch.md +52 -0
  460. package/pp-core/workflows/discuss-phase/modes/chain.md +98 -0
  461. package/pp-core/workflows/discuss-phase/modes/default.md +141 -0
  462. package/pp-core/workflows/discuss-phase/modes/power.md +44 -0
  463. package/pp-core/workflows/discuss-phase/modes/text.md +55 -0
  464. package/pp-core/workflows/discuss-phase/templates/checkpoint.json +18 -0
  465. package/pp-core/workflows/discuss-phase/templates/context.md +136 -0
  466. package/pp-core/workflows/discuss-phase/templates/discussion-log.md +50 -0
  467. package/pp-core/workflows/discuss-phase-assumptions.md +675 -0
  468. package/pp-core/workflows/discuss-phase-power.md +291 -0
  469. package/pp-core/workflows/discuss-phase.md +520 -0
  470. package/pp-core/workflows/do.md +111 -0
  471. package/pp-core/workflows/docs-update.md +1168 -0
  472. package/pp-core/workflows/edit-phase.md +295 -0
  473. package/pp-core/workflows/eval-review.md +159 -0
  474. package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +95 -0
  475. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  476. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +117 -0
  477. package/pp-core/workflows/execute-phase/steps/worktree-recovery-policy.md +9 -0
  478. package/pp-core/workflows/execute-phase.md +1707 -0
  479. package/pp-core/workflows/execute-plan.md +541 -0
  480. package/pp-core/workflows/explore.md +146 -0
  481. package/pp-core/workflows/extract-learnings.md +243 -0
  482. package/pp-core/workflows/fast.md +124 -0
  483. package/pp-core/workflows/forensics.md +279 -0
  484. package/pp-core/workflows/graduation.md +196 -0
  485. package/pp-core/workflows/health.md +224 -0
  486. package/pp-core/workflows/help/modes/brief.md +22 -0
  487. package/pp-core/workflows/help/modes/default.md +50 -0
  488. package/pp-core/workflows/help/modes/full.md +791 -0
  489. package/pp-core/workflows/help/modes/topic.md +74 -0
  490. package/pp-core/workflows/help.md +24 -0
  491. package/pp-core/workflows/import.md +256 -0
  492. package/pp-core/workflows/inbox.md +387 -0
  493. package/pp-core/workflows/ingest-docs.md +340 -0
  494. package/pp-core/workflows/insert-phase.md +152 -0
  495. package/pp-core/workflows/list-phase-assumptions.md +178 -0
  496. package/pp-core/workflows/list-workspaces.md +57 -0
  497. package/pp-core/workflows/manager.md +435 -0
  498. package/pp-core/workflows/map-codebase.md +444 -0
  499. package/pp-core/workflows/milestone-summary.md +224 -0
  500. package/pp-core/workflows/mvp-phase.md +222 -0
  501. package/pp-core/workflows/new-milestone.md +643 -0
  502. package/pp-core/workflows/new-project.md +1563 -0
  503. package/pp-core/workflows/new-workspace.md +240 -0
  504. package/pp-core/workflows/next.md +347 -0
  505. package/pp-core/workflows/node-repair.md +92 -0
  506. package/pp-core/workflows/note.md +158 -0
  507. package/pp-core/workflows/pause-work.md +250 -0
  508. package/pp-core/workflows/plan-milestone-gaps.md +281 -0
  509. package/pp-core/workflows/plan-phase.md +1755 -0
  510. package/pp-core/workflows/plan-review-convergence.md +369 -0
  511. package/pp-core/workflows/plant-seed.md +230 -0
  512. package/pp-core/workflows/pr-branch.md +159 -0
  513. package/pp-core/workflows/profile-user.md +455 -0
  514. package/pp-core/workflows/progress.md +756 -0
  515. package/pp-core/workflows/quick.md +1045 -0
  516. package/pp-core/workflows/reapply-patches.md +443 -0
  517. package/pp-core/workflows/remove-phase.md +156 -0
  518. package/pp-core/workflows/remove-workspace.md +108 -0
  519. package/pp-core/workflows/resume-project.md +348 -0
  520. package/pp-core/workflows/review.md +765 -0
  521. package/pp-core/workflows/scan.md +107 -0
  522. package/pp-core/workflows/secure-phase.md +184 -0
  523. package/pp-core/workflows/session-report.md +146 -0
  524. package/pp-core/workflows/settings-advanced.md +816 -0
  525. package/pp-core/workflows/settings-integrations.md +312 -0
  526. package/pp-core/workflows/settings.md +592 -0
  527. package/pp-core/workflows/ship.md +465 -0
  528. package/pp-core/workflows/sketch-wrap-up.md +286 -0
  529. package/pp-core/workflows/sketch.md +361 -0
  530. package/pp-core/workflows/spec-phase.md +495 -0
  531. package/pp-core/workflows/spike-wrap-up.md +307 -0
  532. package/pp-core/workflows/spike.md +453 -0
  533. package/pp-core/workflows/stats.md +80 -0
  534. package/pp-core/workflows/sync-skills.md +182 -0
  535. package/pp-core/workflows/thread.md +222 -0
  536. package/pp-core/workflows/transition.md +694 -0
  537. package/pp-core/workflows/ui-phase.md +328 -0
  538. package/pp-core/workflows/ui-review.md +193 -0
  539. package/pp-core/workflows/ultraplan-phase.md +199 -0
  540. package/pp-core/workflows/undo.md +314 -0
  541. package/pp-core/workflows/update.md +501 -0
  542. package/pp-core/workflows/validate-phase.md +183 -0
  543. package/pp-core/workflows/verify-phase.md +569 -0
  544. package/pp-core/workflows/verify-work.md +804 -0
  545. package/scripts/affected-tests-lib.cjs +554 -0
  546. package/scripts/audit-workflow-script-paths.cjs +73 -0
  547. package/scripts/base64-scan.sh +351 -0
  548. package/scripts/build-hooks.js +254 -0
  549. package/scripts/changeset/README.md +129 -0
  550. package/scripts/changeset/cli.cjs +597 -0
  551. package/scripts/changeset/github-release-notes.cjs +199 -0
  552. package/scripts/changeset/lint.cjs +147 -0
  553. package/scripts/changeset/new.cjs +151 -0
  554. package/scripts/changeset/parse.cjs +114 -0
  555. package/scripts/changeset/render.cjs +34 -0
  556. package/scripts/changeset/serialize.cjs +130 -0
  557. package/scripts/check-alias-drift.cjs +114 -0
  558. package/scripts/check-env.cjs +312 -0
  559. package/scripts/check-npm-integrity.cjs +215 -0
  560. package/scripts/ci-guard-runner.cjs +22 -0
  561. package/scripts/ci-prepare-test-scope.cjs +93 -0
  562. package/scripts/ci-rebase-check.cjs +86 -0
  563. package/scripts/ci-test-scope.cjs +441 -0
  564. package/scripts/command-contract-helpers.cjs +64 -0
  565. package/scripts/diff-touches-shipped-paths.cjs +149 -0
  566. package/scripts/fix-slash-commands.cjs +161 -0
  567. package/scripts/gen-capability-registry.cjs +2646 -0
  568. package/scripts/gen-commands-reference.cjs +139 -0
  569. package/scripts/gen-inventory-manifest.cjs +114 -0
  570. package/scripts/gen-loop-host-contract.cjs +526 -0
  571. package/scripts/gen-research-agents.cjs +276 -0
  572. package/scripts/generate-package-identity.cjs +134 -0
  573. package/scripts/issue-dedupe.cjs +278 -0
  574. package/scripts/issue-version-gate.cjs +140 -0
  575. package/scripts/lib/allowlist-ratchet.cjs +236 -0
  576. package/scripts/lib/cli-exit.cjs +56 -0
  577. package/scripts/lint-allow-test-rule-refs.allowlist.json +328 -0
  578. package/scripts/lint-allow-test-rule-refs.cjs +162 -0
  579. package/scripts/lint-command-contract.cjs +118 -0
  580. package/scripts/lint-descriptions.cjs +87 -0
  581. package/scripts/lint-docs-required.cjs +222 -0
  582. package/scripts/lint-legacy-dir-name.cjs +166 -0
  583. package/scripts/lint-package-identity-drift.cjs +141 -0
  584. package/scripts/lint-pr-check-project-dir.cjs +99 -0
  585. package/scripts/lint-regression-test-names.allowlist.json +269 -0
  586. package/scripts/lint-regression-test-names.cjs +117 -0
  587. package/scripts/lint-shell-command-projection-drift.cjs +62 -0
  588. package/scripts/lint-skill-deps.cjs +185 -0
  589. package/scripts/lint-test-file-count.allowlist.json +181 -0
  590. package/scripts/lint-test-file-count.cjs +246 -0
  591. package/scripts/lint-windows-test-portability.cjs +178 -0
  592. package/scripts/mutation-matrix.cjs +323 -0
  593. package/scripts/pr-target-policy.cjs +63 -0
  594. package/scripts/pr-template-policy.cjs +268 -0
  595. package/scripts/prompt-injection-scan.sh +207 -0
  596. package/scripts/registry-smoke.cjs +337 -0
  597. package/scripts/release-notes/discord-release-summary.cjs +373 -0
  598. package/scripts/release-notes/format-github-release-notes.cjs +261 -0
  599. package/scripts/release-tarball-smoke.cjs +763 -0
  600. package/scripts/research-profiles.cjs +149 -0
  601. package/scripts/run-affected-tests.cjs +7 -0
  602. package/scripts/run-tests.cjs +589 -0
  603. package/scripts/secret-scan-lint.sh +231 -0
  604. package/scripts/secret-scan.sh +358 -0
  605. package/scripts/setup-branch-protection.sh +234 -0
  606. package/scripts/strip-prose-atrefs.cjs +106 -0
  607. package/scripts/sync-manifest-versions.cjs +119 -0
  608. package/scripts/sync-next-version.cjs +133 -0
  609. package/scripts/sync-rulesets.sh +34 -0
  610. package/scripts/sync-runtime-launcher.cjs +425 -0
  611. package/scripts/test-failure-reasons.cjs +34 -0
  612. package/scripts/update-size-baseline.cjs +68 -0
  613. package/scripts/verify-npm-publish.cjs +240 -0
  614. package/scripts/verify-opengsd-sweep.cjs +288 -0
  615. package/scripts/verify-phase-02-rename.sh +258 -0
  616. package/scripts/verify-phase-03-rename.sh +376 -0
  617. package/scripts/workflow-policy.cjs +483 -0
  618. package/scripts/workflow-size.cjs +90 -0
@@ -0,0 +1,2587 @@
1
+ "use strict";
2
+ /**
3
+ * State — STATE.md operations and progression engine
4
+ *
5
+ * ADR-457 build-at-publish: the hand-written bin/lib/state.cjs collapsed
6
+ * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
7
+ * from the prior hand-written .cjs; only strict types are added.
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ const node_fs_1 = __importDefault(require("node:fs"));
13
+ const node_path_1 = __importDefault(require("node:path"));
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ const ioMod = require("./io.cjs");
16
+ const { output, error } = ioMod;
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ const configLoaderMod = require("./config-loader.cjs");
19
+ const { loadConfig } = configLoaderMod;
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ const phaseIdMod = require("./phase-id.cjs");
22
+ const { escapeRegex } = phaseIdMod;
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ const roadmapParserMod = require("./roadmap-parser.cjs");
25
+ const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
26
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
28
+ const planningWorkspace = require("./planning-workspace.cjs");
29
+ const { planningDir, planningPaths } = planningWorkspace;
30
+ const clock_cjs_1 = require("./clock.cjs");
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ const frontmatter = require("./frontmatter.cjs");
33
+ const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const scanPhasePlans = require("./plan-scan.cjs");
36
+ const state_document_cjs_1 = require("./state-document.cjs");
37
+ const STATE_PROGRESS_RESYNC_FIELDS = new Set([
38
+ 'Progress',
39
+ 'Total Plans in Phase',
40
+ 'Total Phases',
41
+ ]);
42
+ function shouldResyncStateProgress(fields) {
43
+ for (const field of fields) {
44
+ if (STATE_PROGRESS_RESYNC_FIELDS.has(field)) {
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+ // ─── Cache ────────────────────────────────────────────────────────────────────
51
+ // Cache disk scan results from buildStateFrontmatter per cwd per process (#1967).
52
+ // Avoids re-reading N+1 directories on every state write when the phase structure
53
+ // hasn't changed within the same gsd-tools invocation.
54
+ const _diskScanCache = new Map();
55
+ // Track all lock files held by this process so they can be removed on exit.
56
+ // process.on('exit') fires even on process.exit(1), unlike try/finally which is
57
+ // skipped when error() calls process.exit(1) inside a locked region (#1916).
58
+ const _heldStateLocks = new Set();
59
+ process.on('exit', () => {
60
+ for (const lockPath of _heldStateLocks) {
61
+ try {
62
+ node_fs_1.default.unlinkSync(lockPath);
63
+ }
64
+ catch { /* already gone */ }
65
+ }
66
+ });
67
+ // Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match).
68
+ 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;
69
+ function cmdStateLoad(cwd, raw) {
70
+ const config = loadConfig(cwd);
71
+ const planDir = planningPaths(cwd).planning;
72
+ const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planDir, 'STATE.md')) || '';
73
+ const configExists = node_fs_1.default.existsSync(node_path_1.default.join(planDir, 'config.json'));
74
+ const roadmapExists = node_fs_1.default.existsSync(node_path_1.default.join(planDir, 'ROADMAP.md'));
75
+ const stateExists = stateRaw.length > 0;
76
+ const result = {
77
+ config,
78
+ state_raw: stateRaw,
79
+ state_exists: stateExists,
80
+ roadmap_exists: roadmapExists,
81
+ config_exists: configExists,
82
+ };
83
+ // For --raw, output a condensed key=value format
84
+ if (raw) {
85
+ const c = config;
86
+ const lines = [
87
+ `model_profile=${c['model_profile']}`,
88
+ `commit_docs=${c['commit_docs']}`,
89
+ `branching_strategy=${c['branching_strategy']}`,
90
+ `phase_branch_template=${c['phase_branch_template']}`,
91
+ `milestone_branch_template=${c['milestone_branch_template']}`,
92
+ `parallelization=${c['parallelization']}`,
93
+ `research=${c['research']}`,
94
+ `plan_checker=${c['plan_checker']}`,
95
+ `verifier=${c['verifier']}`,
96
+ `config_exists=${configExists}`,
97
+ `roadmap_exists=${roadmapExists}`,
98
+ `state_exists=${stateExists}`,
99
+ ];
100
+ process.stdout.write(lines.join('\n'));
101
+ process.exit(0);
102
+ }
103
+ output(result, false, undefined);
104
+ }
105
+ function cmdStateGet(cwd, section, raw) {
106
+ const statePath = planningPaths(cwd).state;
107
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
108
+ if (content === null) {
109
+ error('STATE.md not found');
110
+ return;
111
+ }
112
+ {
113
+ if (!section) {
114
+ output({ content }, raw, content);
115
+ return;
116
+ }
117
+ // Try to find markdown section or field
118
+ const fieldEscaped = escapeRegex(section);
119
+ // Check for **field:** value (bold format)
120
+ const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
121
+ const boldMatch = content.match(boldPattern);
122
+ if (boldMatch) {
123
+ output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
124
+ return;
125
+ }
126
+ // Check for field: value (plain format)
127
+ const plainPattern = new RegExp(`^${fieldEscaped}:\\s*(.*)`, 'im');
128
+ const plainMatch = content.match(plainPattern);
129
+ if (plainMatch) {
130
+ output({ [section]: plainMatch[1].trim() }, raw, plainMatch[1].trim());
131
+ return;
132
+ }
133
+ // Check for ## Section
134
+ const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
135
+ const sectionMatch = content.match(sectionPattern);
136
+ if (sectionMatch) {
137
+ output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim());
138
+ return;
139
+ }
140
+ output({ error: `Section or field "${section}" not found` }, raw, '');
141
+ }
142
+ }
143
+ function readTextArgOrFile(cwd, value, filePath, label) {
144
+ if (!filePath)
145
+ return value;
146
+ // Path traversal guard: ensure file resolves within project directory
147
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
148
+ const { validatePath } = require('./security.cjs');
149
+ const pathCheck = validatePath(filePath, cwd, { allowAbsolute: true });
150
+ if (!pathCheck.safe) {
151
+ throw new Error(`${label} path rejected: ${pathCheck.error}`);
152
+ }
153
+ try {
154
+ return node_fs_1.default.readFileSync(pathCheck.resolved, 'utf-8').trimEnd();
155
+ }
156
+ catch {
157
+ throw new Error(`${label} file not found: ${filePath}`);
158
+ }
159
+ }
160
+ function cmdStatePatch(cwd, patches, raw) {
161
+ // Validate all field names before processing
162
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
163
+ const { validateFieldName } = require('./security.cjs');
164
+ for (const field of Object.keys(patches)) {
165
+ const fieldCheck = validateFieldName(field);
166
+ if (!fieldCheck.valid) {
167
+ error(`state patch: ${fieldCheck.error}`);
168
+ }
169
+ }
170
+ const statePath = planningPaths(cwd).state;
171
+ try {
172
+ const results = { updated: [], failed: [] };
173
+ const shouldResync = shouldResyncStateProgress(Object.keys(patches));
174
+ // Use atomic read-modify-write to prevent lost updates from concurrent agents
175
+ readModifyWriteStateMd(statePath, (content) => {
176
+ for (const [field, value] of Object.entries(patches)) {
177
+ const result = (0, state_document_cjs_1.stateReplaceField)(content, field, value);
178
+ if (result) {
179
+ content = result;
180
+ results.updated.push(field);
181
+ }
182
+ else {
183
+ results.failed.push(field);
184
+ }
185
+ }
186
+ return content;
187
+ }, cwd, { resync: shouldResync });
188
+ output(results, raw, results.updated.length > 0 ? 'true' : 'false');
189
+ }
190
+ catch {
191
+ error('STATE.md not found');
192
+ }
193
+ }
194
+ function cmdStateUpdate(cwd, field, value) {
195
+ if (!field || value === undefined) {
196
+ error('field and value required for state update');
197
+ }
198
+ // Validate field name to prevent regex injection via crafted field names
199
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
200
+ const { validateFieldName } = require('./security.cjs');
201
+ const fieldCheck = validateFieldName(field);
202
+ if (!fieldCheck.valid) {
203
+ error(`state update: ${fieldCheck.error}`);
204
+ }
205
+ const statePath = planningPaths(cwd).state;
206
+ try {
207
+ let updated = false;
208
+ const shouldResync = shouldResyncStateProgress([field]);
209
+ // Preserve curated progress for body-only updates, but allow fields that
210
+ // directly project into progress.* frontmatter to rebuild after mutation.
211
+ readModifyWriteStateMd(statePath, (content) => {
212
+ const body = stripFrontmatter(content);
213
+ const result = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
214
+ if (result) {
215
+ updated = true;
216
+ const existingFm = extractFrontmatter(content);
217
+ if (Object.keys(existingFm).length > 0) {
218
+ return `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}`;
219
+ }
220
+ return result;
221
+ }
222
+ return content;
223
+ }, cwd, { resync: shouldResync });
224
+ if (updated) {
225
+ output({ updated: true }, false, undefined);
226
+ }
227
+ else {
228
+ output({ updated: false, reason: `Field "${field}" not found in STATE.md` }, false, undefined);
229
+ }
230
+ }
231
+ catch {
232
+ output({ updated: false, reason: 'STATE.md not found' }, false, undefined);
233
+ }
234
+ }
235
+ // ─── State Progression Engine ────────────────────────────────────────────────
236
+ /**
237
+ * Replace a STATE.md field with fallback field name support.
238
+ * Tries `primary` first, then `fallback` (if provided), returns content unchanged
239
+ * if neither matches. This consolidates the replaceWithFallback pattern that was
240
+ * previously duplicated inline across phase.cjs, milestone.cjs, and state.cjs.
241
+ */
242
+ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
243
+ let result = (0, state_document_cjs_1.stateReplaceField)(content, primary, value);
244
+ if (result)
245
+ return result;
246
+ if (fallback) {
247
+ result = (0, state_document_cjs_1.stateReplaceField)(content, fallback, value);
248
+ if (result)
249
+ return result;
250
+ }
251
+ // Neither pattern matched — field may have been reformatted or removed.
252
+ // Log diagnostic so template drift is detected early rather than silently swallowed.
253
+ process.stderr.write(`[gsd-tools] WARNING: STATE.md field "${primary}"${fallback ? ` (fallback: "${fallback}")` : ''} not found — update skipped. ` +
254
+ `This may indicate STATE.md was externally modified or uses an unexpected format.\n`);
255
+ return content;
256
+ }
257
+ /**
258
+ * Update fields within the ## Current Position section of STATE.md.
259
+ * This keeps the Current Position body in sync with the bold frontmatter fields.
260
+ * Only updates fields that already exist in the section; does not add new lines.
261
+ * Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
262
+ */
263
+ function updateCurrentPositionFields(content, fields) {
264
+ const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
265
+ const posMatch = content.match(posPattern);
266
+ if (!posMatch)
267
+ return content;
268
+ let posBody = posMatch[2];
269
+ const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
270
+ const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
271
+ if (fields.status) {
272
+ if (/^Status:/m.test(posBody)) {
273
+ // Inline format: Status: value — only replace when the existing value is a
274
+ // known template default (Knuth invariant: preserve executor-authored values).
275
+ const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
276
+ const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
277
+ const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
278
+ const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
279
+ const isDefault = !existingStatus || isInList || matchesPattern;
280
+ if (isDefault) {
281
+ posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
282
+ }
283
+ }
284
+ else {
285
+ // Table format: | Status | value | — apply the same preserve-authored guard
286
+ // as the inline branch: only overwrite a known template default.
287
+ // (Finding 2 code-review: the table branch was unconditional before this fix.)
288
+ const existingStatus = (0, state_document_cjs_1.stateExtractField)(posBody, 'Status');
289
+ const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
290
+ const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
291
+ const isDefault = !existingStatus || isInList || matchesPattern;
292
+ if (isDefault) {
293
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', fields.status);
294
+ if (replaced !== null)
295
+ posBody = replaced;
296
+ }
297
+ }
298
+ }
299
+ if (fields.lastActivity) {
300
+ if (/^Last activity:/im.test(posBody)) {
301
+ // Inline format — only replace when the existing value is a known template
302
+ // default (a bare ISO date). Executor-authored narrative prose is preserved.
303
+ const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
304
+ const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
305
+ // A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
306
+ // A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
307
+ // was authored by the executor and must be preserved.
308
+ const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
309
+ const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
310
+ const isDefault = !existingActivity || isDateShape || inList;
311
+ if (isDefault) {
312
+ posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
313
+ }
314
+ }
315
+ else {
316
+ // Table format — apply the same preserve-authored guard as the inline branch:
317
+ // only overwrite a bare ISO date or a known default; preserve narrative prose.
318
+ // (Finding 2 code-review: the table branch was unconditional before this fix.)
319
+ const existingActivity = (0, state_document_cjs_1.stateExtractField)(posBody, 'Last Activity')
320
+ ?? (0, state_document_cjs_1.stateExtractField)(posBody, 'Last activity');
321
+ const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
322
+ const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
323
+ const isDefault = !existingActivity || isDateShape || inList;
324
+ if (isDefault) {
325
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', fields.lastActivity)
326
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', fields.lastActivity);
327
+ if (replaced !== null)
328
+ posBody = replaced;
329
+ }
330
+ }
331
+ }
332
+ if (fields.plan) {
333
+ if (/^Plan:/m.test(posBody)) {
334
+ posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
335
+ }
336
+ else {
337
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', fields.plan);
338
+ if (replaced !== null)
339
+ posBody = replaced;
340
+ }
341
+ }
342
+ return content.replace(posPattern, () => `${posMatch[1]}${posBody}`);
343
+ }
344
+ function cmdStateAdvancePlan(cwd, raw) {
345
+ const statePath = planningPaths(cwd).state;
346
+ if (!node_fs_1.default.existsSync(statePath)) {
347
+ output({ error: 'STATE.md not found' }, raw, undefined);
348
+ return;
349
+ }
350
+ const today = clock_cjs_1.realClock.today();
351
+ let result = null;
352
+ readModifyWriteStateMd(statePath, (content) => {
353
+ // Try legacy separate fields first, then compound "Plan: X of Y" format
354
+ const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan');
355
+ const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
356
+ const planField = (0, state_document_cjs_1.stateExtractField)(content, 'Plan');
357
+ let currentPlan, totalPlans;
358
+ let useCompoundFormat = false;
359
+ if (legacyPlan && legacyTotal) {
360
+ currentPlan = parseInt(legacyPlan, 10);
361
+ totalPlans = parseInt(legacyTotal, 10);
362
+ }
363
+ else if (planField) {
364
+ // Compound format: "2 of 6 in current phase" or "2 of 6"
365
+ currentPlan = parseInt(planField, 10);
366
+ const ofMatch = planField.match(/of\s+(\d+)/);
367
+ totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
368
+ useCompoundFormat = true;
369
+ }
370
+ else {
371
+ currentPlan = NaN;
372
+ totalPlans = NaN;
373
+ }
374
+ if (isNaN(currentPlan) || isNaN(totalPlans)) {
375
+ result = { error: true };
376
+ return content;
377
+ }
378
+ const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
379
+ const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
380
+ if (currentPlan >= totalPlans) {
381
+ // Phase-complete branch — only replace Status/Last Activity when the existing
382
+ // value is a known template default (Knuth invariant: preserve executor-authored).
383
+ content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Status', statusDefaults, 'Phase complete — ready for verification');
384
+ content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last Activity', lastActivityDefaults, today);
385
+ // stateReplaceFieldWithFallback tries 'Last activity' alias too
386
+ content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last activity', lastActivityDefaults, today);
387
+ content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
388
+ result = { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' };
389
+ }
390
+ else {
391
+ const newPlan = currentPlan + 1;
392
+ let planDisplayValue;
393
+ if (useCompoundFormat) {
394
+ // Preserve compound format: "X of Y in current phase" → replace X only
395
+ planDisplayValue = planField.replace(/^\d+/, String(newPlan));
396
+ content = (0, state_document_cjs_1.stateReplaceField)(content, 'Plan', planDisplayValue) || content;
397
+ }
398
+ else {
399
+ planDisplayValue = `${newPlan} of ${totalPlans}`;
400
+ content = (0, state_document_cjs_1.stateReplaceField)(content, 'Current Plan', String(newPlan)) || content;
401
+ }
402
+ // Normal advance — only replace Status/Last Activity when the existing value is
403
+ // a known template default (Knuth invariant: preserve executor-authored).
404
+ content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Status', statusDefaults, 'Ready to execute');
405
+ content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last Activity', lastActivityDefaults, today);
406
+ content = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last activity', lastActivityDefaults, today);
407
+ content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
408
+ result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
409
+ }
410
+ return content;
411
+ }, cwd);
412
+ if (!result || result['error']) {
413
+ output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
414
+ return;
415
+ }
416
+ if (result['advanced'] === false) {
417
+ output(result, raw, 'false');
418
+ }
419
+ else {
420
+ output(result, raw, 'true');
421
+ }
422
+ }
423
+ function cmdStateRecordMetric(cwd, options, raw) {
424
+ const statePath = planningPaths(cwd).state;
425
+ if (!node_fs_1.default.existsSync(statePath)) {
426
+ output({ error: 'STATE.md not found' }, raw, undefined);
427
+ return;
428
+ }
429
+ const { phase, plan, duration, tasks, files } = options;
430
+ if (!phase || !plan || !duration) {
431
+ output({ error: 'phase, plan, and duration required' }, raw, undefined);
432
+ return;
433
+ }
434
+ let _recorded = false;
435
+ let created = false;
436
+ readModifyWriteStateMd(statePath, (content) => {
437
+ // Find Performance Metrics section and its table
438
+ const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
439
+ const metricsMatch = content.match(metricsPattern);
440
+ const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
441
+ if (metricsMatch) {
442
+ let tableBody = metricsMatch[2].trimEnd();
443
+ if (tableBody.trim() === '' || tableBody.includes('None yet')) {
444
+ tableBody = newRow;
445
+ }
446
+ else {
447
+ tableBody = tableBody + '\n' + newRow;
448
+ }
449
+ _recorded = true;
450
+ return content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
451
+ }
452
+ // Section absent — DWIM: auto-create canonical ## Performance Metrics scaffold,
453
+ // then append the row. Matches state begin-phase / advance-plan DWIM behavior.
454
+ const scaffold = [
455
+ '',
456
+ '## Performance Metrics',
457
+ '',
458
+ '| Phase | Plan | Duration | Notes |',
459
+ '|-------|------|----------|-------|',
460
+ newRow,
461
+ '',
462
+ ].join('\n');
463
+ _recorded = true;
464
+ created = true;
465
+ return content.trimEnd() + '\n' + scaffold;
466
+ }, cwd);
467
+ // Auto-create fallback guarantees recorded === true; no else branch needed.
468
+ const result = { recorded: true, phase, plan, duration };
469
+ if (created)
470
+ result['created'] = true;
471
+ output(result, raw, 'true');
472
+ }
473
+ function cmdStateUpdateProgress(cwd, raw) {
474
+ const statePath = planningPaths(cwd).state;
475
+ if (!node_fs_1.default.existsSync(statePath)) {
476
+ output({ error: 'STATE.md not found' }, raw, undefined);
477
+ return;
478
+ }
479
+ // Count summaries across current milestone phases only (outside lock — read-only)
480
+ const phasesDir = planningPaths(cwd).phases;
481
+ let totalPlans = 0;
482
+ let totalSummaries = 0;
483
+ if (node_fs_1.default.existsSync(phasesDir)) {
484
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
485
+ const phaseDirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
486
+ .filter(e => e.isDirectory()).map(e => e.name)
487
+ .filter(isDirInMilestone);
488
+ for (const dir of phaseDirs) {
489
+ const { planCount, summaryCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
490
+ totalPlans += planCount;
491
+ totalSummaries += summaryCount;
492
+ }
493
+ }
494
+ const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
495
+ const barWidth = 10;
496
+ const filled = Math.round(percent / 100 * barWidth);
497
+ const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
498
+ const progressStr = `[${bar}] ${percent}%`;
499
+ let updated = false;
500
+ const _totalPlans = totalPlans;
501
+ const _totalSummaries = totalSummaries;
502
+ readModifyWriteStateMd(statePath, (content) => {
503
+ // Try **Progress:** bold format first, then plain Progress: format
504
+ const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
505
+ const plainProgressPattern = /^(Progress:\s*).*/im;
506
+ if (boldProgressPattern.test(content)) {
507
+ updated = true;
508
+ return content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
509
+ }
510
+ else if (plainProgressPattern.test(content)) {
511
+ updated = true;
512
+ return content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
513
+ }
514
+ return content;
515
+ }, cwd);
516
+ if (updated) {
517
+ output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
518
+ }
519
+ else {
520
+ output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
521
+ }
522
+ }
523
+ function cmdStateAddDecision(cwd, options, raw) {
524
+ const statePath = planningPaths(cwd).state;
525
+ if (!node_fs_1.default.existsSync(statePath)) {
526
+ output({ error: 'STATE.md not found' }, raw, undefined);
527
+ return;
528
+ }
529
+ const { phase, summary, summary_file, rationale, rationale_file } = options;
530
+ let summaryText = undefined;
531
+ let rationaleText = '';
532
+ try {
533
+ summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
534
+ rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale') || '';
535
+ }
536
+ catch (err) {
537
+ output({ added: false, reason: err.message }, raw, 'false');
538
+ return;
539
+ }
540
+ if (!summaryText) {
541
+ output({ error: 'summary required' }, raw, undefined);
542
+ return;
543
+ }
544
+ const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
545
+ let _added = false;
546
+ let created = false;
547
+ readModifyWriteStateMd(statePath, (content) => {
548
+ // Find Decisions section (various heading patterns)
549
+ const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
550
+ const match = content.match(sectionPattern);
551
+ if (match) {
552
+ let sectionBody = match[2];
553
+ // Remove placeholders
554
+ sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
555
+ sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
556
+ _added = true;
557
+ return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
558
+ }
559
+ // Section absent — DWIM: auto-create canonical ## Decisions scaffold,
560
+ // then append the entry. Matches state begin-phase / advance-plan DWIM behavior.
561
+ const scaffold = [
562
+ '',
563
+ '## Decisions',
564
+ '',
565
+ entry,
566
+ '',
567
+ ].join('\n');
568
+ _added = true;
569
+ created = true;
570
+ return content.trimEnd() + '\n' + scaffold;
571
+ }, cwd);
572
+ // Auto-create fallback guarantees added === true; no else branch needed.
573
+ const result = { added: true, decision: entry };
574
+ if (created)
575
+ result['created'] = true;
576
+ output(result, raw, 'true');
577
+ }
578
+ function cmdStateAddBlocker(cwd, text, raw) {
579
+ const statePath = planningPaths(cwd).state;
580
+ if (!node_fs_1.default.existsSync(statePath)) {
581
+ output({ error: 'STATE.md not found' }, raw, undefined);
582
+ return;
583
+ }
584
+ const blockerOptions = typeof text === 'object' && text !== null ? text : { text: text };
585
+ let blockerText = undefined;
586
+ try {
587
+ blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
588
+ }
589
+ catch (err) {
590
+ output({ added: false, reason: err.message }, raw, 'false');
591
+ return;
592
+ }
593
+ if (!blockerText) {
594
+ output({ error: 'text required' }, raw, undefined);
595
+ return;
596
+ }
597
+ const entry = `- ${blockerText}`;
598
+ let _added = false;
599
+ let created = false;
600
+ readModifyWriteStateMd(statePath, (content) => {
601
+ const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
602
+ const match = content.match(sectionPattern);
603
+ if (match) {
604
+ let sectionBody = match[2];
605
+ sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
606
+ sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
607
+ _added = true;
608
+ return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
609
+ }
610
+ // Section absent — DWIM: auto-create canonical ### Blockers scaffold.
611
+ const scaffold = [
612
+ '',
613
+ '### Blockers',
614
+ '',
615
+ entry,
616
+ '',
617
+ ].join('\n');
618
+ _added = true;
619
+ created = true;
620
+ return content.trimEnd() + '\n' + scaffold;
621
+ }, cwd);
622
+ // Auto-create fallback guarantees added === true; no else branch needed.
623
+ const result = { added: true, blocker: blockerText };
624
+ if (created)
625
+ result['created'] = true;
626
+ output(result, raw, 'true');
627
+ }
628
+ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
629
+ const statePath = planningPaths(cwd).state;
630
+ if (!node_fs_1.default.existsSync(statePath)) {
631
+ output({ error: 'STATE.md not found' }, raw, undefined);
632
+ return;
633
+ }
634
+ const { phase, action, after, note, note_file, urgent } = options;
635
+ let noteText = undefined;
636
+ try {
637
+ noteText = readTextArgOrFile(cwd, note, note_file, 'note');
638
+ }
639
+ catch (err) {
640
+ output({ added: false, reason: err.message }, raw, 'false');
641
+ return;
642
+ }
643
+ // Reject missing / empty / whitespace-only notes — an evolution entry with no
644
+ // narrative is meaningless and would corrupt the section with a dangling bullet.
645
+ if (!noteText || !noteText.trim()) {
646
+ output({ error: 'note required' }, raw, undefined);
647
+ return;
648
+ }
649
+ // Flatten line breaks so the entry is always a single Markdown bullet. The
650
+ // dedupe + rendering contract is line-oriented; a multiline --note-file would
651
+ // otherwise spill continuation lines outside the bullet and defeat dedupe.
652
+ // Internal spacing (e.g. dollar columns) is preserved.
653
+ const flatNote = noteText.replace(/\s*[\r\n]+\s*/g, ' ').trim();
654
+ const actionText = (action && action.trim()) || 'changed';
655
+ const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
656
+ const urgentText = urgent ? ' (URGENT)' : '';
657
+ const entry = `- Phase ${phase || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
658
+ let duplicate = false;
659
+ let created = false;
660
+ let subsectionCreated = false;
661
+ // The Roadmap Evolution subsection lives under `## Accumulated Context`. Scope
662
+ // every lookup to that section's body so a `### Roadmap Evolution` heading in an
663
+ // unrelated h2 section (or a fenced example) can never be matched or mutated.
664
+ // The accBody lookahead stops only at the next h2 (`\n##[^#]`), so nested h3
665
+ // subsections stay inside the captured Accumulated Context body.
666
+ // Section boundaries mirror the sibling handlers (add-decision/add-blocker):
667
+ // a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed,
668
+ // so following sections are preserved without data loss (see the CRLF test).
669
+ readModifyWriteStateMd(statePath, (content) => {
670
+ const accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i;
671
+ const accMatch = content.match(accPattern);
672
+ if (accMatch) {
673
+ const accHeader = accMatch[1];
674
+ const accBody = accMatch[2];
675
+ // Find `### Roadmap Evolution` WITHIN the Accumulated Context body only.
676
+ // Bounded by the next h3/h2 or the end of the section body.
677
+ const subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i;
678
+ const subMatch = accBody.match(subPattern);
679
+ if (subMatch) {
680
+ let subBody = subMatch[2];
681
+ // Dedupe: exact (trimmed) line already present is a no-op replay.
682
+ if (subBody.split('\n').some((line) => line.trim() === entry.trim())) {
683
+ duplicate = true;
684
+ return content;
685
+ }
686
+ subBody = subBody.replace(/None yet\.?\s*\n?/gi, '');
687
+ subBody = subBody.trimEnd() + '\n' + entry + '\n';
688
+ const newAccBody = accBody.replace(subPattern, (_m, header) => `${header}${subBody}`);
689
+ return content.replace(accPattern, () => `${accHeader}${newAccBody}`);
690
+ }
691
+ // Subsection missing — append it at the end of the Accumulated Context body.
692
+ subsectionCreated = true;
693
+ const trimmedAcc = accBody.trimEnd();
694
+ const block = `${trimmedAcc ? `${trimmedAcc}\n\n` : ''}### Roadmap Evolution\n\n${entry}\n`;
695
+ return content.replace(accPattern, () => `${accHeader}${block}`);
696
+ }
697
+ // No `## Accumulated Context` — DWIM: create both at end of file.
698
+ // Mirrors the add-decision / add-blocker auto-create behavior.
699
+ created = true;
700
+ subsectionCreated = true;
701
+ const scaffold = [
702
+ '',
703
+ '## Accumulated Context',
704
+ '',
705
+ '### Roadmap Evolution',
706
+ '',
707
+ entry,
708
+ '',
709
+ ].join('\n');
710
+ return content.trimEnd() + '\n' + scaffold;
711
+ }, cwd);
712
+ if (duplicate) {
713
+ output({ added: false, reason: 'duplicate', entry }, raw, 'false');
714
+ return;
715
+ }
716
+ const result = { added: true, entry };
717
+ if (created)
718
+ result['created'] = true;
719
+ if (subsectionCreated)
720
+ result['subsection_created'] = true;
721
+ output(result, raw, 'true');
722
+ }
723
+ function cmdStateResolveBlocker(cwd, text, raw) {
724
+ const statePath = planningPaths(cwd).state;
725
+ if (!node_fs_1.default.existsSync(statePath)) {
726
+ output({ error: 'STATE.md not found' }, raw, undefined);
727
+ return;
728
+ }
729
+ if (!text) {
730
+ output({ error: 'text required' }, raw, undefined);
731
+ return;
732
+ }
733
+ let resolved = false;
734
+ readModifyWriteStateMd(statePath, (content) => {
735
+ const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
736
+ const match = content.match(sectionPattern);
737
+ if (match) {
738
+ const sectionBody = match[2];
739
+ const lines = sectionBody.split('\n');
740
+ const filtered = lines.filter(line => {
741
+ if (!line.startsWith('- '))
742
+ return true;
743
+ return !line.toLowerCase().includes(text.toLowerCase());
744
+ });
745
+ let newBody = filtered.join('\n');
746
+ // If section is now empty, add placeholder
747
+ if (!newBody.trim() || !newBody.includes('- ')) {
748
+ newBody = 'None\n';
749
+ }
750
+ resolved = true;
751
+ return content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
752
+ }
753
+ return content;
754
+ }, cwd);
755
+ if (resolved) {
756
+ output({ resolved: true, blocker: text }, raw, 'true');
757
+ }
758
+ else {
759
+ output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
760
+ }
761
+ }
762
+ function cmdStateRecordSession(cwd, options, raw) {
763
+ const statePath = planningPaths(cwd).state;
764
+ if (!node_fs_1.default.existsSync(statePath)) {
765
+ output({ error: 'STATE.md not found' }, raw, undefined);
766
+ return;
767
+ }
768
+ const now = clock_cjs_1.realClock.nowIso();
769
+ const updated = [];
770
+ let sessionCreated = false;
771
+ readModifyWriteStateMd(statePath, (content) => {
772
+ // Update Last session / Last Date
773
+ let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
774
+ if (result) {
775
+ content = result;
776
+ updated.push('Last session');
777
+ }
778
+ result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Date', now);
779
+ if (result) {
780
+ content = result;
781
+ updated.push('Last Date');
782
+ }
783
+ // Update Stopped at
784
+ if (options.stopped_at) {
785
+ result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped At', options.stopped_at);
786
+ if (!result)
787
+ result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped at', options.stopped_at);
788
+ if (result) {
789
+ content = result;
790
+ updated.push('Stopped At');
791
+ }
792
+ }
793
+ // Update Resume File — only when the caller explicitly passed a value OR the
794
+ // existing value is a known template default. An executor-authored path must
795
+ // not be silently replaced with 'None' just because --resume-file was omitted
796
+ // (Knuth invariant: handler-owns-transition-between-known-template-defaults).
797
+ const resumeFileDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Resume File'];
798
+ if (options.resume_file !== undefined && options.resume_file !== null) {
799
+ // Caller explicitly passed a value — always honour it.
800
+ result = (0, state_document_cjs_1.stateReplaceField)(content, 'Resume File', options.resume_file);
801
+ if (!result)
802
+ result = (0, state_document_cjs_1.stateReplaceField)(content, 'Resume file', options.resume_file);
803
+ if (result) {
804
+ content = result;
805
+ updated.push('Resume File');
806
+ }
807
+ }
808
+ else {
809
+ // No explicit value — only set 'None' when existing value is also a known default
810
+ // (i.e. not executor-authored).
811
+ const newRf = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Resume File', resumeFileDefaults, 'None');
812
+ if (newRf !== content) {
813
+ content = newRf;
814
+ updated.push('Resume File');
815
+ }
816
+ else {
817
+ // Try alternate capitalisation
818
+ const newRfAlt = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Resume file', resumeFileDefaults, 'None');
819
+ if (newRfAlt !== content) {
820
+ content = newRfAlt;
821
+ updated.push('Resume File');
822
+ }
823
+ }
824
+ }
825
+ // Bug #944: DWIM normalize/auto-create — when the caller supplied --stopped-at or
826
+ // --resume-file but the body lacks the canonical labels (in-place replace
827
+ // returned a miss), persist the values durably. Mirrors the DWIM pattern used
828
+ // by add-decision, add-blocker, and record-metric. Never silently drop
829
+ // caller-supplied values.
830
+ //
831
+ // Guard: only act when the caller actually supplied a value. When no
832
+ // --stopped-at / --resume-file are given and the body already had no session
833
+ // labels (nothing was updated), we return recorded:false — the existing
834
+ // behaviour for a no-op call that didn't supply any values.
835
+ //
836
+ // Correctness invariant: both buildStateFrontmatter and cmdStateSnapshot read
837
+ // only the FIRST `## Session` block (via a /##\s*Session\s*\n…/i regex).
838
+ // If we blindly append a second `## Session` block when one already exists, the
839
+ // newly-written Stopped at / Resume file end up in the second (invisible) block.
840
+ // Fix: when a `## Session` heading already exists, normalize THAT block in place
841
+ // (insert / replace canonical bold-label lines within the existing section).
842
+ // A `## Session Continuity` heading (bootstrap shape) is handled additively —
843
+ // missing canonical fields are inserted while the heading and any prose are
844
+ // preserved (#1101). Only append a brand-new section when NEITHER heading exists.
845
+ const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
846
+ const needsStoppedAt = options.stopped_at && !updated.includes('Stopped At');
847
+ const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
848
+ const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date');
849
+ if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
850
+ const resumeValue = (options.resume_file !== undefined && options.resume_file !== null)
851
+ ? options.resume_file
852
+ : 'None';
853
+ const stoppedAtValue = options.stopped_at || 'None';
854
+ // Determine whether a session heading already exists in the body. The
855
+ // canonical normalized form is `## Session`; the bootstrap templates
856
+ // (workstream.cts, gsd2-import.cts, templates/state.md) instead emit
857
+ // `## Session Continuity`. Treat each separately so we never append a
858
+ // duplicate section alongside an existing one.
859
+ const existingCanonicalSession = /^## Session[ \t]*$/im.test(content);
860
+ const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content);
861
+ if (existingCanonicalSession) {
862
+ // Normalize in place: replace the ENTIRE BODY of the existing ## Session
863
+ // section (heading + all content up to the next ## heading or EOF) with
864
+ // canonical bold-label lines. The negative-lookahead per-line pattern
865
+ // `(?!^## )[\s\S]` consumes every line that doesn't start with "## ",
866
+ // which correctly stops at the next section boundary without consuming it.
867
+ // A trailing blank line is added so the next ## heading keeps its spacing.
868
+ content = content.replace(/^(## Session[ \t]*\n(?:(?!^## )[\s\S])*)/m, [
869
+ '## Session',
870
+ '',
871
+ `**Last session:** ${now}`,
872
+ `**Stopped at:** ${stoppedAtValue}`,
873
+ `**Resume file:** ${resumeValue}`,
874
+ '',
875
+ '',
876
+ ].join('\n'));
877
+ }
878
+ else if (existingSessionContinuity) {
879
+ // #1101: a `## Session Continuity` section already exists (bootstrap
880
+ // shape). Previously this fell through to the append branch and created
881
+ // a SECOND `## Session` block — a duplicate. Instead, insert only the
882
+ // canonical fields that are still missing, right after the heading,
883
+ // preserving the `## Session Continuity` heading and ALL existing lines
884
+ // (e.g. prose like "Next recommended action"). Fields already updated in
885
+ // place above (needs* false) are not re-inserted. A function replacement
886
+ // is used so `$`-bearing caller values are inserted literally (#3454).
887
+ const linesToInsert = [];
888
+ if (needsLastSession)
889
+ linesToInsert.push(`**Last session:** ${now}`);
890
+ if (needsStoppedAt)
891
+ linesToInsert.push(`**Stopped at:** ${stoppedAtValue}`);
892
+ if (needsResumeFile)
893
+ linesToInsert.push(`**Resume file:** ${resumeValue}`);
894
+ if (linesToInsert.length > 0) {
895
+ // Case-insensitive to match the `existingSessionContinuity` detection
896
+ // above (#1101 review F3) — otherwise a lowercase heading would detect
897
+ // but no-op the insert while still reporting the fields as updated.
898
+ content = content.replace(/^(## Session Continuity[ \t]*\n)/im, (_m, heading) => heading + linesToInsert.join('\n') + '\n');
899
+ }
900
+ }
901
+ else {
902
+ // No session heading exists at all — append a new canonical section.
903
+ const scaffold = [
904
+ '',
905
+ '## Session',
906
+ '',
907
+ `**Last session:** ${now}`,
908
+ `**Stopped at:** ${stoppedAtValue}`,
909
+ `**Resume file:** ${resumeValue}`,
910
+ '',
911
+ ].join('\n');
912
+ content = content.trimEnd() + '\n' + scaffold;
913
+ }
914
+ sessionCreated = true;
915
+ if (needsLastSession)
916
+ updated.push('Last session');
917
+ if (needsStoppedAt)
918
+ updated.push('Stopped At');
919
+ if (needsResumeFile)
920
+ updated.push('Resume File');
921
+ }
922
+ return content;
923
+ }, cwd);
924
+ if (updated.length > 0) {
925
+ const result = { recorded: true, updated };
926
+ if (sessionCreated)
927
+ result['created'] = true;
928
+ output(result, raw, 'true');
929
+ }
930
+ else {
931
+ output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
932
+ }
933
+ }
934
+ /**
935
+ * Match the session section body from a STATE.md body. #1101: recognise the
936
+ * bootstrap `## Session Continuity` heading but PREFER the normalized `## Session`
937
+ * block when both exist (legacy duplicate files), so the reader agrees with the
938
+ * writer (which updates `## Session` first). `(?:^|\n)` line-anchors (kept out of
939
+ * `/m` so `$` stays end-of-string for the `(?=\n##|$)` section boundary), which
940
+ * excludes an h3 `### Session Continuity`; the trailing-` Archive` boundary still
941
+ * excludes `## Session Continuity Archive` (preserving the #2444 scoping).
942
+ * Returns the match whose group 1 is the section body, or null.
943
+ */
944
+ function matchSessionSection(body) {
945
+ return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i)
946
+ || body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i);
947
+ }
948
+ function parseProsePhaseField(value) {
949
+ if (!value)
950
+ return { phase: null, name: null };
951
+ const phaseMatch = value.match(/\b(\d+[A-Z]?(?:\.\d+)*)\b/i);
952
+ const parenName = value.match(/\(([^)]+)\)/);
953
+ const dashName = value.match(/—\s*([^(\n]+?)(?:\s*\(|$)/);
954
+ const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
955
+ const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim())
956
+ ? rawName.trim()
957
+ : null;
958
+ return {
959
+ phase: phaseMatch ? phaseMatch[1] : null,
960
+ name,
961
+ };
962
+ }
963
+ function parseProseLastActivityField(value) {
964
+ if (!value)
965
+ return { date: null, description: null };
966
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})(?:\s+[—-]{1,2}\s+(.+))?$/);
967
+ if (!match)
968
+ return { date: value, description: null };
969
+ return {
970
+ date: match[1],
971
+ description: match[2]?.trim() || null,
972
+ };
973
+ }
974
+ function cmdStateSnapshot(cwd, raw) {
975
+ const statePath = planningPaths(cwd).state;
976
+ if (!node_fs_1.default.existsSync(statePath)) {
977
+ output({ error: 'STATE.md not found' }, raw, undefined);
978
+ return;
979
+ }
980
+ const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
981
+ // Bug #3265: prefer YAML frontmatter for canonical scalar fields so that a
982
+ // body table cell containing **Status:** Y cannot shadow the authoritative
983
+ // frontmatter value. Mirrors the fix in sdk/src/query/state.ts.
984
+ const fm = extractFrontmatter(content);
985
+ const body = stripFrontmatter(content);
986
+ // Helper: return frontmatter scalar value when present and non-empty.
987
+ // Accepts strings, numbers, and booleans — coercing non-string primitives to
988
+ // their string representation so callers always receive string | null.
989
+ // Returns null for missing, null/undefined, or empty-after-trim values so
990
+ // the caller falls back to body extraction.
991
+ const fmScalar = (key) => {
992
+ const v = fm[key];
993
+ if (v === null || v === undefined)
994
+ return null;
995
+ if (typeof v === 'string')
996
+ return v.trim() || null;
997
+ if (typeof v === 'number' || typeof v === 'boolean')
998
+ return String(v);
999
+ return null;
1000
+ };
1001
+ // Extract basic fields — frontmatter keys take precedence over body
1002
+ const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(body, 'Phase'));
1003
+ const currentPhase = fmScalar('current_phase') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') ?? prosePhase.phase;
1004
+ const currentPhaseName = fmScalar('current_phase_name') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase Name') ?? prosePhase.name;
1005
+ const totalPhasesRaw = fmScalar('total_phases') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Total Phases');
1006
+ const currentPlan = fmScalar('current_plan') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
1007
+ const totalPlansRaw = fmScalar('total_plans_in_phase') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Total Plans in Phase');
1008
+ const status = fmScalar('status') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Status');
1009
+ const progressRaw = fmScalar('progress') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Progress');
1010
+ const rawLastActivity = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last activity');
1011
+ const proseLastActivity = parseProseLastActivityField(rawLastActivity);
1012
+ const lastActivity = fmScalar('last_activity') ?? proseLastActivity.date ?? rawLastActivity;
1013
+ const lastActivityDesc = fmScalar('last_activity_desc') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity Description') ?? proseLastActivity.description;
1014
+ const pausedAt = fmScalar('paused_at') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Paused At');
1015
+ // Parse numeric fields
1016
+ const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1017
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1018
+ const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
1019
+ // Extract decisions table
1020
+ const decisions = [];
1021
+ const decisionsMatch = body.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
1022
+ if (decisionsMatch) {
1023
+ const tableBody = decisionsMatch[1];
1024
+ const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
1025
+ for (const row of rows) {
1026
+ const cells = row.split('|').map(c => c.trim()).filter(Boolean);
1027
+ if (cells.length >= 3) {
1028
+ decisions.push({
1029
+ phase: cells[0],
1030
+ summary: cells[1],
1031
+ rationale: cells[2],
1032
+ });
1033
+ }
1034
+ }
1035
+ }
1036
+ // Extract blockers list
1037
+ const blockers = [];
1038
+ const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
1039
+ if (blockersMatch) {
1040
+ const blockersSection = blockersMatch[1];
1041
+ const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
1042
+ for (const item of items) {
1043
+ blockers.push(item.replace(/^-\s+/, '').trim());
1044
+ }
1045
+ }
1046
+ // Extract session info
1047
+ const session = {
1048
+ last_date: null,
1049
+ stopped_at: null,
1050
+ resume_file: null,
1051
+ };
1052
+ // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
1053
+ // `## Session Continuity` heading. See matchSessionSection for the anchoring.
1054
+ const sessionMatch = matchSessionSection(body);
1055
+ if (sessionMatch) {
1056
+ const sessionSection = sessionMatch[1];
1057
+ // Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
1058
+ // (the form written by the DWIM auto-create / normalize path added for #944).
1059
+ const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
1060
+ || sessionSection.match(/^Last Date:\s*(.+)/im)
1061
+ || sessionSection.match(/\*\*Last session:\*\*\s*(.+)/i)
1062
+ || sessionSection.match(/^Last session:\s*(.+)/im);
1063
+ const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i)
1064
+ || sessionSection.match(/^Stopped At:\s*(.+)/im);
1065
+ const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i)
1066
+ || sessionSection.match(/^Resume File:\s*(.+)/im);
1067
+ if (lastDateMatch)
1068
+ session.last_date = lastDateMatch[1].trim();
1069
+ if (stoppedAtMatch)
1070
+ session.stopped_at = stoppedAtMatch[1].trim();
1071
+ if (resumeFileMatch)
1072
+ session.resume_file = resumeFileMatch[1].trim();
1073
+ }
1074
+ const result = {
1075
+ current_phase: currentPhase,
1076
+ current_phase_name: currentPhaseName,
1077
+ total_phases: totalPhases,
1078
+ current_plan: currentPlan,
1079
+ total_plans_in_phase: totalPlansInPhase,
1080
+ status,
1081
+ progress_percent: progressPercent,
1082
+ last_activity: lastActivity,
1083
+ last_activity_desc: lastActivityDesc,
1084
+ decisions,
1085
+ blockers,
1086
+ paused_at: pausedAt,
1087
+ session,
1088
+ };
1089
+ output(result, raw, undefined);
1090
+ }
1091
+ // ─── State Frontmatter Sync ──────────────────────────────────────────────────
1092
+ /**
1093
+ * Extract machine-readable fields from STATE.md markdown body and build
1094
+ * a YAML frontmatter object. Allows hooks and scripts to read state
1095
+ * reliably via `state json` instead of fragile regex parsing.
1096
+ */
1097
+ function buildStateFrontmatter(bodyContent, cwd) {
1098
+ const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(bodyContent, 'Phase'));
1099
+ const currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase') ?? prosePhase.phase;
1100
+ const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name') ?? prosePhase.name;
1101
+ const currentPlan = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Plan');
1102
+ const totalPhasesRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Total Phases');
1103
+ const totalPlansRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Total Plans in Phase');
1104
+ const status = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Status');
1105
+ const progressRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Progress');
1106
+ const rawLastActivity = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last activity');
1107
+ const proseLastActivity = parseProseLastActivityField(rawLastActivity);
1108
+ const lastActivity = proseLastActivity.date ?? rawLastActivity;
1109
+ const lastActivityDesc = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last Activity Description') ?? proseLastActivity.description;
1110
+ // Bug #2444: scope Stopped At extraction to the ## Session section so that
1111
+ // historical "Stopped at:" prose elsewhere in the body (e.g. in a
1112
+ // Session Continuity Archive section) never overwrites the current value.
1113
+ // Fall back to full-body search only when no ## Session section exists.
1114
+ // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
1115
+ // `## Session Continuity` heading. See matchSessionSection for the anchoring.
1116
+ const sessionSectionMatch = matchSessionSection(bodyContent);
1117
+ const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent;
1118
+ const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
1119
+ const pausedAt = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Paused At');
1120
+ let milestone = null;
1121
+ let milestoneName = null;
1122
+ if (cwd) {
1123
+ try {
1124
+ const info = getMilestoneInfo(cwd);
1125
+ milestone = info.version;
1126
+ milestoneName = info.name;
1127
+ }
1128
+ catch { /* intentionally empty */ }
1129
+ }
1130
+ let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1131
+ let completedPhases = null;
1132
+ let totalPlans = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1133
+ let completedPlans = null;
1134
+ if (cwd) {
1135
+ try {
1136
+ const phasesDir = planningPaths(cwd).phases;
1137
+ if (node_fs_1.default.existsSync(phasesDir)) {
1138
+ // Use cached disk scan when available — avoids N+1 readdirSync calls
1139
+ // on repeated buildStateFrontmatter invocations within the same process (#1967)
1140
+ let cached = _diskScanCache.get(cwd);
1141
+ if (!cached) {
1142
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
1143
+ const allMatchingDirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
1144
+ .filter(e => e.isDirectory()).map(e => e.name)
1145
+ .filter(isDirInMilestone);
1146
+ // Bug #2445: when stale phase dirs from a prior milestone remain in
1147
+ // .planning/phases/ alongside new dirs with the same phase number,
1148
+ // de-duplicate by normalized phase number keeping the most recently
1149
+ // modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
1150
+ const seenPhaseNums = new Map(); // normalizedNum -> dirName
1151
+ for (const dir of allMatchingDirs) {
1152
+ const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1153
+ const key = m ? m[1].toLowerCase() : dir;
1154
+ if (!seenPhaseNums.has(key)) {
1155
+ seenPhaseNums.set(key, dir);
1156
+ }
1157
+ else {
1158
+ // Keep the dir that is newer on disk (more likely current milestone)
1159
+ try {
1160
+ const existing = node_path_1.default.join(phasesDir, seenPhaseNums.get(key));
1161
+ const candidate = node_path_1.default.join(phasesDir, dir);
1162
+ if (node_fs_1.default.statSync(candidate).mtimeMs > node_fs_1.default.statSync(existing).mtimeMs) {
1163
+ seenPhaseNums.set(key, dir);
1164
+ }
1165
+ }
1166
+ catch { /* keep existing on stat error */ }
1167
+ }
1168
+ }
1169
+ const phaseDirs = [...seenPhaseNums.values()];
1170
+ let diskTotalPlans = 0;
1171
+ let diskTotalSummaries = 0;
1172
+ let diskCompletedPhases = 0;
1173
+ for (const dir of phaseDirs) {
1174
+ const phaseDir = node_path_1.default.join(phasesDir, dir);
1175
+ const { planCount, summaryCount, completed } = scanPhasePlans(phaseDir);
1176
+ diskTotalPlans += planCount;
1177
+ diskTotalSummaries += summaryCount;
1178
+ if (completed)
1179
+ diskCompletedPhases++;
1180
+ }
1181
+ // Count phase headings from ROADMAP using a digit-containing pattern
1182
+ // that matches both numeric phases (01, 05.1) and project-code phases
1183
+ // (PROJ-42, CK-05) but excludes pure-word section headers like
1184
+ // `## Phase Overview:` or `## Phase Details:` — single source of
1185
+ // truth for total_phases (#549).
1186
+ let roadmapPhaseCount = 0;
1187
+ try {
1188
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1189
+ const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1190
+ if (roadmapRaw !== null) {
1191
+ const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1192
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1193
+ let m;
1194
+ while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
1195
+ // Only count tokens that contain at least one digit — excludes
1196
+ // pure-word section headings (Overview, Details) while keeping
1197
+ // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
1198
+ if (/\d/.test(m[1]))
1199
+ roadmapPhaseCount++;
1200
+ }
1201
+ }
1202
+ }
1203
+ catch { /* fall through: phaseDirs.length used as sole count */ }
1204
+ cached = {
1205
+ totalPhases: roadmapPhaseCount > 0
1206
+ ? Math.max(phaseDirs.length, roadmapPhaseCount)
1207
+ : phaseDirs.length,
1208
+ completedPhases: diskCompletedPhases,
1209
+ totalPlans: diskTotalPlans,
1210
+ completedPlans: diskTotalSummaries,
1211
+ };
1212
+ _diskScanCache.set(cwd, cached);
1213
+ }
1214
+ totalPhases = cached.totalPhases;
1215
+ completedPhases = cached.completedPhases;
1216
+ totalPlans = cached.totalPlans;
1217
+ completedPlans = cached.completedPlans;
1218
+ }
1219
+ }
1220
+ catch { /* intentionally empty */ }
1221
+ }
1222
+ // Derive percent from disk counts when available (ground truth).
1223
+ // Uses min(plan_fraction, phase_fraction) via computeProgressPercent so that
1224
+ // ROADMAP-declared-but-unrealized future phases cap the reported completion
1225
+ // instead of a false 100% from plan-only coverage (#3242 Bug B).
1226
+ // Falls back to the body Progress: field only when no plan files exist on disk.
1227
+ let progressPercent = (0, state_document_cjs_1.computeProgressPercent)(completedPlans, totalPlans, completedPhases, totalPhases);
1228
+ if (progressPercent === null && progressRaw) {
1229
+ const pctMatch = progressRaw.match(/(\d+)%/);
1230
+ if (pctMatch)
1231
+ progressPercent = parseInt(pctMatch[1], 10);
1232
+ }
1233
+ const normalizedStatus = (0, state_document_cjs_1.normalizeStateStatus)(status, pausedAt);
1234
+ const fm = { gsd_state_version: '1.0' };
1235
+ if (milestone)
1236
+ fm['milestone'] = milestone;
1237
+ if (milestoneName)
1238
+ fm['milestone_name'] = milestoneName;
1239
+ if (currentPhase)
1240
+ fm['current_phase'] = currentPhase;
1241
+ if (currentPhaseName)
1242
+ fm['current_phase_name'] = currentPhaseName;
1243
+ if (currentPlan)
1244
+ fm['current_plan'] = currentPlan;
1245
+ fm['status'] = normalizedStatus;
1246
+ if (stoppedAt)
1247
+ fm['stopped_at'] = stoppedAt;
1248
+ if (pausedAt)
1249
+ fm['paused_at'] = pausedAt;
1250
+ fm['last_updated'] = clock_cjs_1.realClock.nowIso();
1251
+ if (lastActivity)
1252
+ fm['last_activity'] = lastActivity;
1253
+ if (lastActivityDesc)
1254
+ fm['last_activity_desc'] = lastActivityDesc;
1255
+ const progress = {};
1256
+ if (totalPhases !== null)
1257
+ progress['total_phases'] = totalPhases;
1258
+ if (completedPhases !== null)
1259
+ progress['completed_phases'] = completedPhases;
1260
+ if (totalPlans !== null)
1261
+ progress['total_plans'] = totalPlans;
1262
+ if (completedPlans !== null)
1263
+ progress['completed_plans'] = completedPlans;
1264
+ if (progressPercent !== null)
1265
+ progress['percent'] = progressPercent;
1266
+ if (Object.keys(progress).length > 0)
1267
+ fm['progress'] = progress;
1268
+ return fm;
1269
+ }
1270
+ function stripFrontmatter(content) {
1271
+ // Strip ALL frontmatter blocks at the start of the file.
1272
+ // Handles CRLF line endings and multiple stacked blocks (corruption recovery).
1273
+ // Greedy: keeps stripping ---...--- blocks separated by optional whitespace.
1274
+ let result = content;
1275
+ while (true) {
1276
+ const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
1277
+ if (stripped === result)
1278
+ break;
1279
+ result = stripped;
1280
+ }
1281
+ return result;
1282
+ }
1283
+ function syncStateFrontmatter(content, cwd) {
1284
+ // Read existing frontmatter BEFORE stripping — it may contain values
1285
+ // that the body no longer has (e.g., Status field removed by an agent).
1286
+ const existingFm = extractFrontmatter(content);
1287
+ const body = stripFrontmatter(content);
1288
+ const derivedFm = buildStateFrontmatter(body, cwd);
1289
+ // Preserve existing frontmatter status when body-derived status is 'unknown'.
1290
+ // This prevents a missing Status: field in the body from overwriting a
1291
+ // previously valid status (e.g., 'executing' → 'unknown').
1292
+ if (derivedFm['status'] === 'unknown' && existingFm['status'] && existingFm['status'] !== 'unknown') {
1293
+ derivedFm['status'] = existingFm['status'];
1294
+ }
1295
+ // Bug #948: preserve `milestone_name` / `milestone` when the derived value
1296
+ // is the template placeholder 'milestone'. getMilestoneInfo returns the
1297
+ // literal string 'milestone' when it cannot match the version from the roadmap
1298
+ // (e.g. no ROADMAP.md, roadmap lacks the heading for the stored version, or the
1299
+ // milestone version read from STATE.md itself triggers the lookup before the
1300
+ // file is fully written). A placeholder must never overwrite a real name that the
1301
+ // existing frontmatter already holds; only an empty derived value falls through
1302
+ // to this guard (the primary #905 preserve path below handles that).
1303
+ const MILESTONE_NAME_PLACEHOLDER = 'milestone';
1304
+ if (derivedFm['milestone_name'] === MILESTONE_NAME_PLACEHOLDER &&
1305
+ existingFm['milestone_name'] &&
1306
+ existingFm['milestone_name'] !== MILESTONE_NAME_PLACEHOLDER) {
1307
+ derivedFm['milestone_name'] = existingFm['milestone_name'];
1308
+ // Keep the stored milestone version consistent with the preserved name.
1309
+ if (existingFm['milestone']) {
1310
+ derivedFm['milestone'] = existingFm['milestone'];
1311
+ }
1312
+ }
1313
+ // Bug #905: preserve scalar fields that buildStateFrontmatter can only derive
1314
+ // from body annotations (Current Phase:, Current Plan:, etc.). When those
1315
+ // annotations are absent — e.g. after an agent or tool rewrites the body —
1316
+ // buildStateFrontmatter returns no value for those keys. Mirror the same
1317
+ // fallback pattern used in cmdStateJson so the existing frontmatter values
1318
+ // survive every writeStateMd call.
1319
+ //
1320
+ // For stopped_at / paused_at: the original #905 "fall back when derived is
1321
+ // absent" rule is preserved here. The stale-body-overwrites-frontmatter
1322
+ // scenario from #948 is prevented by the no-op guard in
1323
+ // readModifyWriteStateMd: when the transform produces no change the file is
1324
+ // never written, so syncStateFrontmatter never even runs. Attempting to
1325
+ // "always prefer frontmatter" here breaks legitimate callers like phase.complete
1326
+ // that intentionally write a new stopped_at value to the body and expect
1327
+ // syncStateFrontmatter to pick it up.
1328
+ if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
1329
+ derivedFm['stopped_at'] = existingFm['stopped_at'];
1330
+ }
1331
+ if (!derivedFm['paused_at'] && existingFm['paused_at']) {
1332
+ derivedFm['paused_at'] = existingFm['paused_at'];
1333
+ }
1334
+ if (!derivedFm['current_phase'] && existingFm['current_phase']) {
1335
+ derivedFm['current_phase'] = existingFm['current_phase'];
1336
+ }
1337
+ if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
1338
+ derivedFm['current_phase_name'] = existingFm['current_phase_name'];
1339
+ }
1340
+ if (!derivedFm['current_plan'] && existingFm['current_plan']) {
1341
+ derivedFm['current_plan'] = existingFm['current_plan'];
1342
+ }
1343
+ // progress is a sub-object: fall back to existing only when the body+disk
1344
+ // scan produced NO progress block at all. When buildStateFrontmatter did
1345
+ // derive a progress block (even a lower one), that derived value wins — the
1346
+ // shouldPreserveExistingProgress cross-milestone logic is applied later in
1347
+ // cmdStateJson on the read path where it is appropriate.
1348
+ if (!derivedFm['progress'] && existingFm['progress']) {
1349
+ derivedFm['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
1350
+ }
1351
+ const yamlStr = reconstructFrontmatter(derivedFm);
1352
+ return `---\n${yamlStr}\n---\n\n${body}`;
1353
+ }
1354
+ // Transient errno codes that indicate a temporary filesystem condition under
1355
+ // concurrent O_EXCL races — Docker overlay-fs (ENOENT/EINVAL/EIO), NFS
1356
+ // (ESTALE), and OS-level interrupt/retry signals (EAGAIN/EINTR). These are
1357
+ // recoverable; acquireStateLock retries instead of propagating them.
1358
+ // Truly fatal codes (EMFILE, ENOSPC, EROFS, EACCES) are NOT in this set and
1359
+ // will still throw immediately.
1360
+ const ACQUIRE_LOCK_RETRY_ERRNOS = new Set([
1361
+ 'EPERM', // Windows / macOS AV scanner holds the file open during delete
1362
+ 'EBUSY', // Windows: file in use by another process
1363
+ 'EAGAIN', // POSIX: resource temporarily unavailable
1364
+ 'EINTR', // POSIX: syscall interrupted by signal
1365
+ 'EINVAL', // Docker overlay-fs: transient during concurrent O_EXCL creation
1366
+ 'EIO', // Docker overlay-fs / NFS: transient I/O error
1367
+ 'ENOENT', // Docker overlay-fs: parent dir transiently missing during race
1368
+ 'ESTALE', // NFS: stale file handle (self-resolves on retry)
1369
+ ]);
1370
+ /**
1371
+ * Acquire a lockfile for STATE.md operations.
1372
+ * Returns the lock path for later release.
1373
+ *
1374
+ * @param statePath
1375
+ * @param clock
1376
+ * Optional clock seam for testing. Defaults to realClock (Date.now + Atomics.wait).
1377
+ * Pass a fake clock from tests/helpers/clock.cjs to drive timeout/stale logic
1378
+ * without real wall-clock waits.
1379
+ */
1380
+ function acquireStateLock(statePath, clock) {
1381
+ if (clock === undefined)
1382
+ clock = clock_cjs_1.realClock;
1383
+ const lockPath = statePath + '.lock';
1384
+ const retryDelay = 200; // ms
1385
+ const staleThresholdMs = 10000;
1386
+ const maxWaitMs = 30000;
1387
+ const startedAt = clock.now();
1388
+ // Shared helper: check the time budget then back off with jitter before the
1389
+ // next retry. Both the EEXIST contention path and the recoverable-errno path
1390
+ // must go through this so neither can busy-spin (#1217).
1391
+ const checkBudgetAndSleep = (context) => {
1392
+ if (clock.now() - startedAt >= maxWaitMs) {
1393
+ const e = new Error('acquireStateLock: ' + lockPath + ' ' + context + ' for ' +
1394
+ (clock.now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)');
1395
+ e.lockBudgetExceeded = true;
1396
+ throw e;
1397
+ }
1398
+ const jitter = Math.floor(Math.random() * 50);
1399
+ clock.sleep(retryDelay + jitter);
1400
+ };
1401
+ while (true) {
1402
+ try {
1403
+ const fd = node_fs_1.default.openSync(lockPath, node_fs_1.default.constants.O_CREAT | node_fs_1.default.constants.O_EXCL | node_fs_1.default.constants.O_WRONLY);
1404
+ node_fs_1.default.writeSync(fd, String(process.pid));
1405
+ node_fs_1.default.closeSync(fd);
1406
+ // Exit-time cleanup keeps a crashed locked region from leaving a stale file (#1916).
1407
+ _heldStateLocks.add(lockPath);
1408
+ return lockPath;
1409
+ }
1410
+ catch (err) {
1411
+ // Transient filesystem errors (Docker overlay-fs, NFS, OS signals, AV scanners)
1412
+ // are recoverable — retry with the same budget + backoff as the EEXIST path so
1413
+ // a permanently-failing errno cannot busy-spin at 100% CPU (#1217).
1414
+ // See ACQUIRE_LOCK_RETRY_ERRNOS for the full list and rationale.
1415
+ if (ACQUIRE_LOCK_RETRY_ERRNOS.has(err.code)) {
1416
+ checkBudgetAndSleep(err.code + ' persisted');
1417
+ continue;
1418
+ }
1419
+ if (err.code !== 'EEXIST')
1420
+ throw err; // propagate — silent bypass causes lost updates
1421
+ // Only unlink a lock we did not place when it has crossed the staleness
1422
+ // threshold (crashed holder). Nuking a fresh lock held by a slow-but-live
1423
+ // writer causes lost updates (#3711 regression).
1424
+ try {
1425
+ const stat = node_fs_1.default.statSync(lockPath);
1426
+ if ((clock).now() - stat.mtimeMs > staleThresholdMs) {
1427
+ let removed = false;
1428
+ try {
1429
+ node_fs_1.default.unlinkSync(lockPath);
1430
+ removed = true;
1431
+ }
1432
+ catch { /* swallow: bounded below */ }
1433
+ if (removed) {
1434
+ // Successful steal — retry immediately to grab the just-freed lock.
1435
+ // Must NOT call checkBudgetAndSleep here: a throw-after-delete would
1436
+ // corrupt the filesystem state, and the budget is already bounded on
1437
+ // the next iteration's EEXIST or open attempt (#1217 regression fix).
1438
+ continue;
1439
+ }
1440
+ // Persistent unlinkSync failure — apply budget + backoff so it cannot
1441
+ // busy-spin (#1217).
1442
+ checkBudgetAndSleep('stale lock removal failed');
1443
+ continue;
1444
+ }
1445
+ }
1446
+ catch (err) {
1447
+ // Re-throw a budget-exceeded error from the unlinkSync failure path above
1448
+ // unchanged — its message already names the real cause ("stale lock removal
1449
+ // failed") and double-wrapping it would replace that with the misleading
1450
+ // "statSync failed after EEXIST" context string (#1217 diagnostic fix).
1451
+ if (err?.lockBudgetExceeded)
1452
+ throw err;
1453
+ // statSync failed — lock was likely released between our EEXIST and this
1454
+ // stat call. Apply budget + backoff so a persistent statSync failure
1455
+ // cannot busy-spin (#1217).
1456
+ checkBudgetAndSleep('statSync failed after EEXIST');
1457
+ continue;
1458
+ }
1459
+ checkBudgetAndSleep('held by live process');
1460
+ }
1461
+ }
1462
+ }
1463
+ function releaseStateLock(lockPath) {
1464
+ _heldStateLocks.delete(lockPath);
1465
+ try {
1466
+ node_fs_1.default.unlinkSync(lockPath);
1467
+ }
1468
+ catch { /* lock already gone */ }
1469
+ }
1470
+ function withStateLock(statePath, fn) {
1471
+ const lockPath = acquireStateLock(statePath);
1472
+ try {
1473
+ return fn();
1474
+ }
1475
+ finally {
1476
+ releaseStateLock(lockPath);
1477
+ }
1478
+ }
1479
+ /**
1480
+ * Write STATE.md with synchronized YAML frontmatter.
1481
+ * All STATE.md writes should use this instead of raw writeFileSync.
1482
+ * Uses a simple lockfile to prevent parallel agents from overwriting
1483
+ * each other's changes (race condition with read-modify-write cycle).
1484
+ *
1485
+ * @param statePath
1486
+ * @param content
1487
+ * @param cwd
1488
+ * @param clock
1489
+ * Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
1490
+ */
1491
+ function writeStateMd(statePath, content, cwd, clock) {
1492
+ // Invalidate disk scan cache before computing new frontmatter — the write
1493
+ // may create new PLAN/SUMMARY files that buildStateFrontmatter must see.
1494
+ // Safe for any calling pattern, not just short-lived CLI processes (#1967).
1495
+ if (cwd)
1496
+ _diskScanCache.delete(cwd);
1497
+ const synced = syncStateFrontmatter(content, cwd);
1498
+ const lockPath = acquireStateLock(statePath, clock);
1499
+ try {
1500
+ (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
1501
+ }
1502
+ finally {
1503
+ releaseStateLock(lockPath);
1504
+ }
1505
+ }
1506
+ /**
1507
+ * Atomic read-modify-write for STATE.md.
1508
+ * Holds the lock across the entire read -> transform -> write cycle,
1509
+ * preventing the lost-update problem where two agents read the same
1510
+ * content and the second write clobbers the first.
1511
+ *
1512
+ * @param statePath
1513
+ * @param transformFn - (content: string) => string
1514
+ * @param cwd
1515
+ * @param options
1516
+ * resync: when true (default) rebuilds the entire frontmatter from disk after
1517
+ * the transform. Pass { resync: false } for body-only updates (e.g. state.update
1518
+ * on a single field) that must not trample manually-curated cross-milestone
1519
+ * progress.* counters in the frontmatter (#3242 Bug A).
1520
+ * When resync is false, syncStateFrontmatter still runs to maintain/create the
1521
+ * frontmatter block, but any existing progress.* sub-keys are preserved from
1522
+ * the pre-transform file rather than being rebuilt from disk.
1523
+ * @param clock
1524
+ * Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
1525
+ */
1526
+ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1527
+ const resync = !options || options.resync !== false;
1528
+ const lockPath = acquireStateLock(statePath, clock);
1529
+ try {
1530
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
1531
+ // Snapshot the existing progress block BEFORE the transform so we can
1532
+ // restore it when resync is false.
1533
+ const preFm = resync ? null : extractFrontmatter(content);
1534
+ // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
1535
+ // we can detect whether THIS write changed them. syncStateFrontmatter
1536
+ // re-derives frontmatter status/stopped_at from the body on every write;
1537
+ // when the body's source field was NOT changed by the transform, the
1538
+ // existing frontmatter value (e.g. a hand-set 'completed') must win over
1539
+ // the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
1540
+ // Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
1541
+ // above (null when resync:true) — these are independent snapshots.
1542
+ // Strip frontmatter before calling stateExtractField so the YAML `status:`
1543
+ // key in the frontmatter block cannot shadow the body field we are tracking.
1544
+ const preBody = stripFrontmatter(content);
1545
+ const preFmSnapshot = extractFrontmatter(content);
1546
+ const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
1547
+ // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1548
+ // mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172).
1549
+ // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
1550
+ // Archive prose) must not interfere with the delta comparison.
1551
+ const preSessionMatch = matchSessionSection(preBody);
1552
+ const preSessionScope = preSessionMatch ? preSessionMatch[1] : preBody;
1553
+ const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
1554
+ const modified = transformFn(content);
1555
+ // Bug #948: no-op guard — if the transform produced no change, do NOT write
1556
+ // the file. An unconditional write would bump `last_updated`, reset
1557
+ // `milestone_name` to the template placeholder, and resurrect stale
1558
+ // body-derived `stopped_at` values via syncStateFrontmatter. Skipping the
1559
+ // write when content is unchanged is safe because every caller that mutates
1560
+ // content already returns the mutated string, and callers that detect a
1561
+ // no-op explicitly return the original content unchanged.
1562
+ if (modified === content) {
1563
+ return;
1564
+ }
1565
+ let synced = syncStateFrontmatter(modified, cwd);
1566
+ // Compute postFm once and apply BOTH the progress-restore (when !resync)
1567
+ // AND the status/stopped_at preservation (#1230) before reconstructing.
1568
+ // This avoids double-wrapping the frontmatter block.
1569
+ const needsProgressRestore = !resync && preFm && preFm['progress'];
1570
+ // Post-transform body source fields used for the delta comparison (#1230).
1571
+ // Use `modified` (not `synced`): syncStateFrontmatter only rewrites the frontmatter block, so the body is identical in both — and we need the body the transform produced.
1572
+ // Strip frontmatter so the YAML status key cannot shadow the body field.
1573
+ const postBody = stripFrontmatter(modified);
1574
+ const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
1575
+ // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1576
+ // consistent with the pre-transform snapshot above and buildStateFrontmatter.
1577
+ const postSessionMatch = matchSessionSection(postBody);
1578
+ const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
1579
+ const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
1580
+ let mutated = false;
1581
+ const postFm = extractFrontmatter(synced);
1582
+ if (needsProgressRestore) {
1583
+ // Re-apply the curated progress block that syncStateFrontmatter just
1584
+ // overwrote with disk-derived values. Only restore keys that were present
1585
+ // in the snapshot — this preserves any new non-progress frontmatter fields
1586
+ // (e.g., status, current_phase) that syncStateFrontmatter legitimately
1587
+ // derived from the updated body.
1588
+ postFm['progress'] = preFm['progress'];
1589
+ mutated = true;
1590
+ }
1591
+ // Bug #1230: preserve existing frontmatter status when this write did NOT
1592
+ // change the body's Status field. A write that doesn't touch Status must
1593
+ // not silently revert a hand-set frontmatter status (e.g. 'completed') to
1594
+ // whatever the stale body Status happens to derive (e.g. 'verifying').
1595
+ // Only apply when the existing frontmatter held a real, non-unknown status.
1596
+ if (postBodyStatus === preBodyStatus &&
1597
+ typeof preFmSnapshot['status'] === 'string' &&
1598
+ preFmSnapshot['status'].length > 0 &&
1599
+ preFmSnapshot['status'] !== 'unknown' &&
1600
+ postFm['status'] !== preFmSnapshot['status']) {
1601
+ postFm['status'] = preFmSnapshot['status'];
1602
+ mutated = true;
1603
+ }
1604
+ // Bug #1230: same delta heuristic for stopped_at.
1605
+ if (postBodyStoppedAt === preBodyStoppedAt &&
1606
+ typeof preFmSnapshot['stopped_at'] === 'string' &&
1607
+ preFmSnapshot['stopped_at'].length > 0 &&
1608
+ postFm['stopped_at'] !== preFmSnapshot['stopped_at']) {
1609
+ postFm['stopped_at'] = preFmSnapshot['stopped_at'];
1610
+ mutated = true;
1611
+ }
1612
+ if (mutated) {
1613
+ const yamlStr = reconstructFrontmatter(postFm);
1614
+ const body = stripFrontmatter(synced);
1615
+ synced = `---\n${yamlStr}\n---\n\n${body}`;
1616
+ }
1617
+ (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
1618
+ }
1619
+ finally {
1620
+ releaseStateLock(lockPath);
1621
+ }
1622
+ }
1623
+ function cmdStateJson(cwd, raw) {
1624
+ const statePath = planningPaths(cwd).state;
1625
+ if (!node_fs_1.default.existsSync(statePath)) {
1626
+ output({ error: 'STATE.md not found' }, raw, 'STATE.md not found');
1627
+ return;
1628
+ }
1629
+ const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
1630
+ const existingFm = extractFrontmatter(content);
1631
+ const body = stripFrontmatter(content);
1632
+ // Always rebuild from body + disk so progress counters reflect current state.
1633
+ // Returning cached frontmatter directly causes stale percent/completed_plans
1634
+ // when SUMMARY files were added after the last STATE.md write (#1589).
1635
+ const built = buildStateFrontmatter(body, cwd);
1636
+ // Preserve frontmatter-only fields that cannot be recovered from the body.
1637
+ if (existingFm && existingFm['stopped_at'] && !built['stopped_at']) {
1638
+ built['stopped_at'] = existingFm['stopped_at'];
1639
+ }
1640
+ if (existingFm && existingFm['paused_at'] && !built['paused_at']) {
1641
+ built['paused_at'] = existingFm['paused_at'];
1642
+ }
1643
+ // Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
1644
+ if (built['status'] === 'unknown' && existingFm && existingFm['status'] && existingFm['status'] !== 'unknown') {
1645
+ built['status'] = existingFm['status'];
1646
+ }
1647
+ // Bug #905: preserve scalar fields when body annotations are absent.
1648
+ // Mirrors the same fallback pattern applied in syncStateFrontmatter.
1649
+ if (existingFm && !built['current_phase'] && existingFm['current_phase']) {
1650
+ built['current_phase'] = existingFm['current_phase'];
1651
+ }
1652
+ if (existingFm && !built['current_phase_name'] && existingFm['current_phase_name']) {
1653
+ built['current_phase_name'] = existingFm['current_phase_name'];
1654
+ }
1655
+ if (existingFm && !built['current_plan'] && existingFm['current_plan']) {
1656
+ built['current_plan'] = existingFm['current_plan'];
1657
+ }
1658
+ // Preserve curated cross-milestone aggregates when local disk scanning sees
1659
+ // only a narrower realized subset (#3242 Bug A). Stale lower counters still
1660
+ // rebuild from disk because they do not exceed the derived scan.
1661
+ if (existingFm && (0, state_document_cjs_1.shouldPreserveExistingProgress)(existingFm['progress'], built['progress'])) {
1662
+ built['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
1663
+ }
1664
+ output(built, raw, JSON.stringify(built, null, 2));
1665
+ }
1666
+ /**
1667
+ * Update STATE.md when a new phase begins execution.
1668
+ * Updates body text fields (Current focus, Status, Last Activity, Current Position)
1669
+ * and synchronizes frontmatter via writeStateMd.
1670
+ * Fixes: #1102 (plan counts), #1103 (status/last_activity), #1104 (body text).
1671
+ */
1672
+ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1673
+ const statePath = planningPaths(cwd).state;
1674
+ if (!node_fs_1.default.existsSync(statePath)) {
1675
+ output({ error: 'STATE.md not found' }, raw, undefined);
1676
+ return;
1677
+ }
1678
+ const today = clock_cjs_1.realClock.today();
1679
+ const updated = [];
1680
+ readModifyWriteStateMd(statePath, (content) => {
1681
+ // Bug #1255: all body-field replacements must operate on the body only
1682
+ // (frontmatter stripped), not on the full content. When the full content is
1683
+ // passed to stateReplaceField the YAML `status: planning` key matches the
1684
+ // plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
1685
+ // pipe-table `| Status | Planning |` is never updated and syncStateFrontmatter
1686
+ // re-derives 'planning' from the unchanged body — the status never advances.
1687
+ const existingFm = extractFrontmatter(content);
1688
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
1689
+ let body = stripFrontmatter(content);
1690
+ // Helper to reassemble content for field-replacement checks; callers that
1691
+ // only need to test/replace body fields use `body` directly, and the final
1692
+ // return reassembles the frontmatter block with the updated body.
1693
+ const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
1694
+ // Idempotency guard (#3127): if the phase is already mid-flight, do NOT
1695
+ // overwrite execution-progress fields (Current Plan, plan body line,
1696
+ // Last Activity Description). Only update fields that are safe to
1697
+ // refresh on resume (Last Activity date, Status if inconsistent).
1698
+ // A phase is considered mid-flight when Status contains 'Executing Phase N'
1699
+ // for the current phase number.
1700
+ // #1255: extract from body (not full content) so the YAML `status:` key
1701
+ // cannot shadow the body Status field.
1702
+ const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || '';
1703
+ const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`, 'i').test(currentStatus);
1704
+ // Update Status field (body only — #1255)
1705
+ const statusValue = `Executing Phase ${phaseNumber}`;
1706
+ let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
1707
+ if (result) {
1708
+ body = result;
1709
+ updated.push('Status');
1710
+ }
1711
+ // Update Last Activity (safe to update on resume — tracks when execute-phase ran)
1712
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
1713
+ if (result) {
1714
+ body = result;
1715
+ updated.push('Last Activity');
1716
+ }
1717
+ if (!isAlreadyExecuting) {
1718
+ // First-time execution: set all progress fields
1719
+ // Update Last Activity Description
1720
+ const activityDesc = `Phase ${phaseNumber} execution started`;
1721
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
1722
+ if (result) {
1723
+ body = result;
1724
+ updated.push('Last Activity Description');
1725
+ }
1726
+ // Update Current Phase
1727
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase', String(phaseNumber));
1728
+ if (result) {
1729
+ body = result;
1730
+ updated.push('Current Phase');
1731
+ }
1732
+ // Update Current Phase Name
1733
+ if (phaseName) {
1734
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase Name', phaseName);
1735
+ if (result) {
1736
+ body = result;
1737
+ updated.push('Current Phase Name');
1738
+ }
1739
+ }
1740
+ // Update Current Plan to 1 (starting from the first plan)
1741
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', '1');
1742
+ if (result) {
1743
+ body = result;
1744
+ updated.push('Current Plan');
1745
+ }
1746
+ // Update Total Plans in Phase
1747
+ if (planCount) {
1748
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
1749
+ if (result) {
1750
+ body = result;
1751
+ updated.push('Total Plans in Phase');
1752
+ }
1753
+ }
1754
+ // Update **Current focus:** body text line (#1104)
1755
+ const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
1756
+ const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
1757
+ if (focusPattern.test(body)) {
1758
+ body = body.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
1759
+ updated.push('Current focus');
1760
+ }
1761
+ // Update ## Current Position section (#1104, #1365)
1762
+ const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1763
+ const positionMatch = body.match(positionPattern);
1764
+ if (positionMatch) {
1765
+ const header = positionMatch[1];
1766
+ let posBody = positionMatch[2];
1767
+ // Update or insert Phase line
1768
+ const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
1769
+ if (/^Phase:/m.test(posBody)) {
1770
+ posBody = posBody.replace(/^Phase:.*$/m, newPhase);
1771
+ }
1772
+ else {
1773
+ // Pipe-table format in Current Position (#1257): update the | Phase | … |
1774
+ // cell rather than prepending a spurious inline `Phase:` line (which left
1775
+ // the table cell stale). Mirrors the Status/Last-activity table branches.
1776
+ const phaseValue = `${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
1777
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', phaseValue);
1778
+ if (replaced !== null)
1779
+ posBody = replaced;
1780
+ }
1781
+ // Update or insert Plan line
1782
+ const newPlan = `Plan: 1 of ${planCount || '?'}`;
1783
+ if (/^Plan:/m.test(posBody)) {
1784
+ posBody = posBody.replace(/^Plan:.*$/m, newPlan);
1785
+ }
1786
+ else {
1787
+ // Pipe-table format in Current Position (#1257): update the | Plan | … |
1788
+ // cell rather than appending after a prepended inline line.
1789
+ const planValue = `1 of ${planCount || '?'}`;
1790
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', planValue);
1791
+ if (replaced !== null)
1792
+ posBody = replaced;
1793
+ }
1794
+ // Update Status line if present
1795
+ const newStatus = `Status: Executing Phase ${phaseNumber}`;
1796
+ if (/^Status:/m.test(posBody)) {
1797
+ posBody = posBody.replace(/^Status:.*$/m, newStatus);
1798
+ }
1799
+ else {
1800
+ // Pipe-table format in Current Position (#1255)
1801
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Executing Phase ${phaseNumber}`);
1802
+ if (replaced !== null)
1803
+ posBody = replaced;
1804
+ }
1805
+ // Update Last activity line if present
1806
+ const newActivity = `Last activity: ${today} — Phase ${phaseNumber} execution started`;
1807
+ if (/^Last activity:/im.test(posBody)) {
1808
+ posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
1809
+ }
1810
+ else {
1811
+ // Pipe-table format in Current Position (#1255)
1812
+ // Value must match the inline branch (date + narrative), not bare date.
1813
+ const activityValue = `${today} — Phase ${phaseNumber} execution started`;
1814
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
1815
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
1816
+ if (replaced !== null)
1817
+ posBody = replaced;
1818
+ }
1819
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
1820
+ updated.push('Current Position');
1821
+ }
1822
+ }
1823
+ else {
1824
+ // Resume path: only update Last activity timestamp in Current Position
1825
+ // (do not touch Plan:, stopped_at, progress.percent, or plan counter)
1826
+ const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1827
+ const positionMatch = body.match(positionPattern);
1828
+ if (positionMatch) {
1829
+ const header = positionMatch[1];
1830
+ let posBody = positionMatch[2];
1831
+ const resumeActivity = `Last activity: ${today} — Phase ${phaseNumber} execution resumed (wave continue)`;
1832
+ if (/^Last activity:/im.test(posBody)) {
1833
+ posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
1834
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
1835
+ updated.push('Last activity (resume)');
1836
+ }
1837
+ else {
1838
+ // Pipe-table format in Current Position (#1255)
1839
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', resumeActivity)
1840
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', resumeActivity);
1841
+ if (replaced !== null) {
1842
+ posBody = replaced;
1843
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
1844
+ updated.push('Last activity (resume)');
1845
+ }
1846
+ }
1847
+ }
1848
+ }
1849
+ return reassemble(body);
1850
+ }, cwd);
1851
+ output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
1852
+ }
1853
+ /**
1854
+ * Write a WAITING.json signal file when GSD hits a decision point.
1855
+ * External watchers (fswatch, polling, orchestrators) can detect this.
1856
+ * File is written to .planning/WAITING.json (or .gsd/WAITING.json if .gsd exists).
1857
+ * Fixes #1034.
1858
+ */
1859
+ function cmdSignalWaiting(cwd, type, question, options, phase, raw) {
1860
+ const gsdDir = node_fs_1.default.existsSync(node_path_1.default.join(cwd, '.gsd')) ? node_path_1.default.join(cwd, '.gsd') : planningDir(cwd);
1861
+ const waitingPath = node_path_1.default.join(gsdDir, 'WAITING.json');
1862
+ const signal = {
1863
+ status: 'waiting',
1864
+ type: type || 'decision_point',
1865
+ question: question || null,
1866
+ options: options ? options.split('|').map(o => o.trim()) : [],
1867
+ since: clock_cjs_1.realClock.nowIso(),
1868
+ phase: phase || null,
1869
+ };
1870
+ try {
1871
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(gsdDir);
1872
+ (0, shell_command_projection_cjs_1.platformWriteSync)(waitingPath, JSON.stringify(signal, null, 2));
1873
+ output({ signaled: true, path: waitingPath }, raw, 'true');
1874
+ }
1875
+ catch (e) {
1876
+ output({ signaled: false, error: e.message }, raw, 'false');
1877
+ }
1878
+ }
1879
+ /**
1880
+ * Remove the WAITING.json signal file when user answers and agent resumes.
1881
+ */
1882
+ function cmdSignalResume(cwd, raw) {
1883
+ const paths = [
1884
+ node_path_1.default.join(cwd, '.gsd', 'WAITING.json'),
1885
+ node_path_1.default.join(planningDir(cwd), 'WAITING.json'),
1886
+ ];
1887
+ let removed = false;
1888
+ for (const p of paths) {
1889
+ if (node_fs_1.default.existsSync(p)) {
1890
+ try {
1891
+ node_fs_1.default.unlinkSync(p);
1892
+ removed = true;
1893
+ }
1894
+ catch { /* intentionally empty */ }
1895
+ }
1896
+ }
1897
+ output({ resumed: true, removed }, raw, removed ? 'true' : 'false');
1898
+ }
1899
+ // ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
1900
+ /**
1901
+ * Update the ## Performance Metrics section in STATE.md content.
1902
+ * Increments Velocity totals and upserts a By Phase table row.
1903
+ * Returns modified content string.
1904
+ */
1905
+ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) {
1906
+ // Update Velocity: Total plans completed
1907
+ const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/);
1908
+ const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0;
1909
+ const newTotal = prevTotal + summaryCount;
1910
+ content = content.replace(/Total plans completed:\s*(\d+|\[N\])/, `Total plans completed: ${newTotal}`);
1911
+ // Update By Phase table — upsert row for this phase
1912
+ const byPhaseMatch = content.match(byPhaseTablePattern);
1913
+ if (byPhaseMatch) {
1914
+ let tableBody = byPhaseMatch[2].trim();
1915
+ const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm');
1916
+ const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
1917
+ if (phaseRowPattern.test(tableBody)) {
1918
+ // Update existing row
1919
+ tableBody = tableBody.replace(phaseRowPattern, newRow);
1920
+ }
1921
+ else {
1922
+ // Remove placeholder row and add new row
1923
+ tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
1924
+ tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
1925
+ }
1926
+ content = content.replace(byPhaseTablePattern, (_match, tableHeader) => `${tableHeader}${tableBody}\n`);
1927
+ }
1928
+ return content;
1929
+ }
1930
+ /**
1931
+ * Gate 3a: Record state after plan-phase completes.
1932
+ * Updates Status to "Ready to execute", Total Plans, Last Activity.
1933
+ */
1934
+ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
1935
+ const statePath = planningPaths(cwd).state;
1936
+ if (!node_fs_1.default.existsSync(statePath)) {
1937
+ output({ error: 'STATE.md not found' }, raw, undefined);
1938
+ return;
1939
+ }
1940
+ const today = clock_cjs_1.realClock.today();
1941
+ const updated = [];
1942
+ const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
1943
+ const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
1944
+ // plan-phase updates per-phase body fields only. It must NOT resync the
1945
+ // milestone-wide progress.* frontmatter from a half-planned disk snapshot —
1946
+ // doing so tramples curated/known-good counters. Route through the body-only
1947
+ // write contract (resync:false), the same guard state.update uses. (#500 RC1)
1948
+ readModifyWriteStateMd(statePath, (content) => {
1949
+ // Bug #1257: all body-field replacements must operate on the body only
1950
+ // (frontmatter stripped), not on the full content. When the full content is
1951
+ // passed to stateReplaceFieldIfTemplate the YAML `status: planning` key matches
1952
+ // the plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
1953
+ // pipe-table `| Status | Planning |` cell is never updated and syncStateFrontmatter
1954
+ // re-derives 'planning' from the unchanged body — the status never advances.
1955
+ // (Mirrors the begin/complete-phase fix from #1255/#1256.)
1956
+ const existingFm = extractFrontmatter(content);
1957
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
1958
+ let body = stripFrontmatter(content);
1959
+ const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
1960
+ // Update Status — only when the existing value is a known template default
1961
+ // (Knuth invariant: preserve executor-authored values).
1962
+ const newBody = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute');
1963
+ if (newBody !== body) {
1964
+ body = newBody;
1965
+ updated.push('Status');
1966
+ }
1967
+ // Update Total Plans in Phase
1968
+ if (planCount !== null && planCount !== undefined) {
1969
+ const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
1970
+ if (result) {
1971
+ body = result;
1972
+ updated.push('Total Plans in Phase');
1973
+ }
1974
+ }
1975
+ // Update Last Activity — only when the existing value is a known template default
1976
+ {
1977
+ const after = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today);
1978
+ if (after !== body) {
1979
+ body = after;
1980
+ updated.push('Last Activity');
1981
+ }
1982
+ }
1983
+ // Update Last Activity Description
1984
+ {
1985
+ const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
1986
+ if (result) {
1987
+ body = result;
1988
+ updated.push('Last Activity Description');
1989
+ }
1990
+ }
1991
+ // Update Current Position section
1992
+ body = updateCurrentPositionFields(body, {
1993
+ status: 'Ready to execute',
1994
+ lastActivity: `${today} — Phase ${phaseNumber} planning complete`,
1995
+ });
1996
+ return reassemble(body);
1997
+ }, cwd, { resync: false });
1998
+ output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
1999
+ }
2000
+ /**
2001
+ * Bug #2630: reset STATE.md for a new milestone cycle.
2002
+ * Stomps frontmatter milestone/milestone_name/status/progress AND rewrites
2003
+ * the Current Position body. Preserves Accumulated Context.
2004
+ * Symmetric with the SDK `stateMilestoneSwitch` handler.
2005
+ */
2006
+ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
2007
+ if (!version || !String(version).trim()) {
2008
+ output({ error: 'milestone required (--milestone <vX.Y>)' }, raw, undefined);
2009
+ return;
2010
+ }
2011
+ const resolvedName = (name && String(name).trim()) || 'milestone';
2012
+ const statePath = planningPaths(cwd).state;
2013
+ const today = clock_cjs_1.realClock.today();
2014
+ const lockPath = acquireStateLock(statePath);
2015
+ try {
2016
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
2017
+ const existingFm = extractFrontmatter(content);
2018
+ const body = stripFrontmatter(content);
2019
+ const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
2020
+ const resetPositionBody = `\nPhase: Not started (defining requirements)\n` +
2021
+ `Plan: —\n` +
2022
+ `Status: Defining requirements\n` +
2023
+ `Last activity: ${today} — Milestone ${version} started\n\n`;
2024
+ let newBody;
2025
+ if (positionPattern.test(body)) {
2026
+ newBody = body.replace(positionPattern, (_m, header) => `${header}${resetPositionBody}`);
2027
+ }
2028
+ else {
2029
+ const preface = body.trim().length > 0 ? body : '# Project State\n';
2030
+ newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
2031
+ }
2032
+ const fm = {
2033
+ gsd_state_version: existingFm['gsd_state_version'] || '1.0',
2034
+ milestone: version,
2035
+ milestone_name: resolvedName,
2036
+ status: 'planning',
2037
+ last_updated: clock_cjs_1.realClock.nowIso(),
2038
+ last_activity: today,
2039
+ progress: {
2040
+ total_phases: 0,
2041
+ completed_phases: 0,
2042
+ total_plans: 0,
2043
+ completed_plans: 0,
2044
+ percent: 0,
2045
+ },
2046
+ };
2047
+ const yamlStr = reconstructFrontmatter(fm);
2048
+ const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
2049
+ (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, assembled);
2050
+ output({ switched: true, version, name: resolvedName, status: 'planning' }, raw, 'true');
2051
+ }
2052
+ finally {
2053
+ releaseStateLock(lockPath);
2054
+ }
2055
+ }
2056
+ /**
2057
+ * Gate 1: Validate STATE.md against filesystem.
2058
+ * Returns { valid, warnings, drift } JSON.
2059
+ */
2060
+ function cmdStateValidate(cwd, raw) {
2061
+ const statePath = planningPaths(cwd).state;
2062
+ if (!node_fs_1.default.existsSync(statePath)) {
2063
+ output({ error: 'STATE.md not found' }, raw, undefined);
2064
+ return;
2065
+ }
2066
+ const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2067
+ const warnings = [];
2068
+ const drift = {};
2069
+ const status = (0, state_document_cjs_1.stateExtractField)(content, 'Status') || '';
2070
+ const currentPhase = (0, state_document_cjs_1.stateExtractField)(content, 'Current Phase');
2071
+ const totalPlansRaw = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
2072
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
2073
+ const phasesDir = planningPaths(cwd).phases;
2074
+ // Scan disk for current phase
2075
+ if (currentPhase && node_fs_1.default.existsSync(phasesDir)) {
2076
+ const normalized = currentPhase.replace(/\s+of\s+\d+.*/, '').trim();
2077
+ try {
2078
+ const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
2079
+ const phaseDir = entries.find(e => e.isDirectory() && e.name.startsWith(normalized.replace(/^0+/, '').padStart(2, '0')));
2080
+ if (phaseDir) {
2081
+ const phaseDirPath = node_path_1.default.join(phasesDir, phaseDir.name);
2082
+ const { planCount: diskPlans, summaryCount: diskSummaries } = scanPhasePlans(phaseDirPath);
2083
+ // Check plan count mismatch
2084
+ if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
2085
+ warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`);
2086
+ drift['plan_count'] = { state: totalPlansInPhase, disk: diskPlans };
2087
+ }
2088
+ // Check for VERIFICATION.md
2089
+ const files = node_fs_1.default.readdirSync(phaseDirPath);
2090
+ const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
2091
+ for (const vf of verificationFiles) {
2092
+ try {
2093
+ const vContent = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDirPath, vf), 'utf-8');
2094
+ if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
2095
+ warnings.push(`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`);
2096
+ drift['verification_status'] = { state_status: status, verification: 'passed' };
2097
+ }
2098
+ }
2099
+ catch { /* intentionally empty */ }
2100
+ }
2101
+ // Check if all plans have summaries but status still says executing
2102
+ if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
2103
+ // Only warn if no verification exists (if verification passed, the above warning covers it)
2104
+ if (verificationFiles.length === 0) {
2105
+ warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`);
2106
+ }
2107
+ }
2108
+ }
2109
+ }
2110
+ catch { /* intentionally empty */ }
2111
+ }
2112
+ const valid = warnings.length === 0;
2113
+ output({ valid, warnings, drift }, raw, undefined);
2114
+ }
2115
+ /**
2116
+ * Gate 2: Sync STATE.md from filesystem ground truth.
2117
+ * Scans phase dirs, reconstructs counters, progress, metrics.
2118
+ * Supports --verify for dry-run mode.
2119
+ */
2120
+ function cmdStateSync(cwd, options, raw) {
2121
+ const statePath = planningPaths(cwd).state;
2122
+ if (!node_fs_1.default.existsSync(statePath)) {
2123
+ output({ error: 'STATE.md not found' }, raw, undefined);
2124
+ return;
2125
+ }
2126
+ const verify = options && options.verify;
2127
+ const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2128
+ const changes = [];
2129
+ let modified = content;
2130
+ const today = clock_cjs_1.realClock.today();
2131
+ const phasesDir = planningPaths(cwd).phases;
2132
+ if (!node_fs_1.default.existsSync(phasesDir)) {
2133
+ output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
2134
+ return;
2135
+ }
2136
+ // Scan all phases
2137
+ let entries;
2138
+ try {
2139
+ entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
2140
+ .filter(e => e.isDirectory())
2141
+ .map(e => e.name)
2142
+ .sort();
2143
+ }
2144
+ catch {
2145
+ output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
2146
+ return;
2147
+ }
2148
+ let totalDiskPlans = 0;
2149
+ let totalDiskSummaries = 0;
2150
+ let diskCompletedPhases = 0;
2151
+ let highestIncompletePhase = null;
2152
+ let _highestIncompletePhaseNum = null;
2153
+ let highestIncompletePhaseplanCount = 0;
2154
+ let _highestIncompletePhaseSummaryCount = 0;
2155
+ for (const dir of entries) {
2156
+ const dirPath = node_path_1.default.join(phasesDir, dir);
2157
+ const { planCount: plans, summaryCount: summaries, completed } = scanPhasePlans(dirPath);
2158
+ totalDiskPlans += plans;
2159
+ totalDiskSummaries += summaries;
2160
+ if (completed)
2161
+ diskCompletedPhases++;
2162
+ // Track the highest phase with incomplete plans (or any plans)
2163
+ const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
2164
+ if (phaseMatch && plans > 0) {
2165
+ if (summaries < plans) {
2166
+ // Incomplete phase — this is likely the current one
2167
+ highestIncompletePhase = dir;
2168
+ _highestIncompletePhaseNum = phaseMatch[1];
2169
+ highestIncompletePhaseplanCount = plans;
2170
+ _highestIncompletePhaseSummaryCount = summaries;
2171
+ }
2172
+ else if (!highestIncompletePhase) {
2173
+ // All complete, track as potential current
2174
+ highestIncompletePhase = dir;
2175
+ _highestIncompletePhaseNum = phaseMatch[1];
2176
+ highestIncompletePhaseplanCount = plans;
2177
+ _highestIncompletePhaseSummaryCount = summaries;
2178
+ }
2179
+ }
2180
+ }
2181
+ // Determine total phases from ROADMAP (may be larger than realized disk dirs).
2182
+ // Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B).
2183
+ let syncTotalPhases = null;
2184
+ try {
2185
+ let roadmapPhaseCount = 0;
2186
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
2187
+ const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
2188
+ if (roadmapRaw !== null) {
2189
+ const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
2190
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
2191
+ let m;
2192
+ while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
2193
+ // Only count tokens that contain at least one digit — excludes
2194
+ // pure-word section headings (Overview, Details) while keeping
2195
+ // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
2196
+ if (/\d/.test(m[1]))
2197
+ roadmapPhaseCount++;
2198
+ }
2199
+ }
2200
+ if (roadmapPhaseCount > 0) {
2201
+ syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
2202
+ }
2203
+ else {
2204
+ syncTotalPhases = entries.length;
2205
+ }
2206
+ }
2207
+ catch { /* intentionally empty */ }
2208
+ // Sync Total Plans in Phase
2209
+ if (highestIncompletePhase) {
2210
+ const currentPlansField = (0, state_document_cjs_1.stateExtractField)(modified, 'Total Plans in Phase');
2211
+ if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
2212
+ changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
2213
+ const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
2214
+ if (result)
2215
+ modified = result;
2216
+ }
2217
+ }
2218
+ // Sync Progress — use shared helper so formula stays in one place (#3242 Bug B).
2219
+ // computeProgressPercent applies min(plan_fraction, phase_fraction) so unrealised
2220
+ // ROADMAP phases cap the reported percent rather than allowing a false 100%.
2221
+ const percent = (() => {
2222
+ const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
2223
+ return p !== null ? p : 0;
2224
+ })();
2225
+ const currentProgress = (0, state_document_cjs_1.stateExtractField)(modified, 'Progress');
2226
+ if (currentProgress) {
2227
+ const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
2228
+ if (currentPercent !== percent) {
2229
+ const barWidth = 10;
2230
+ const filled = Math.round(percent / 100 * barWidth);
2231
+ const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
2232
+ const progressStr = `[${bar}] ${percent}%`;
2233
+ changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
2234
+ const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Progress', progressStr);
2235
+ if (result)
2236
+ modified = result;
2237
+ }
2238
+ }
2239
+ // Sync Last Activity
2240
+ const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Last Activity', today);
2241
+ if (result) {
2242
+ const oldActivity = (0, state_document_cjs_1.stateExtractField)(modified, 'Last Activity');
2243
+ if (oldActivity !== today) {
2244
+ changes.push(`Last Activity: ${oldActivity} -> ${today}`);
2245
+ }
2246
+ modified = result;
2247
+ }
2248
+ if (verify) {
2249
+ output({ synced: false, changes, dry_run: true }, raw, undefined);
2250
+ return;
2251
+ }
2252
+ if (changes.length > 0 || modified !== content) {
2253
+ writeStateMd(statePath, modified, cwd);
2254
+ }
2255
+ output({ synced: true, changes, dry_run: false }, raw, undefined);
2256
+ }
2257
+ /**
2258
+ * Prune old entries from STATE.md sections that grow unboundedly (#1970).
2259
+ * Moves decisions, recently-completed summaries, and resolved blockers
2260
+ * older than keepRecent phases to STATE-ARCHIVE.md.
2261
+ *
2262
+ * Options:
2263
+ * keepRecent: number of recent phases to retain (default: 3)
2264
+ * dryRun: if true, return what would be pruned without modifying STATE.md
2265
+ */
2266
+ function cmdStatePrune(cwd, options, raw) {
2267
+ const silent = !!options.silent;
2268
+ const emit = silent ? () => { } : (result, r, v) => output(result, r, v);
2269
+ const statePath = planningPaths(cwd).state;
2270
+ if (!node_fs_1.default.existsSync(statePath)) {
2271
+ emit({ error: 'STATE.md not found' }, raw);
2272
+ return;
2273
+ }
2274
+ const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
2275
+ const dryRun = !!options.dryRun;
2276
+ const currentPhaseRaw = (0, state_document_cjs_1.stateExtractField)(node_fs_1.default.readFileSync(statePath, 'utf-8'), 'Current Phase');
2277
+ const currentPhase = parseInt(currentPhaseRaw, 10) || 0;
2278
+ const cutoff = currentPhase - keepRecent;
2279
+ if (cutoff <= 0) {
2280
+ emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
2281
+ return;
2282
+ }
2283
+ const archivePath = node_path_1.default.join(node_path_1.default.dirname(statePath), 'STATE-ARCHIVE.md');
2284
+ const archived = [];
2285
+ // Shared pruning logic applied to both dry-run and real passes.
2286
+ // Returns { newContent, archivedSections }.
2287
+ function prunePass(content) {
2288
+ const sections = [];
2289
+ // Prune Decisions section: entries like "- [Phase N]: ..."
2290
+ const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
2291
+ const decMatch = content.match(decisionPattern);
2292
+ if (decMatch) {
2293
+ const lines = decMatch[2].split('\n');
2294
+ const keep = [];
2295
+ const archive = [];
2296
+ for (const line of lines) {
2297
+ const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
2298
+ if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
2299
+ archive.push(line);
2300
+ }
2301
+ else {
2302
+ keep.push(line);
2303
+ }
2304
+ }
2305
+ if (archive.length > 0) {
2306
+ sections.push({ section: 'Decisions', count: archive.length, lines: archive });
2307
+ content = content.replace(decisionPattern, (_m, header) => `${header}${keep.join('\n')}`);
2308
+ }
2309
+ }
2310
+ // Prune Recently Completed section: entries mentioning phase numbers
2311
+ const recentPattern = /(###?\s*Recently Completed\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
2312
+ const recMatch = content.match(recentPattern);
2313
+ if (recMatch) {
2314
+ const lines = recMatch[2].split('\n');
2315
+ const keep = [];
2316
+ const archive = [];
2317
+ for (const line of lines) {
2318
+ const phaseMatch = line.match(/Phase\s+(\d+)/i);
2319
+ if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
2320
+ archive.push(line);
2321
+ }
2322
+ else {
2323
+ keep.push(line);
2324
+ }
2325
+ }
2326
+ if (archive.length > 0) {
2327
+ sections.push({ section: 'Recently Completed', count: archive.length, lines: archive });
2328
+ content = content.replace(recentPattern, (_m, header) => `${header}${keep.join('\n')}`);
2329
+ }
2330
+ }
2331
+ // Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~
2332
+ // or "[RESOLVED]" prefix) with a phase reference older than cutoff
2333
+ const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
2334
+ const blockersMatch = content.match(blockersPattern);
2335
+ if (blockersMatch) {
2336
+ const lines = blockersMatch[2].split('\n');
2337
+ const keep = [];
2338
+ const archive = [];
2339
+ for (const line of lines) {
2340
+ const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
2341
+ const phaseMatch = line.match(/Phase\s+(\d+)/i);
2342
+ if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
2343
+ archive.push(line);
2344
+ }
2345
+ else {
2346
+ keep.push(line);
2347
+ }
2348
+ }
2349
+ if (archive.length > 0) {
2350
+ sections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive });
2351
+ content = content.replace(blockersPattern, (_m, header) => `${header}${keep.join('\n')}`);
2352
+ }
2353
+ }
2354
+ // Prune Performance Metrics table rows: keep only rows for phases > cutoff.
2355
+ // Preserves header rows (| Phase | ... and |---|...) and any prose around the table.
2356
+ const metricsPattern = /(###?\s*Performance Metrics\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
2357
+ const metricsMatch = content.match(metricsPattern);
2358
+ if (metricsMatch) {
2359
+ const sectionLines = metricsMatch[2].split('\n');
2360
+ const keep = [];
2361
+ const archive = [];
2362
+ for (const line of sectionLines) {
2363
+ // Table data row: starts with | followed by a number (phase)
2364
+ const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
2365
+ if (tableRowMatch) {
2366
+ const rowPhase = parseInt(tableRowMatch[1], 10);
2367
+ if (rowPhase <= cutoff) {
2368
+ archive.push(line);
2369
+ }
2370
+ else {
2371
+ keep.push(line);
2372
+ }
2373
+ }
2374
+ else {
2375
+ // Header row, separator row, or prose — always keep
2376
+ keep.push(line);
2377
+ }
2378
+ }
2379
+ if (archive.length > 0) {
2380
+ sections.push({ section: 'Performance Metrics', count: archive.length, lines: archive });
2381
+ content = content.replace(metricsPattern, (_m, header) => `${header}${keep.join('\n')}`);
2382
+ }
2383
+ }
2384
+ return { newContent: content, archivedSections: sections };
2385
+ }
2386
+ if (dryRun) {
2387
+ // Dry-run: compute what would be pruned without writing anything
2388
+ const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2389
+ const result = prunePass(content);
2390
+ const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
2391
+ emit({
2392
+ pruned: false,
2393
+ dry_run: true,
2394
+ cutoff_phase: cutoff,
2395
+ keep_recent: keepRecent,
2396
+ sections: result.archivedSections.map(s => ({ section: s.section, entries_would_archive: s.count })),
2397
+ total_would_archive: totalPruned,
2398
+ note: totalPruned > 0 ? 'Run without --dry-run to actually prune' : 'Nothing to prune',
2399
+ }, raw, totalPruned > 0 ? 'true' : 'false');
2400
+ return;
2401
+ }
2402
+ readModifyWriteStateMd(statePath, (content) => {
2403
+ const result = prunePass(content);
2404
+ archived.push(...result.archivedSections);
2405
+ return result.newContent;
2406
+ }, cwd);
2407
+ // Write archived entries to STATE-ARCHIVE.md
2408
+ if (archived.length > 0) {
2409
+ const timestamp = clock_cjs_1.realClock.today();
2410
+ let archiveContent = (0, shell_command_projection_cjs_1.platformReadSync)(archivePath);
2411
+ if (archiveContent === null) {
2412
+ archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
2413
+ }
2414
+ archiveContent += `## Pruned ${timestamp} (phases 1-${cutoff}, kept recent ${keepRecent})\n\n`;
2415
+ for (const section of archived) {
2416
+ archiveContent += `### ${section.section}\n\n${section.lines.join('\n')}\n\n`;
2417
+ }
2418
+ (0, shell_command_projection_cjs_1.platformWriteSync)(archivePath, archiveContent);
2419
+ }
2420
+ const totalPruned = archived.reduce((sum, s) => sum + s.count, 0);
2421
+ emit({
2422
+ pruned: totalPruned > 0,
2423
+ cutoff_phase: cutoff,
2424
+ keep_recent: keepRecent,
2425
+ sections: archived.map(s => ({ section: s.section, entries_archived: s.count })),
2426
+ total_archived: totalPruned,
2427
+ archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
2428
+ }, raw, totalPruned > 0 ? 'true' : 'false');
2429
+ }
2430
+ /**
2431
+ * Mark the current phase as COMPLETE in STATE.md.
2432
+ * Updates Status, Last Activity, and the Current Position section to reflect
2433
+ * that the phase execution is finished and the project is ready for the next phase.
2434
+ * Implements the `gsd state complete-phase` subcommand (issue #2735).
2435
+ */
2436
+ function resolvePhaseIdForCompletePhase(content, overridePhase) {
2437
+ const candidate = overridePhase ||
2438
+ (0, state_document_cjs_1.stateExtractField)(content, 'Current Phase') ||
2439
+ (0, state_document_cjs_1.stateExtractField)(content, 'Phase') ||
2440
+ '';
2441
+ // Accept canonical phase token only (e.g. 3, 03, 3A, 3.3, 10.2)
2442
+ const phaseMatch = String(candidate).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
2443
+ return phaseMatch ? phaseMatch[1] : null;
2444
+ }
2445
+ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2446
+ const statePath = planningPaths(cwd).state;
2447
+ if (!node_fs_1.default.existsSync(statePath)) {
2448
+ output({ error: 'STATE.md not found' }, raw, undefined);
2449
+ return;
2450
+ }
2451
+ const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2452
+ const resolvedPhase = resolvePhaseIdForCompletePhase(content, overridePhase);
2453
+ if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
2454
+ output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw, undefined);
2455
+ return;
2456
+ }
2457
+ // Idempotency guard (#3489). If STATE.md's canonical `Current Phase` field
2458
+ // already names a phase distinct from the one we are being asked to mark
2459
+ // complete, the project has advanced past the requested phase (e.g. a
2460
+ // follow-up phase was inserted, or the next phase began). Re-running
2461
+ // `state complete-phase --phase <N>` in that situation previously rolled
2462
+ // STATE.md back to <N>'s moment-of-completion — silently clobbering Status,
2463
+ // Last Activity, Last Activity Description, and the Current Position body.
2464
+ // The handler is now a no-op in that case so re-invocation from downstream
2465
+ // workflows cannot regress the project state.
2466
+ const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateExtractField)(content, 'Current Phase') || '';
2467
+ const existingCurrentPhaseMatch = String(existingCurrentPhaseRaw).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
2468
+ const existingCurrentPhase = existingCurrentPhaseMatch ? existingCurrentPhaseMatch[1] : null;
2469
+ if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
2470
+ output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, raw, 'false');
2471
+ return;
2472
+ }
2473
+ const today = clock_cjs_1.realClock.today();
2474
+ const updated = [];
2475
+ readModifyWriteStateMd(statePath, (content) => {
2476
+ const currentPhase = resolvedPhase;
2477
+ // Bug #1255: operate on body only so the YAML frontmatter `status:` key
2478
+ // cannot shadow the body Status field (pipe-table or inline).
2479
+ const existingFm = extractFrontmatter(content);
2480
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
2481
+ let body = stripFrontmatter(content);
2482
+ const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
2483
+ // Update Status field (body only — #1255)
2484
+ const statusValue = `Phase ${currentPhase} complete`;
2485
+ let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
2486
+ if (result) {
2487
+ body = result;
2488
+ updated.push('Status');
2489
+ }
2490
+ // Update Last Activity date
2491
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
2492
+ if (result) {
2493
+ body = result;
2494
+ updated.push('Last Activity');
2495
+ }
2496
+ // Update Last Activity Description
2497
+ const activityDesc = `Phase ${currentPhase} marked complete`;
2498
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
2499
+ if (result) {
2500
+ body = result;
2501
+ updated.push('Last Activity Description');
2502
+ }
2503
+ // Update ## Current Position section
2504
+ const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
2505
+ const positionMatch = body.match(positionPattern);
2506
+ if (positionMatch) {
2507
+ const header = positionMatch[1];
2508
+ let posBody = positionMatch[2];
2509
+ // Update Phase line to show COMPLETE
2510
+ const newPhase = `Phase: ${currentPhase} — COMPLETE`;
2511
+ if (/^Phase:/m.test(posBody)) {
2512
+ posBody = posBody.replace(/^Phase:.*$/m, newPhase);
2513
+ }
2514
+ else {
2515
+ // Pipe-table format in Current Position (#1255)
2516
+ // Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label.
2517
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', `${currentPhase} — COMPLETE`);
2518
+ if (replaced !== null)
2519
+ posBody = replaced;
2520
+ }
2521
+ // Update Status line if present
2522
+ const newStatus = `Status: Phase ${currentPhase} complete`;
2523
+ if (/^Status:/m.test(posBody)) {
2524
+ posBody = posBody.replace(/^Status:.*$/m, newStatus);
2525
+ }
2526
+ else {
2527
+ // Pipe-table format in Current Position (#1255)
2528
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Phase ${currentPhase} complete`);
2529
+ if (replaced !== null)
2530
+ posBody = replaced;
2531
+ }
2532
+ // Update Last activity line if present
2533
+ const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`;
2534
+ if (/^Last activity:/im.test(posBody)) {
2535
+ posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
2536
+ }
2537
+ else {
2538
+ // Pipe-table format in Current Position (#1255)
2539
+ // Value must match the inline branch (date + narrative), not bare date.
2540
+ const activityValue = `${today} — Phase ${currentPhase} marked complete`;
2541
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
2542
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
2543
+ if (replaced !== null)
2544
+ posBody = replaced;
2545
+ }
2546
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
2547
+ updated.push('Current Position');
2548
+ }
2549
+ return reassemble(body);
2550
+ }, cwd);
2551
+ output({ updated, phase: resolvedPhase }, raw, updated.length > 0 ? 'true' : 'false');
2552
+ }
2553
+ module.exports = {
2554
+ stateExtractField: state_document_cjs_1.stateExtractField,
2555
+ stateReplaceField: state_document_cjs_1.stateReplaceField,
2556
+ stateReplaceFieldWithFallback,
2557
+ acquireStateLock,
2558
+ releaseStateLock,
2559
+ writeStateMd,
2560
+ readModifyWriteStateMd,
2561
+ syncStateFrontmatter,
2562
+ withStateLock,
2563
+ updatePerformanceMetricsSection,
2564
+ cmdStateLoad,
2565
+ cmdStateGet,
2566
+ cmdStatePatch,
2567
+ cmdStateUpdate,
2568
+ cmdStateAdvancePlan,
2569
+ cmdStateRecordMetric,
2570
+ cmdStateUpdateProgress,
2571
+ cmdStateAddDecision,
2572
+ cmdStateAddBlocker,
2573
+ cmdStateAddRoadmapEvolution,
2574
+ cmdStateResolveBlocker,
2575
+ cmdStateRecordSession,
2576
+ cmdStateSnapshot,
2577
+ cmdStateJson,
2578
+ cmdStateBeginPhase,
2579
+ cmdStatePlannedPhase,
2580
+ cmdStateCompletePhase,
2581
+ cmdStateValidate,
2582
+ cmdStateSync,
2583
+ cmdStatePrune,
2584
+ cmdStateMilestoneSwitch,
2585
+ cmdSignalWaiting,
2586
+ cmdSignalResume,
2587
+ };