@opengsd/gsd-core 1.12.0 → 1.14.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 (455) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-advisor-researcher.compact.md +85 -0
  5. package/agents/gsd-ai-researcher.compact.md +96 -0
  6. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  7. package/agents/gsd-code-fixer.compact.md +458 -0
  8. package/agents/gsd-code-fixer.md +5 -5
  9. package/agents/gsd-code-reviewer.compact.md +269 -0
  10. package/agents/gsd-code-reviewer.md +15 -3
  11. package/agents/gsd-codebase-mapper.compact.md +760 -0
  12. package/agents/gsd-debug-session-manager.compact.md +345 -0
  13. package/agents/gsd-doc-classifier.compact.md +192 -0
  14. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  15. package/agents/gsd-doc-verifier.compact.md +143 -0
  16. package/agents/gsd-doc-writer.compact.md +440 -0
  17. package/agents/gsd-dom-verifier.compact.md +138 -0
  18. package/agents/gsd-domain-researcher.compact.md +141 -0
  19. package/agents/gsd-eval-auditor.compact.md +160 -0
  20. package/agents/gsd-eval-planner.compact.md +137 -0
  21. package/agents/gsd-executor.md +63 -35
  22. package/agents/gsd-framework-selector.compact.md +82 -0
  23. package/agents/gsd-integration-checker.compact.md +245 -0
  24. package/agents/gsd-intel-updater.compact.md +226 -0
  25. package/agents/gsd-mempalace-curator.compact.md +45 -0
  26. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  27. package/agents/gsd-pattern-mapper.compact.md +275 -0
  28. package/agents/gsd-plan-checker.md +76 -57
  29. package/agents/gsd-planner.md +14 -0
  30. package/agents/gsd-project-researcher.compact.md +587 -0
  31. package/agents/gsd-research-synthesizer.compact.md +212 -0
  32. package/agents/gsd-roadmapper.compact.md +454 -0
  33. package/agents/gsd-roadmapper.md +13 -0
  34. package/agents/gsd-security-auditor.compact.md +162 -0
  35. package/agents/gsd-ui-auditor.compact.md +404 -0
  36. package/agents/gsd-ui-checker.compact.md +277 -0
  37. package/agents/gsd-ui-checker.md +19 -3
  38. package/agents/gsd-ui-researcher.compact.md +282 -0
  39. package/agents/gsd-ui-researcher.md +29 -0
  40. package/agents/gsd-user-profiler.compact.md +108 -0
  41. package/agents/gsd-verifier.md +23 -1
  42. package/bin/install.js +444 -134
  43. package/commands/gsd/cleanup.md +1 -0
  44. package/commands/gsd/code-review.md +2 -1
  45. package/commands/gsd/complete-milestone.md +1 -0
  46. package/commands/gsd/config.md +1 -0
  47. package/commands/gsd/debug.md +1 -0
  48. package/commands/gsd/execute-phase.md +1 -1
  49. package/commands/gsd/graphify.md +1 -0
  50. package/commands/gsd/health.md +1 -0
  51. package/commands/gsd/mempalace-capture.md +1 -0
  52. package/commands/gsd/mempalace-recall.md +1 -0
  53. package/commands/gsd/new-milestone.md +1 -0
  54. package/commands/gsd/new-project.md +1 -0
  55. package/commands/gsd/next.md +1 -0
  56. package/commands/gsd/ns-workflow.md +2 -1
  57. package/commands/gsd/pause-work.md +1 -0
  58. package/commands/gsd/phase.md +2 -1
  59. package/commands/gsd/pr-branch.md +1 -0
  60. package/commands/gsd/quick-batch.md +105 -0
  61. package/commands/gsd/resume-work.md +1 -0
  62. package/commands/gsd/review-backlog.md +1 -0
  63. package/commands/gsd/settings.md +2 -1
  64. package/commands/gsd/stats.md +1 -0
  65. package/commands/gsd/surface.md +18 -8
  66. package/commands/gsd/thread.md +1 -0
  67. package/commands/gsd/workspace.md +1 -0
  68. package/commands/gsd/workstreams.md +1 -0
  69. package/gsd-core/bin/check-latest-version.cjs +8 -3
  70. package/gsd-core/bin/gsd-tools.cjs +532 -174
  71. package/gsd-core/bin/lib/adr-parser.cjs +1 -1
  72. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  73. package/gsd-core/bin/lib/audit.cjs +39 -22
  74. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  75. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  76. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  77. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  78. package/gsd-core/bin/lib/capability-registry.cjs +528 -116
  79. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  80. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  81. package/gsd-core/bin/lib/capability-validator.cjs +134 -5
  82. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  83. package/gsd-core/bin/lib/check-command-router.cjs +198 -38
  84. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  85. package/gsd-core/bin/lib/clusters.cjs +1 -0
  86. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  87. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  88. package/gsd-core/bin/lib/commands.cjs +981 -79
  89. package/gsd-core/bin/lib/config-loader.cjs +4 -0
  90. package/gsd-core/bin/lib/config.cjs +153 -38
  91. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  92. package/gsd-core/bin/lib/coverage.cjs +1 -1
  93. package/gsd-core/bin/lib/decisions.cjs +343 -28
  94. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  95. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  96. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  97. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  98. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  99. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  100. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  101. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  102. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  103. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +87 -23
  104. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  105. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  106. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  107. package/gsd-core/bin/lib/init.cjs +539 -60
  108. package/gsd-core/bin/lib/install-engine.cjs +199 -14
  109. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  110. package/gsd-core/bin/lib/install-profiles.cjs +36 -14
  111. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  112. package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
  113. package/gsd-core/bin/lib/io.cjs +35 -0
  114. package/gsd-core/bin/lib/loop-resolver.cjs +64 -39
  115. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  116. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  117. package/gsd-core/bin/lib/milestone.cjs +41 -10
  118. package/gsd-core/bin/lib/model-resolver.cjs +101 -10
  119. package/gsd-core/bin/lib/phase-command-router.cjs +20 -7
  120. package/gsd-core/bin/lib/phase-id.cjs +412 -31
  121. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  122. package/gsd-core/bin/lib/phase.cjs +941 -98
  123. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  124. package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
  125. package/gsd-core/bin/lib/planning-snapshot.cjs +206 -30
  126. package/gsd-core/bin/lib/planning-workspace.cjs +153 -29
  127. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  128. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  129. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  130. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  131. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  132. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  133. package/gsd-core/bin/lib/research-store.cjs +11 -12
  134. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  135. package/gsd-core/bin/lib/review-lane-invocation.cjs +96 -1
  136. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  137. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  138. package/gsd-core/bin/lib/roadmap-parser.cjs +555 -41
  139. package/gsd-core/bin/lib/roadmap.cjs +292 -69
  140. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +260 -43
  141. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +28 -20
  142. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +294 -108
  143. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +408 -47
  144. package/gsd-core/bin/lib/security.cjs +126 -7
  145. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  146. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  147. package/gsd-core/bin/lib/state-document.cjs +159 -32
  148. package/gsd-core/bin/lib/state-md-schema.cjs +44 -27
  149. package/gsd-core/bin/lib/state-transition.cjs +465 -62
  150. package/gsd-core/bin/lib/state.cjs +906 -151
  151. package/gsd-core/bin/lib/surface.cjs +83 -10
  152. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  153. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  154. package/gsd-core/bin/lib/uat.cjs +1420 -516
  155. package/gsd-core/bin/lib/update-context.cjs +36 -26
  156. package/gsd-core/bin/lib/validate.cjs +230 -12
  157. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  158. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  159. package/gsd-core/bin/lib/verification.cjs +316 -23
  160. package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
  161. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  162. package/gsd-core/bin/lib/verify.cjs +531 -36
  163. package/gsd-core/bin/lib/workstream-inventory.cjs +21 -2
  164. package/gsd-core/bin/lib/worktree-safety.cjs +21 -7
  165. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  166. package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
  167. package/gsd-core/bin/verify-reapply-patches.cjs +507 -81
  168. package/gsd-core/references/agent-contracts.md +3 -3
  169. package/gsd-core/references/compact-content-gate.md +66 -0
  170. package/gsd-core/references/edge-probe.md +17 -13
  171. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  172. package/gsd-core/references/execute-phase-response-language.md +6 -0
  173. package/gsd-core/references/executor-examples.md +42 -0
  174. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  175. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  176. package/gsd-core/references/model-profiles.md +12 -3
  177. package/gsd-core/references/mvp-concepts.md +2 -2
  178. package/gsd-core/references/plan-checker-examples.md +41 -0
  179. package/gsd-core/references/planner-antipatterns.md +25 -0
  180. package/gsd-core/references/planner-chunked.md +5 -1
  181. package/gsd-core/references/planner-coupling.md +42 -0
  182. package/gsd-core/references/planner-quick-batch.md +71 -0
  183. package/gsd-core/references/planner-reviews.md +47 -0
  184. package/gsd-core/references/planner-revision.md +75 -2
  185. package/gsd-core/references/planning-config.md +5 -1
  186. package/gsd-core/references/response-language-directive.md +9 -0
  187. package/gsd-core/references/revision-loop.md +118 -11
  188. package/gsd-core/references/tdd.md +17 -9
  189. package/gsd-core/references/thinking-models-planning.md +18 -2
  190. package/gsd-core/references/verification-patterns.md +17 -4
  191. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  192. package/gsd-core/references/worktree-path-safety.md +112 -2
  193. package/gsd-core/templates/README.md +7 -1
  194. package/gsd-core/templates/phase-prompt.md +4 -0
  195. package/gsd-core/templates/state.md +6 -3
  196. package/gsd-core/templates/summary.compact.md +212 -0
  197. package/gsd-core/templates/user-setup.compact.md +199 -0
  198. package/gsd-core/templates/user-setup.md +0 -9
  199. package/gsd-core/templates/verification-report.md +5 -0
  200. package/gsd-core/workflows/add-backlog.md +2 -0
  201. package/gsd-core/workflows/add-phase.md +2 -0
  202. package/gsd-core/workflows/add-tests.md +1 -1
  203. package/gsd-core/workflows/add-todo.md +4 -3
  204. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  205. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  206. package/gsd-core/workflows/audit-fix.md +2 -0
  207. package/gsd-core/workflows/audit-milestone.md +2 -0
  208. package/gsd-core/workflows/audit-uat.md +2 -0
  209. package/gsd-core/workflows/autonomous.md +15 -10
  210. package/gsd-core/workflows/check-todos.md +5 -3
  211. package/gsd-core/workflows/cleanup.md +4 -2
  212. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +22 -13
  213. package/gsd-core/workflows/code-review-fix.md +5 -3
  214. package/gsd-core/workflows/code-review.md +211 -43
  215. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  216. package/gsd-core/workflows/complete-milestone.md +40 -254
  217. package/gsd-core/workflows/debug.md +1 -1
  218. package/gsd-core/workflows/diagnose-issues.md +5 -1
  219. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  220. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  221. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  222. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  223. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  224. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  225. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  226. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  227. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  228. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  229. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  230. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  231. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  232. package/gsd-core/workflows/discuss-phase.md +1 -1
  233. package/gsd-core/workflows/do.md +43 -13
  234. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  235. package/gsd-core/workflows/docs-update.md +15 -156
  236. package/gsd-core/workflows/edit-phase.md +2 -0
  237. package/gsd-core/workflows/eval-review.md +1 -1
  238. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  239. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +20 -3
  240. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  241. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +24 -3
  242. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  243. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  245. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  246. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  247. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  248. package/gsd-core/workflows/execute-phase.md +78 -159
  249. package/gsd-core/workflows/execute-plan.md +28 -15
  250. package/gsd-core/workflows/explore.md +2 -0
  251. package/gsd-core/workflows/extract-learnings.md +2 -0
  252. package/gsd-core/workflows/fast.md +6 -0
  253. package/gsd-core/workflows/forensics.md +2 -0
  254. package/gsd-core/workflows/graduation.md +1 -1
  255. package/gsd-core/workflows/health.md +1 -1
  256. package/gsd-core/workflows/help/modes/brief.md +2 -0
  257. package/gsd-core/workflows/help/modes/default.md +2 -0
  258. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  259. package/gsd-core/workflows/help/modes/full.md +12 -0
  260. package/gsd-core/workflows/help/modes/topic.md +2 -0
  261. package/gsd-core/workflows/help.md +3 -1
  262. package/gsd-core/workflows/import.md +3 -3
  263. package/gsd-core/workflows/inbox.md +1 -1
  264. package/gsd-core/workflows/ingest-docs.md +1 -1
  265. package/gsd-core/workflows/insert-phase.md +2 -0
  266. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  267. package/gsd-core/workflows/list-seeds.md +2 -0
  268. package/gsd-core/workflows/list-workspaces.md +2 -0
  269. package/gsd-core/workflows/manager.md +3 -3
  270. package/gsd-core/workflows/map-codebase.md +52 -3
  271. package/gsd-core/workflows/milestone-summary.md +2 -0
  272. package/gsd-core/workflows/mvp-phase.md +1 -1
  273. package/gsd-core/workflows/new-milestone.md +55 -13
  274. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  275. package/gsd-core/workflows/new-project.md +37 -205
  276. package/gsd-core/workflows/new-workspace.md +1 -1
  277. package/gsd-core/workflows/next.md +2 -0
  278. package/gsd-core/workflows/node-repair.md +2 -0
  279. package/gsd-core/workflows/note.md +2 -0
  280. package/gsd-core/workflows/onboard.md +1 -1
  281. package/gsd-core/workflows/pause-work.md +19 -4
  282. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  283. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  284. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  285. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  286. package/gsd-core/workflows/plan-phase.md +144 -185
  287. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  288. package/gsd-core/workflows/plant-seed.md +1 -1
  289. package/gsd-core/workflows/pr-branch.md +30 -10
  290. package/gsd-core/workflows/profile-user.md +1 -1
  291. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  292. package/gsd-core/workflows/progress.md +25 -3
  293. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  294. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  295. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  296. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  297. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  298. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  299. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  300. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  301. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  302. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  303. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  304. package/gsd-core/workflows/quick-batch.md +203 -0
  305. package/gsd-core/workflows/quick.md +21 -4
  306. package/gsd-core/workflows/reapply-patches.md +79 -3
  307. package/gsd-core/workflows/remove-phase.md +2 -0
  308. package/gsd-core/workflows/remove-workspace.md +1 -1
  309. package/gsd-core/workflows/resume-project.md +6 -2
  310. package/gsd-core/workflows/review.md +215 -10
  311. package/gsd-core/workflows/scan.md +2 -0
  312. package/gsd-core/workflows/section-manifest.json +12 -0
  313. package/gsd-core/workflows/secure-phase.md +1 -1
  314. package/gsd-core/workflows/session-report.md +2 -0
  315. package/gsd-core/workflows/settings-advanced.md +2 -0
  316. package/gsd-core/workflows/settings-integrations.md +9 -8
  317. package/gsd-core/workflows/settings.md +19 -6
  318. package/gsd-core/workflows/ship.md +10 -10
  319. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  320. package/gsd-core/workflows/sketch.md +1 -1
  321. package/gsd-core/workflows/smart-entry.md +1 -1
  322. package/gsd-core/workflows/spec-phase.md +24 -19
  323. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  324. package/gsd-core/workflows/spike.md +1 -1
  325. package/gsd-core/workflows/stats.md +2 -0
  326. package/gsd-core/workflows/sync-skills.md +12 -4
  327. package/gsd-core/workflows/thread.md +2 -0
  328. package/gsd-core/workflows/transition.md +2 -0
  329. package/gsd-core/workflows/ui-phase.md +26 -5
  330. package/gsd-core/workflows/ui-review.md +1 -1
  331. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  332. package/gsd-core/workflows/undo.md +1 -1
  333. package/gsd-core/workflows/update.md +48 -43
  334. package/gsd-core/workflows/validate-phase.md +1 -1
  335. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  336. package/gsd-core/workflows/verify-work.md +68 -182
  337. package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
  338. package/hooks/dist/gsd-check-update-worker.js +19 -2
  339. package/hooks/dist/gsd-context-monitor.js +371 -27
  340. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  341. package/hooks/dist/gsd-node-runner.sh +1 -0
  342. package/hooks/dist/gsd-prompt-guard.js +30 -5
  343. package/hooks/dist/gsd-read-guard.js +2 -0
  344. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  345. package/hooks/dist/gsd-secret-read-guard.js +1105 -0
  346. package/hooks/dist/gsd-statusline.js +18 -10
  347. package/hooks/dist/gsd-validate-commit.sh +474 -7
  348. package/hooks/dist/gsd-workflow-guard.js +2 -1
  349. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  350. package/hooks/dist/gsd-write-guard.js +46 -1
  351. package/hooks/dist/lib/dispatch-identity.js +187 -0
  352. package/hooks/dist/lib/filename-classification.js +64 -0
  353. package/hooks/dist/lib/git-cmd.js +210 -1
  354. package/hooks/dist/lib/injection-patterns.js +36 -6
  355. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  356. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  357. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  358. package/hooks/gsd-agent-isolation-guard.js +42 -16
  359. package/hooks/gsd-check-update-worker.js +19 -2
  360. package/hooks/gsd-context-monitor.js +371 -27
  361. package/hooks/gsd-cursor-subagent-start.js +34 -14
  362. package/hooks/gsd-node-runner.sh +1 -0
  363. package/hooks/gsd-prompt-guard.js +30 -5
  364. package/hooks/gsd-read-guard.js +2 -0
  365. package/hooks/gsd-read-injection-scanner.js +5 -5
  366. package/hooks/gsd-secret-read-guard.js +1105 -0
  367. package/hooks/gsd-statusline.js +18 -10
  368. package/hooks/gsd-validate-commit.sh +474 -7
  369. package/hooks/gsd-workflow-guard.js +2 -1
  370. package/hooks/gsd-worktree-path-guard.js +25 -14
  371. package/hooks/gsd-write-guard.js +46 -1
  372. package/hooks/hooks.json +6 -0
  373. package/hooks/lib/dispatch-identity.js +187 -0
  374. package/hooks/lib/filename-classification.js +64 -0
  375. package/hooks/lib/git-cmd.js +210 -1
  376. package/hooks/lib/injection-patterns.js +36 -6
  377. package/hooks/lib/isolation-deny-reason.js +53 -1
  378. package/hooks/lib/isolation-sentinel.js +58 -19
  379. package/hooks/managed-hooks-registry.cjs +1 -0
  380. package/package.json +13 -9
  381. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  382. package/scripts/benchmark-compact-content.cjs +368 -0
  383. package/scripts/build-hooks.js +11 -4
  384. package/scripts/check-contract-drift.cjs +4 -1
  385. package/scripts/check-env.cjs +36 -8
  386. package/scripts/check-glossary-refs.cjs +25 -21
  387. package/scripts/ci-next-health.cjs +271 -0
  388. package/scripts/ci-prepare-test-scope.cjs +7 -7
  389. package/scripts/ci-test-scope.cjs +133 -20
  390. package/scripts/ci-timeout-report.cjs +1 -1
  391. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  392. package/scripts/docs-guard-registry.cjs +17 -2
  393. package/scripts/gen-adr-index.cjs +8 -2
  394. package/scripts/gen-inventory-manifest.cjs +12 -0
  395. package/scripts/gen-loop-host-contract.cjs +67 -15
  396. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  397. package/scripts/lib/drift-scan.cjs +1 -1
  398. package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
  399. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  400. package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
  401. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  402. package/scripts/lib/suite-detection.cjs +32 -0
  403. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  404. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  405. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  406. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  407. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +24 -2
  408. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  409. package/scripts/lint-phase-id-drift.cjs +465 -15
  410. package/scripts/lint-portable-grep.cjs +176 -0
  411. package/scripts/lint-response-language-coverage.cjs +530 -0
  412. package/scripts/lint-source-test-name-collision.cjs +1 -1
  413. package/scripts/lint-test-file-count.allowlist.json +4 -1
  414. package/scripts/lint-vendored-deps.cjs +128 -17
  415. package/scripts/lint-workflow-shellcheck-baseline.json +1112 -0
  416. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  417. package/scripts/npm-audit-baseline.cjs +376 -0
  418. package/scripts/prompt-injection-scan.sh +22 -0
  419. package/scripts/require-issue-link-policy.cjs +16 -1
  420. package/scripts/workflow-size.cjs +139 -0
  421. package/skills/gsd-cleanup/SKILL.md +1 -0
  422. package/skills/gsd-code-review/SKILL.md +2 -1
  423. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  424. package/skills/gsd-config/SKILL.md +1 -0
  425. package/skills/gsd-debug/SKILL.md +1 -0
  426. package/skills/gsd-execute-phase/SKILL.md +1 -1
  427. package/skills/gsd-graphify/SKILL.md +1 -0
  428. package/skills/gsd-health/SKILL.md +1 -0
  429. package/skills/gsd-mempalace-capture/SKILL.md +1 -0
  430. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  431. package/skills/gsd-new-milestone/SKILL.md +1 -0
  432. package/skills/gsd-new-project/SKILL.md +1 -0
  433. package/skills/gsd-next/SKILL.md +1 -0
  434. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  435. package/skills/gsd-pause-work/SKILL.md +1 -0
  436. package/skills/gsd-phase/SKILL.md +2 -1
  437. package/skills/gsd-pr-branch/SKILL.md +1 -0
  438. package/skills/gsd-quick-batch/SKILL.md +105 -0
  439. package/skills/gsd-resume-work/SKILL.md +1 -0
  440. package/skills/gsd-review-backlog/SKILL.md +1 -0
  441. package/skills/gsd-settings/SKILL.md +2 -1
  442. package/skills/gsd-stats/SKILL.md +1 -0
  443. package/skills/gsd-surface/SKILL.md +18 -8
  444. package/skills/gsd-thread/SKILL.md +1 -0
  445. package/skills/gsd-workspace/SKILL.md +1 -0
  446. package/skills/gsd-workstreams/SKILL.md +1 -0
  447. package/vscode/package.json +1 -1
  448. package/gsd-core/templates/claude-md.md +0 -145
  449. package/gsd-core/templates/codebase/concerns.md +0 -310
  450. package/gsd-core/templates/codebase/conventions.md +0 -307
  451. package/gsd-core/templates/codebase/integrations.md +0 -280
  452. package/gsd-core/templates/codebase/structure.md +0 -285
  453. package/gsd-core/templates/codebase/testing.md +0 -480
  454. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  455. package/gsd-core/templates/discovery.md +0 -146
@@ -14,7 +14,7 @@ const node_path_1 = __importDefault(require("node:path"));
14
14
  const pattern_cjs_1 = require("./pattern.cjs");
15
15
  // eslint-disable-next-line @typescript-eslint/no-require-imports
16
16
  const ioMod = require("./io.cjs");
17
- const { output, error } = ioMod;
17
+ const { output, error, declineNoOp, formatDiagnosticToken } = ioMod;
18
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
19
  const cliExitModule = require("./cli-exit.cjs");
20
20
  const { ExitError } = cliExitModule;
@@ -26,7 +26,9 @@ const configLoaderMod = require("./config-loader.cjs");
26
26
  const { loadConfig } = configLoaderMod;
27
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
28
  const phaseIdMod = require("./phase-id.cjs");
29
- const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId, scopeToPhase, } = phaseIdMod;
29
+ const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, matchPhaseDirs, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
30
+ // #2761 M3: owns the bracket milestone intro and canonical pad2 spelling.
31
+ bracketMilestoneIntroSrcFor, } = phaseIdMod;
30
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
33
  const roadmapParserMod = require("./roadmap-parser.cjs");
32
34
  // #3642: hasMilestoneSectioning no longer consumed here — its >=2 semantics answered sibling conflation, but this branch asks asserted-vs-section (>=1). It stays exported from roadmap-parser.cjs for its unit pins.
@@ -34,7 +36,7 @@ const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap,
34
36
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
35
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
38
  const planningWorkspace = require("./planning-workspace.cjs");
37
- const { planningDir, planningPaths } = planningWorkspace;
39
+ const { planningDir, planningPaths, resolvePhaseIdConvention } = planningWorkspace;
38
40
  const clock_cjs_1 = require("./clock.cjs");
39
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
40
42
  const frontmatter = require("./frontmatter.cjs");
@@ -53,8 +55,19 @@ function isUnparseableFrontmatter(existingFm) {
53
55
  // eslint-disable-next-line @typescript-eslint/no-require-imports
54
56
  const scanPhasePlans = require("./plan-scan.cjs");
55
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports
58
+ const coreUtilsMod = require("./core-utils.cjs");
59
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
60
+ const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
56
62
  const verificationMod = require("./verification.cjs");
57
63
  const { isPhaseComplete } = verificationMod;
64
+ // #4129: the single owner of "count the ROADMAP's milestone Complete rows"
65
+ // (phase-lifecycle.cts) — reused for the completed-phases numerator floor so
66
+ // this scan cannot grow a second ROADMAP parser. Pure computation module (no
67
+ // I/O), so it introduces no cycle on this path.
68
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
69
+ const phaseLifecycleMod = require("./phase-lifecycle.cjs");
70
+ const { deriveProgressFromRoadmap } = phaseLifecycleMod;
58
71
  // eslint-disable-next-line @typescript-eslint/no-require-imports
59
72
  const planningScopeMod = require("./planning-scope.cjs");
60
73
  const { SCOPE } = planningScopeMod;
@@ -75,7 +88,7 @@ const project_root_cjs_1 = require("./project-root.cjs");
75
88
  // it introduces no cycle on this path.
76
89
  // eslint-disable-next-line @typescript-eslint/no-require-imports
77
90
  const milestoneLockMod = require("./milestone-lock.cjs");
78
- const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
91
+ const { transitionCore, applyStatePreservation, sliceCurrentPositionSection, stateReplaceProgressPercent, formatProgressMachineSegment } = stateTransitionMod;
79
92
  // #3699: the frontmatter-key <-> body-field routing behind `state update`'s
80
93
  // failure explanation, and the classification table it falls back to.
81
94
  const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
@@ -280,7 +293,7 @@ function cmdStateGet(cwd, section, raw) {
280
293
  // Try to find markdown section or field
281
294
  const fieldEscaped = (0, pattern_cjs_1.escapeRegex)(section);
282
295
  // Check for **field:** value (bold format)
283
- const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
296
+ const boldPattern = new RegExp(`^[ \\t]*\\*\\*${fieldEscaped}:\\*\\*[ \\t]*(.*)`, 'im');
284
297
  const boldMatch = content.match(boldPattern);
285
298
  if (boldMatch) {
286
299
  output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
@@ -308,13 +321,10 @@ function readTextArgOrFile(cwd, value, filePath, label) {
308
321
  return value;
309
322
  // Path traversal guard: ensure file resolves within project directory
310
323
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
311
- const { validatePath } = require('./security.cjs');
312
- const pathCheck = validatePath(filePath, cwd, { allowAbsolute: true });
313
- if (!pathCheck.safe) {
314
- throw new Error(`${label} path rejected: ${pathCheck.error}`);
315
- }
324
+ const { assertWithinRoot, PathAcceptance } = require('./security.cjs');
325
+ const contained = assertWithinRoot(filePath, cwd, `${label} path`, PathAcceptance.AbsoluteInsideRoot);
316
326
  try {
317
- return node_fs_1.default.readFileSync(pathCheck.resolved, 'utf-8').trimEnd();
327
+ return node_fs_1.default.readFileSync(contained, 'utf-8').trimEnd();
318
328
  }
319
329
  catch {
320
330
  throw new Error(`${label} file not found: ${filePath}`);
@@ -483,7 +493,31 @@ function cmdStateUpdate(cwd, field, value) {
483
493
  // (#3345's direction) — reported separately from `updated` because this
484
494
  // command's contract is a single-field boolean, not a per-field array.
485
495
  const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
486
- updated = reconciled.includes(field);
496
+ // #4488: `updateCore` itself already told us whether it matched the field
497
+ // (`updated`, captured above `readModifyWriteStateMd` runs it) — that is a
498
+ // real signal, not a guess. `reconcileReportedFields` answers a DIFFERENT
499
+ // question ("what changed on disk") and, per its own docstring, reports
500
+ // `[]` whenever `preWriteState.fm` is `undefined`. That happens in two
501
+ // known cases, both of which mean "no snapshot was ever captured", not
502
+ // "nothing happened": (a) `readModifyWriteStateMd`'s #948 no-op guard
503
+ // fires because the transform's output was byte-identical to the input —
504
+ // the requested value already equals what's on disk, so the field WAS
505
+ // found and there was simply nothing left to change; (b)
506
+ // `applyPostSyncPreservation`'s `isUnparseableFrontmatter` early return —
507
+ // the ORIGINAL frontmatter block was malformed, so preservation never
508
+ // runs, yet `readModifyWriteStateMd` still persists the transform's raw
509
+ // output via `platformWriteSync`. In neither case did preservation
510
+ // discard or rewrite what the transform wrote, so trusting the
511
+ // transform's own `updated` signal here is never a false positive.
512
+ // Collapsing either case into the same `false` as "field not found" is
513
+ // the #4488 bug — `explainUpdateFailure` then reports a message that is
514
+ // actively false (it tells the caller to add a line that is already
515
+ // there). Every other `false` origin (case-D fallback did not apply, or
516
+ // the transform genuinely found nothing) is unaffected: there
517
+ // `preWriteState.fm` is defined (a normal sync ran) or `updated` was
518
+ // already false before this line.
519
+ const noopBecauseAlreadyCorrect = updated && preWriteState.fm === undefined;
520
+ updated = reconciled.includes(field) || noopBecauseAlreadyCorrect;
487
521
  const preserved = reconciled.filter((f) => f !== field);
488
522
  if (updated) {
489
523
  // #3699 case D: surfaced so a caller can tell "wrote the body source" from
@@ -520,6 +554,42 @@ function cmdStateUpdate(cwd, field, value) {
520
554
  }
521
555
  }
522
556
  // ─── State Progression Engine ────────────────────────────────────────────────
557
+ /**
558
+ * The "I could not read the plan position" message, DERIVED from
559
+ * `STATE_FIELD_SCHEMA.current_plan.acceptedShapes` rather than transcribed
560
+ * beside it.
561
+ *
562
+ * The accepted-shape set had two owners: the parser branches in
563
+ * `advancePlanCore` and an English list hand-written here. Nothing coupled
564
+ * them, so adding a branch left this message stale and removing one left it
565
+ * advertising a shape that errors — and no test could see either. ADR-3473
566
+ * §8.3 is "one implementation per rule"; the schema row is that one owner, and
567
+ * rows 23/24/25 already hold the parser to it.
568
+ *
569
+ * `Plan: N of M` is spelled out separately because there is no schema row for
570
+ * the body-only `Plan` field: `buildStateFrontmatter` never reads it into
571
+ * frontmatter, so it has no `current_*` key to hang a row on. That asymmetry is
572
+ * the schema's, not this function's.
573
+ */
574
+ function advancePlanShapeError() {
575
+ const shapes = stateMdSchemaMod.STATE_FIELD_SCHEMA['current_plan']?.acceptedShapes ?? [];
576
+ const spellings = shapes.map((shape) => (shape === 'N'
577
+ ? '`Current Plan: N` with `Total Plans in Phase: M`'
578
+ : `\`Current Plan: ${shape}\``));
579
+ // The body-only `Plan` field has no schema row to derive from:
580
+ // `buildStateFrontmatter` never reads it into frontmatter, so there is no
581
+ // `current_*` key to hang a row on. Its ONE accepted spelling is named here.
582
+ //
583
+ // `Plan: N` with a `Total Plans in Phase: M` sibling is deliberately NOT
584
+ // listed (#3791 review round 6, M2): the parser does not accept it. A
585
+ // revision of this PR added both the branch and this spelling together, on
586
+ // the reasoning that the message must advertise exactly what the parser
587
+ // accepts. That reasoning still holds — which is why removing the branch
588
+ // removes the spelling in the same commit. The invariant is the lockstep,
589
+ // not the length of the list.
590
+ spellings.push('`Plan: N of M`');
591
+ return `Cannot read the plan position from STATE.md. Expected one of: ${spellings.join(', ')}.`;
592
+ }
523
593
  /**
524
594
  * Replace a STATE.md field with fallback field name support.
525
595
  * Tries `primary` first, then `fallback` (if provided), returns content unchanged
@@ -541,6 +611,86 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
541
611
  `This may indicate STATE.md was externally modified or uses an unexpected format.\n`);
542
612
  return content;
543
613
  }
614
+ /**
615
+ * #4067: disk-derived plan-completion answer for advance-plan's phase-complete
616
+ * guard.
617
+ *
618
+ * `advancePlanCore` decides "phase complete" purely from STATE.md's scalar plan
619
+ * counter (`currentPlan >= totalPlans`). That counter cannot represent
620
+ * wave-parallel execution — a stale counter carried over from the prior phase
621
+ * (the reported trigger: `Plan: 7 of 7` surviving into a 10-plan phase) or a
622
+ * counter raced by N concurrent executors both let the phase-complete branch
623
+ * fire while sibling plans are mid-flight. This helper answers the completion
624
+ * question from disk instead, exactly the way `state update-progress`
625
+ * recalculates it: every plan in the Current Position phase's directory has a
626
+ * SUMMARY.md.
627
+ *
628
+ * Single-derivation discipline: plan/summary counting is owned by
629
+ * `scanPhasePlans` (src/plan-scan.cts, ADR-3180 §7.5) — this helper consumes
630
+ * it, never re-derives. It deliberately does NOT consult `isPhaseComplete`
631
+ * (§7.4): that owner answers the *verification* question (passing
632
+ * `*-VERIFICATION.md`), a different question from "are all plans executed?".
633
+ * Blocked summaries (#3345) are filtered from the pairing set with the same
634
+ * shared predicate `scanPhasePlans` uses, so the named outstanding list can
635
+ * never disagree with the count-based decision.
636
+ *
637
+ * FAIL-OPEN contract: returns `null` when the disk answer is UNAVAILABLE — no
638
+ * readable phases dir, no directory matching the position phase, or a scan
639
+ * whose scope is not COMPLETE (the scan may be blind to plans it knows exist).
640
+ * `null` means "the caller must fall back to the counter-derived decision",
641
+ * NOT "plans are outstanding"; worlds the seam cannot see (STATE.md with no
642
+ * Current Position `Phase:` line, milestone-archived layouts) keep today's
643
+ * behavior rather than being newly refused.
644
+ *
645
+ * Returns `{ dir, outstanding, planCount, summaryCount }` where `outstanding`
646
+ * is empty when every plan on disk is summarized (vacuously so for a zero-plan
647
+ * phase — #3168's zero-plan-phase posture). `planCount`/`summaryCount` are the
648
+ * countable disk facts behind `outstanding` (live plan files; summaries after
649
+ * the #3345 blocked filter) — #4093's recovery decline reports them to a
650
+ * caller whose STATE.md has lost its labeled plan position, so the suggested
651
+ * repair values are computed from the SAME set `outstanding` was, and can
652
+ * never disagree with a count-based decision either.
653
+ */
654
+ function scanOutstanding(phasesDir, dir) {
655
+ const phaseDirPath = node_path_1.default.join(phasesDir, dir);
656
+ const scan = scanPhasePlans(phaseDirPath);
657
+ if (scan.scope !== SCOPE.COMPLETE)
658
+ return null;
659
+ // Blocked summaries (#3345) are filtered with the same shared predicate
660
+ // scanPhasePlans uses for its own count, so the named outstanding list can
661
+ // never disagree with a count-based decision.
662
+ const countableSummaries = scan.summaryFiles.filter((f) => !planDependencyGraphMod.isSummaryFileBlocked(node_path_1.default.join(phaseDirPath, f)));
663
+ const outstanding = coreUtilsMod.findUnsummarizedPlans(scan.planFiles, countableSummaries);
664
+ return { dir, outstanding, planCount: scan.planFiles.length, summaryCount: countableSummaries.length };
665
+ }
666
+ function unsummarizedPlansForPositionPhase(cwd, positionPhase) {
667
+ const phasesDir = planningPaths(cwd).phases;
668
+ // #3185 (ADR-3180 Decision 1): "which phase directories exist" is owned by
669
+ // listMilestonePhaseDirs — no hand-rolled readdirSync here. The owner
670
+ // handles an absent phasesDir as a real empty and refuses sentinels.
671
+ //
672
+ // Two passes, narrowest first: the CURRENT-MILESTONE window (so an archived
673
+ // milestone's stale `01-*` directory cannot shadow the live one), then —
674
+ // only when the window cannot answer (no bounded ROADMAP, or the position
675
+ // phase is simply not in it) — an unscoped read, which the owner documents
676
+ // as a real answer. This is a lookup of ONE phase token STATE.md names, not
677
+ // a milestone enumeration, so the unscoped retry is in-contract.
678
+ const convention = resolvePhaseIdConvention(cwd);
679
+ const windowed = listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: convention });
680
+ const candidateDirs = windowed.scope === SCOPE.COMPLETE ? windowed.value : [];
681
+ // Canonical phase-token → directory matching (phase-id owner, #2562): both
682
+ // sides of the comparison derived by the same function, never a local regex.
683
+ const { matches } = matchPhaseDirs(candidateDirs, positionPhase, convention);
684
+ if (matches.length > 0)
685
+ return scanOutstanding(phasesDir, matches[0]);
686
+ const unscoped = listMilestonePhaseDirs(phasesDir);
687
+ if (unscoped.scope !== SCOPE.COMPLETE)
688
+ return null;
689
+ const retry = matchPhaseDirs(unscoped.value, positionPhase, convention);
690
+ if (retry.matches.length === 0)
691
+ return null;
692
+ return scanOutstanding(phasesDir, retry.matches[0]);
693
+ }
544
694
  function cmdStateAdvancePlan(cwd, raw) {
545
695
  const statePath = planningPaths(cwd).state;
546
696
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -567,6 +717,16 @@ function cmdStateAdvancePlan(cwd, raw) {
567
717
  // STATE.md lock, so the position read and the claim read cannot interleave
568
718
  // with another session's Current Position write.
569
719
  let milestoneConflict = null;
720
+ // #4067: set when the disk-derived guard declines the phase-complete branch —
721
+ // named here so the post-lock output path can report it without re-deriving.
722
+ // Holder (not a bare let) so TypeScript's closure-unaware narrowing cannot
723
+ // collapse the post-lock read to `never` — the callback assigns it.
724
+ const outstandingRef = { value: null };
725
+ // #4093: the position phase token the callback resolved (Current Position
726
+ // `Phase:` line first, frontmatter `current_phase` as fallback), carried out
727
+ // so the generic parse-failure decline can derive recovery facts from disk
728
+ // without re-reading STATE.md outside the lock. Same holder idiom as above.
729
+ const positionPhaseRef = { value: null };
570
730
  const wrote = readModifyWriteStateMd(statePath, (content) => {
571
731
  // advance-plan has no phase argument of its own — the phase it advances is
572
732
  // whatever ## Current Position names. Compare that against the milestone
@@ -576,6 +736,17 @@ function cmdStateAdvancePlan(cwd, raw) {
576
736
  const body = stripFrontmatter(content);
577
737
  const positionScope = matchCurrentPositionSection(body) ?? body;
578
738
  const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
739
+ // #4093: a Current Position section with ZERO labeled fields has no
740
+ // `Phase:` line either; frontmatter `current_phase` is the documented
741
+ // survivor of body drift (the reporter's document still carried it, and
742
+ // `buildStateFrontmatter` re-derives it from the body only when the body
743
+ // HAS the line). It feeds the recovery DECLINE only — never a write.
744
+ let fmPhase = null;
745
+ if (positionPhase === null) {
746
+ const fmToken = extractFrontmatter(content, statePath)['current_phase'];
747
+ fmPhase = typeof fmToken === 'string' && fmToken.trim() !== '' ? fmToken.trim() : null;
748
+ }
749
+ positionPhaseRef.value = positionPhase ?? fmPhase;
579
750
  if (positionPhase !== null) {
580
751
  milestoneConflict = milestoneLockMod.checkMilestonePosition(cwd, positionPhase);
581
752
  if (milestoneConflict) {
@@ -583,10 +754,53 @@ function cmdStateAdvancePlan(cwd, raw) {
583
754
  }
584
755
  }
585
756
  const result = transitionCore(content, intent, deps);
757
+ // #4067: the transform's phase-complete branch is decided by STATE.md's
758
+ // scalar plan counter, which can neither carry a stale value across phases
759
+ // nor represent wave-parallel execution. Before letting that branch write
760
+ // "Phase complete — ready for verification", re-decide from disk (the same
761
+ // source state.update-progress recalculates from): every plan in the
762
+ // position phase's directory must have a SUMMARY.md. A non-empty
763
+ // outstanding list declines the ENTIRE write — STATE.md is returned
764
+ // byte-identical, so the decline is idempotent and safe for any number of
765
+ // concurrent callers (the disk answer is re-read under the STATE.md lock
766
+ // each call; the counter stays display-only). `null` (disk answer
767
+ // unavailable) fails open to the counter-derived decision, so every
768
+ // world this seam cannot see keeps today's behavior.
769
+ if (result.data?.['advanced'] === false
770
+ && result.data?.['reason'] === 'last_plan'
771
+ && positionPhase !== null) {
772
+ const diskAnswer = unsummarizedPlansForPositionPhase(cwd, positionPhase);
773
+ if (diskAnswer !== null && diskAnswer.outstanding.length > 0) {
774
+ outstandingRef.value = diskAnswer;
775
+ resultData = result.data;
776
+ precomputedUpdated = [];
777
+ return content;
778
+ }
779
+ }
586
780
  resultData = result.data;
587
781
  precomputedUpdated = result.updated;
588
782
  return result.content;
589
783
  }, cwd, { divergedFields, preWriteState });
784
+ // #4067 decline path: plans remain unexecuted on disk. Shaped like the
785
+ // existing `last_plan` decline (advanced:false + machine-readable reason,
786
+ // exit 0) rather than a hard error — the caller did nothing wrong and
787
+ // STATE.md needs no repair; the remaining plans' executors will re-run this
788
+ // command, and the final one finds a fully-summarized phase and completes it.
789
+ const plansOutstanding = outstandingRef.value;
790
+ if (plansOutstanding !== null) {
791
+ declineNoOp(raw, 'advanced', 'plans_outstanding', `state advance-plan skipped — phase-complete declined: ${plansOutstanding.outstanding.length} plan(s) in .planning/phases/${plansOutstanding.dir} have no SUMMARY.md (${plansOutstanding.outstanding.join(', ')}). STATE.md was left unchanged; re-run once every plan has executed and written its summary.`, {
792
+ advanced: false,
793
+ phase_dir: plansOutstanding.dir,
794
+ outstanding_plans: plansOutstanding.outstanding,
795
+ milestone_conflict: milestoneConflict,
796
+ });
797
+ return;
798
+ }
799
+ // `!resultData` is a type guard, not a second failure mode: the callback
800
+ // above assigns it unconditionally and only runs once STATE.md is known to
801
+ // exist (the missing-file case returns "STATE.md not found" earlier), and
802
+ // every `advancePlanCore` return path sets `data`. So the message below is
803
+ // the one a caller can actually receive.
590
804
  if (!resultData || resultData['error']) {
591
805
  // #3807: a multi-`Phase:` Current Position section carries its own cause
592
806
  // and its own remedy (name the candidates; the caller resolves them).
@@ -598,7 +812,70 @@ function cmdStateAdvancePlan(cwd, raw) {
598
812
  }, raw, undefined);
599
813
  return;
600
814
  }
601
- output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
815
+ // #3791 review round 6 (B1): the document carries both plan-position
816
+ // spellings with DIFFERENT numbers. Same posture as the case above — name
817
+ // the candidates and let the caller resolve them. Advancing either one
818
+ // would write a number into the other that nothing derived for it.
819
+ if (resultData && resultData['reason'] === 'ambiguous_plan_position') {
820
+ output({
821
+ error: 'STATE.md carries two plan positions with different numbers — refusing to advance either. Resolve them to a single current plan and re-run.',
822
+ reason: resultData['reason'],
823
+ plan_candidates: resultData['plan_candidates'],
824
+ }, raw, undefined);
825
+ return;
826
+ }
827
+ // #4093: the generic terminus — no accepted labeled plan-position shape
828
+ // parsed anywhere in the document (the reporter's case: ## Current
829
+ // Position drifted to pure narrative prose with zero labeled fields).
830
+ // Every OTHER refusal above carries a machine-readable reason and the
831
+ // evidence to act on; this one stranded the caller at a bare sentence
832
+ // with no recovery path. Give it the same posture: a `reason` the caller
833
+ // can branch on, plus — when the position phase can be resolved and its
834
+ // directory scanned — the disk-derived facts and the exact labeled lines
835
+ // to re-insert. Nothing is WRITTEN: STATE.md is returned byte-identical
836
+ // (the callback already returned the original content for this path),
837
+ // so the decline is idempotent and no repair is guessed into the file —
838
+ // the caller (human or agent) applies the suggested lines and re-runs.
839
+ // Disk is the recovery source per #4067's posture; the values below are
840
+ // computed from the SAME `scanOutstanding` counts the plans_outstanding
841
+ // guard uses, so the two declines can never disagree about a phase.
842
+ const positionToken = positionPhaseRef.value;
843
+ const diskFacts = positionToken !== null
844
+ ? unsummarizedPlansForPositionPhase(cwd, positionToken)
845
+ : null;
846
+ if (diskFacts === null) {
847
+ // No resolvable phase (no Phase: line, no current_phase frontmatter, or
848
+ // no matching phase directory / incomplete scan): keep today's shape
849
+ // error, plus the reason so callers can tell this refusal from the
850
+ // ambiguous_* ones without string-matching the sentence.
851
+ output({ error: advancePlanShapeError(), reason: 'plan_position_unreadable' }, raw, undefined);
852
+ return;
853
+ }
854
+ const planCount = diskFacts.planCount;
855
+ const summarized = diskFacts.summaryCount;
856
+ // A summarized count below the plan count means the next plan to execute
857
+ // is summarized+1; an equal count means the phase is done on disk and the
858
+ // position line should say so (current = total; the next advance-plan run
859
+ // takes the #4067-guarded phase-complete branch from it). Zero plan files
860
+ // means disk has no opinion — suggest nothing rather than `1 of 0`.
861
+ const payload = {
862
+ error: advancePlanShapeError(),
863
+ reason: 'plan_position_unreadable',
864
+ phase_dir: diskFacts.dir,
865
+ disk: { plan_count: planCount, summarized_count: summarized },
866
+ };
867
+ if (planCount > 0) {
868
+ const current = summarized < planCount ? summarized + 1 : planCount;
869
+ payload['suggested'] = {
870
+ current_plan: current,
871
+ total_plans: planCount,
872
+ lines: [`Current Plan: ${current}`, `Total Plans in Phase: ${planCount}`],
873
+ };
874
+ payload['error'] =
875
+ `${advancePlanShapeError()} Disk for phase ${diskFacts.dir}: ${summarized} of ${planCount} plan(s) summarized. ` +
876
+ `Re-insert a labeled plan position at the top of ## Current Position (e.g. Current Plan: ${current} with Total Plans in Phase: ${planCount}), then re-run.`;
877
+ }
878
+ output(payload, raw, undefined);
602
879
  return;
603
880
  }
604
881
  // ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
@@ -803,7 +1080,7 @@ function computeUpdateProgressPreview(statePath, cwd) {
803
1080
  const existingFm = extractFrontmatter(preContent, statePath);
804
1081
  const preBody = stripFrontmatter(preContent);
805
1082
  const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
806
- const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
1083
+ const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
807
1084
  const progress = builtFm['progress'];
808
1085
  const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
809
1086
  const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
@@ -844,7 +1121,39 @@ function cmdStateUpdateProgress(cwd, raw) {
844
1121
  // excluded sentinels, unlike the owner). The owner already handles an
845
1122
  // absent phasesDir as a real empty, so the fs.existsSync guard folds
846
1123
  // into it.
847
- const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
1124
+ //
1125
+ // #2761 (round-11 BLOCKER, single-derivation hygiene): `phaseIdConvention`
1126
+ // threaded explicitly (resolved ambiently off `cwd` — this call site has
1127
+ // no `ws` of its own, same contract `resolvePhaseIdConvention` uses
1128
+ // elsewhere in this file, e.g. the `phaseConvention` ONCE-and-THREAD
1129
+ // pattern at ~:2267/:2300) rather than left `undefined`.
1130
+ //
1131
+ // This does NOT change `phaseScope` — `scope` (roadmap-parser.cts
1132
+ // `getMilestonePhaseFilter`) is assigned at :1979/:2030, both BEFORE
1133
+ // `headingConvention` resolves at ~:2048, so the #3217 withhold gate a
1134
+ // few lines below is convention-independent either way (verified
1135
+ // empirically: forcing `phaseIdConvention: null` here left every
1136
+ // `state update-progress` assertion in
1137
+ // tests/adr-612-bracket-phase-counting.test.cjs's round-11 BLOCKER block
1138
+ // unchanged). What DOES depend on convention is `phaseDirs`/`totalPlans`
1139
+ // — the enumerated `.value` these two lines feed into the #3233
1140
+ // zero-plans no-op check just below. The actual `percent` this command
1141
+ // reports/writes comes from a separate, already-correctly-threaded scan
1142
+ // (`computeUpdateProgressPreview` -> `buildStateFrontmatter`, which
1143
+ // resolves its own `phaseConvention` at :2267). Threading here removes a
1144
+ // second, silent, lazily-resolved answer for the SAME question that scan
1145
+ // already answers explicitly — the single-derivation discipline this
1146
+ // file's own :2300 comment states as a rule — rather than fixing an
1147
+ // observed defect. #2761 round-12: the #3233 gate IS the one place this
1148
+ // is observable, so it — not the reported percent — is what
1149
+ // tests/adr-612-bracket-phase-counting.test.cjs's round-12 addition to
1150
+ // the round-11 BLOCKER block pins: a bracket milestone with no plans on
1151
+ // disk versus a decoy directory outside the milestone window that must
1152
+ // not be swept in by a pass-all degrade.
1153
+ const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, {
1154
+ cwd,
1155
+ phaseIdConvention: cwd ? resolvePhaseIdConvention(cwd) : null,
1156
+ });
848
1157
  phaseScope = scope;
849
1158
  for (const dir of phaseDirs) {
850
1159
  const { planCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
@@ -861,10 +1170,10 @@ function cmdStateUpdateProgress(cwd, raw) {
861
1170
  // never read, and STATE.md's Progress field goes stale with no
862
1171
  // user-visible signal beyond it. Mirrors the established
863
1172
  // `[gsd-tools] WARNING:` stderr convention this file already uses
864
- // (stateReplaceFieldWithFallback above) for a comparable silent no-op.
865
- process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — phase scope is ${phaseScope}, not complete. ` +
866
- `STATE.md's Progress field was left unchanged.\n`);
867
- output({ updated: false, reason: `phase scope is ${phaseScope}, not complete` }, raw, 'false');
1173
+ // (stateReplaceFieldWithFallback above) for a comparable silent no-op —
1174
+ // now routed through the shared `declineNoOp` helper (#3957) so the
1175
+ // pairing is structural rather than hand-written per arm.
1176
+ declineNoOp(raw, 'updated', `phase scope is ${phaseScope}, not complete`, `state update-progress skipped — phase scope is ${phaseScope}, not complete. STATE.md's Progress field was left unchanged.`);
868
1177
  return;
869
1178
  }
870
1179
  // #3233: zero plans in the current-milestone phases means there is nothing to
@@ -876,9 +1185,7 @@ function cmdStateUpdateProgress(cwd, raw) {
876
1185
  // ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist,
877
1186
  // none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0.
878
1187
  if (totalPlans === 0) {
879
- process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — no plans found in current-milestone phases (0 plans). ` +
880
- `STATE.md's Progress field was left unchanged (milestone archived?).\n`);
881
- output({ updated: false, reason: 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)' }, raw, 'false');
1188
+ declineNoOp(raw, 'updated', 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)', `state update-progress skipped — no plans found in current-milestone phases (0 plans). STATE.md's Progress field was left unchanged (milestone archived?).`);
882
1189
  return;
883
1190
  }
884
1191
  // #3583: percent AND the completed/total counts reported alongside it both
@@ -889,48 +1196,30 @@ function cmdStateUpdateProgress(cwd, raw) {
889
1196
  // disagrees with its own completed/total.
890
1197
  const preview = computeUpdateProgressPreview(statePath, cwd);
891
1198
  if (preview.withheld) {
892
- process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — ${preview.reason}\n`);
893
- output({ updated: false, reason: preview.reason }, raw, 'false');
1199
+ declineNoOp(raw, 'updated', preview.reason, `state update-progress skipped — ${preview.reason}`);
894
1200
  return;
895
1201
  }
896
1202
  const { percent, completedPlans: fmCompletedPlans, totalPlans: fmTotalPlans } = preview;
897
- const barWidth = 10;
898
- const filled = Math.round(percent / 100 * barWidth);
899
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
900
- const progressStr = `[${bar}] ${percent}%`;
1203
+ const progressStr = formatProgressMachineSegment(percent);
901
1204
  let updated = false;
902
1205
  readModifyWriteStateMd(statePath, (content) => {
903
- // #2177: match against the BODY only. With /i the patterns below would
904
- // otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would
905
- // eat its newline, mangling the nested block), while the body Progress: line
906
- // — which frontmatter `percent` is re-derived from on every write — stays
907
- // stale and silently reverts the update.
908
- const body = stripFrontmatter(content);
909
- const fmPrefix = content.slice(0, content.length - body.length);
910
- // Swap only the machine segment ("[bar] NN%" or bare "NN%"), preserving any
911
- // descriptive suffix an agent authored, e.g. "(2/4 plans done; blocked on…)".
912
- const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
913
- const replaceValue = (value) => machineSegment.test(value)
914
- ? value.replace(machineSegment, progressStr)
915
- : progressStr;
916
- // Try **Progress:** bold format first, then plain Progress: format.
917
- const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
918
- const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
919
- const pattern = boldProgressPattern.test(body)
920
- ? boldProgressPattern
921
- : plainProgressPattern.test(body)
922
- ? plainProgressPattern
923
- : null;
924
- if (!pattern)
1206
+ const result = stateReplaceProgressPercent(content, percent);
1207
+ if (result === null)
925
1208
  return content;
926
1209
  updated = true;
927
- return fmPrefix + body.replace(pattern, (_match, prefix, value) => `${prefix}${replaceValue(value)}`);
1210
+ return result;
928
1211
  }, cwd);
929
1212
  if (updated) {
930
1213
  output({ updated: true, percent, completed: fmCompletedPlans, total: fmTotalPlans, bar: progressStr }, raw, progressStr);
931
1214
  }
932
1215
  else {
933
- output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
1216
+ // #3957: the frontmatter progress data was already confirmed present a
1217
+ // few lines above (computeUpdateProgressPreview didn't withhold) — what's
1218
+ // actually missing here is the BODY `Progress:`/`**Progress:**` line
1219
+ // itself. The prior 'Progress field not found in STATE.md' reason named
1220
+ // the wrong layer and silently discarded percent/completed/total, which
1221
+ // the sibling success arm above reports from the same preview.
1222
+ declineNoOp(raw, 'updated', 'no Progress: line found in STATE.md body to update (frontmatter progress data is unaffected)', 'state update-progress skipped — no Progress: line found in STATE.md body to update (frontmatter progress data is unaffected).', { percent, completed: fmCompletedPlans, total: fmTotalPlans });
934
1223
  }
935
1224
  }
936
1225
  function cmdStateAddDecision(cwd, options, raw) {
@@ -1236,7 +1525,15 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1236
1525
  output({ error: 'text required' }, raw, undefined);
1237
1526
  return;
1238
1527
  }
1239
- let resolved = false;
1528
+ // #3957: track section-found and bullet-matched SEPARATELY. Previously
1529
+ // `resolved` was set unconditionally as soon as the heading was located —
1530
+ // before checking whether any bullet line actually matched `text` — so a
1531
+ // call naming a non-existent blocker reported `resolved: true` (a false
1532
+ // success). Only a real bullet match makes `resolved` true and the
1533
+ // rewrite happen; otherwise the transform returns `content` unchanged
1534
+ // (this repo's established no-op-return idiom).
1535
+ let sectionFound = false;
1536
+ let matched = false;
1240
1537
  readModifyWriteStateMd(statePath, (content) => {
1241
1538
  // ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3.
1242
1539
  // Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
@@ -1244,6 +1541,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1244
1541
  const i = hs.findIndex(h => (h.level === 2 || h.level === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(h.text));
1245
1542
  if (i === -1)
1246
1543
  return content;
1544
+ sectionFound = true;
1247
1545
  const h = hs[i];
1248
1546
  const ls = content.split('\n');
1249
1547
  const hl = ls[h.line - 1];
@@ -1260,24 +1558,48 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1260
1558
  const filtered = lines.filter(line => {
1261
1559
  if (!line.startsWith('- '))
1262
1560
  return true;
1263
- return !line.toLowerCase().includes(text.toLowerCase());
1561
+ // Case-insensitive substring match — unchanged from before the fix;
1562
+ // only whether a match occurred is now tracked accurately.
1563
+ const isMatch = line.toLowerCase().includes(text.toLowerCase());
1564
+ if (isMatch)
1565
+ matched = true;
1566
+ return !isMatch;
1264
1567
  });
1568
+ if (!matched)
1569
+ return content;
1265
1570
  let newBody = filtered.join('\n');
1266
1571
  // If section is now empty, add placeholder
1267
1572
  if (!newBody.trim() || !newBody.includes('- ')) {
1268
1573
  newBody = 'None\n';
1269
1574
  }
1270
- resolved = true;
1271
1575
  return content.slice(0, bs) + newBody + content.slice(se);
1272
1576
  }, cwd);
1273
- if (resolved) {
1577
+ if (matched) {
1274
1578
  output({ resolved: true, blocker: text }, raw, 'true');
1275
1579
  }
1580
+ else if (!sectionFound) {
1581
+ declineNoOp(raw, 'resolved', 'no Blockers/Concerns section found in STATE.md', 'state resolve-blocker skipped — no Blockers/Concerns section found in STATE.md.');
1582
+ }
1276
1583
  else {
1277
- output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
1584
+ // `formatDiagnosticToken` only guards the STDERR disclosure — the JSON
1585
+ // `reason` field can embed `text` raw since output()'s own
1586
+ // JSON.stringify serialization already escapes it correctly.
1587
+ declineNoOp(raw, 'resolved', `no blocker matching ${text} found in the Blockers section`, `state resolve-blocker skipped — no blocker matching ${formatDiagnosticToken(text)} found in the Blockers section.`);
1278
1588
  }
1279
1589
  }
1280
1590
  function cmdStateRecordSession(cwd, options, raw) {
1591
+ // #4186: a bare invocation is a usage error, not a heartbeat write. The
1592
+ // pre-#4186 handler accepted zero arguments and still refreshed
1593
+ // `Last session` / `Last Date` / `last_updated` — a caller probing the
1594
+ // command's signature (the way other subcommands encourage) silently
1595
+ // mutated STATE.md. Mirrors `state update`'s required-arg guard
1596
+ // (cmdStateUpdate: `error('field and value required for state update')`),
1597
+ // including its ordering: validation precedes the STATE.md existence
1598
+ // check. Either flag suffices — `--resume-file` alone carries an explicit
1599
+ // value the handler must persist.
1600
+ if (!options.stopped_at && (options.resume_file === undefined || options.resume_file === null)) {
1601
+ error('stopped-at or resume-file required for state record-session');
1602
+ }
1281
1603
  const statePath = planningPaths(cwd).state;
1282
1604
  if (!node_fs_1.default.existsSync(statePath)) {
1283
1605
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -1516,8 +1838,22 @@ function cmdStateRecordSession(cwd, options, raw) {
1516
1838
  result['created'] = true;
1517
1839
  output(result, raw, 'true');
1518
1840
  }
1841
+ else if (updated.length === 0) {
1842
+ // Nothing was ever attempted — no --stopped-at/--resume-file supplied
1843
+ // and no existing Last session/Last Date/Stopped At/Resume File labels
1844
+ // to touch.
1845
+ declineNoOp(raw, 'recorded', 'no session fields found in STATE.md to update', 'state record-session skipped — no session fields found in STATE.md to update.');
1846
+ }
1519
1847
  else {
1520
- output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
1848
+ // #3957: `updated` (pre-reconciliation) was non-empty — a rewrite
1849
+ // matched a session field and reported it as changed — but
1850
+ // `reconcileReportedFields` found the persisted bytes byte-identical to
1851
+ // what was already on disk (the matched field's supplied value equals
1852
+ // its already-recorded value), so nothing actually changed. Distinct
1853
+ // from the "nothing was ever attempted" case above: the prior single
1854
+ // reason collapsed both into 'No session fields found in STATE.md',
1855
+ // which was simply wrong for this case.
1856
+ declineNoOp(raw, 'recorded', 'the matched session field(s) already held the reported value — no bytes changed', 'state record-session skipped — the matched session field(s) already held the reported value; no bytes changed.');
1521
1857
  }
1522
1858
  }
1523
1859
  /**
@@ -1819,7 +2155,56 @@ function cmdStateSnapshot(cwd, raw) {
1819
2155
  // ROADMAP phase token against an on-disk phase directory — moved to the
1820
2156
  // phase-id owner module in #2562 so every consumer derives BOTH sides of a
1821
2157
  // phase comparison from the same function (see phase-id.cts). Imported at the
1822
- // top of this file; call sites below are unchanged.
2158
+ // top of this file; call sites below are unchanged. #612 threads the optional
2159
+ // `convention` through that owner's `phaseKeyFromDir` (see phase-id.cts) rather
2160
+ // than re-deriving a bracket-aware key here.
2161
+ /**
2162
+ * #612: is the asserted milestone bounded to a heading in this ROADMAP?
2163
+ *
2164
+ * The legacy rule matches STATE's milestone STRING (`v2.0`) inside a heading.
2165
+ * The ADR-canonical bracket milestone heading is `## [GSD.02] Foundation` — a
2166
+ * name, no version — so that rule finds nothing, the milestone reads as
2167
+ * unbounded, and total_phases falls back to the on-disk directory count. Under
2168
+ * the bracket convention the milestone integer in the bracket is matched against
2169
+ * the `vN` of the milestone string instead (READING-B parity). Gated, and only
2170
+ * consulted after the legacy rule has already failed, so no non-bracket repo
2171
+ * changes answer.
2172
+ */
2173
+ function isMilestoneBounded(roadmapRaw, milestone, convention) {
2174
+ // #3184: preserve roadmap-parser's canonical legacy answer and compose the
2175
+ // gated bracket extension on top of it. Re-deriving the version-heading
2176
+ // grammar here would restore the boundary drift that #3184 removed.
2177
+ if (isMilestoneBoundedInRoadmap(roadmapRaw, String(milestone).trim()))
2178
+ return true;
2179
+ if (convention !== 'bracket')
2180
+ return false;
2181
+ const vMatch = String(milestone).trim().match(/^v(\d+)/i);
2182
+ const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
2183
+ if (!Number.isSafeInteger(milestoneInt))
2184
+ return false;
2185
+ // Canonical spelling only — see the note in roadmap-parser's scoping branch.
2186
+ // Accepting `0*N` here bounded a milestone whose phases were invisible, which
2187
+ // un-suppressed a progress percent computed off an unscoped disk count.
2188
+ // #2761 M3: that padding rule and the grammar both come from the owner's
2189
+ // `bracketMilestoneIntroSrcFor`. This line and roadmap-parser's selector were
2190
+ // character-identical re-typings of one pattern, so "canonical spelling only"
2191
+ // was a convention two files had to keep agreeing on by hand — and the drift
2192
+ // guard could not see either copy.
2193
+ // #612 round-4 (Major 1, F12): fence-aware via tokenizeHeadings, not a raw
2194
+ // `.test(roadmapRaw)` — a FENCED `[GSD.02]` example heading (the ONLY one
2195
+ // in the document, with no real section for the asserted milestone at
2196
+ // all) previously bounded a milestone that isn't actually in the roadmap,
2197
+ // un-suppressing a percent computed off the wrong (prior-milestone-plus-
2198
+ // whole-disk) phase set. tokenizeHeadings never produces a token for a
2199
+ // fenced line, so a fenced-only example can no longer satisfy this test.
2200
+ const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
2201
+ // #612 round-5 (Minor 1): skip ≤3-space-indented tokens — `h.offset` is
2202
+ // tokenizeHeadings' LINE-START offset, not the `#` character, so an
2203
+ // indented heading here would bound a milestone the line-start-anchored
2204
+ // raw predecessor never matched. Restores raw parity; see roadmap-parser's
2205
+ // matching selector-reconstruction comment for the full rationale.
2206
+ return (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapRaw).some((h) => h.level <= 3 && roadmapRaw[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text));
2207
+ }
1823
2208
  /**
1824
2209
  * Extract the set of retired/folded phase keys from a ROADMAP milestone scope
1825
2210
  * (#1514). A retired phase is struck through with GFM strikethrough,
@@ -1841,16 +2226,31 @@ function cmdStateSnapshot(cwd, raw) {
1841
2226
  * decimal, and project-code IDs are detected alike. Returns canonical keys
1842
2227
  * (see phaseKeyFromToken).
1843
2228
  */
1844
- function extractRetiredPhaseNumbers(scope) {
2229
+ function extractRetiredPhaseNumbers(scope, convention) {
1845
2230
  const retired = new Set();
1846
2231
  const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
1847
- for (const line of scope.split(/\r?\n/)) {
2232
+ // #612: the retirement filter has to widen with the counter it protects. The
2233
+ // canonical #1514 gesture strikes the checklist BULLET and leaves the detail
2234
+ // heading intact, so a bracket-form retirement went undetected and the phase
2235
+ // stayed in the denominator forever — a shipped bracket milestone could never
2236
+ // reach 100%. Same selection rule as the counter: a non-bracket repo compiles
2237
+ // the bare `Phase\s+` this line spelled before.
2238
+ const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
2239
+ const phaseRefRe = new RegExp(`^[\\s*_]*${introSrc}([\\w][\\w.-]*)`, 'i');
2240
+ // #612 round-5 (Major 1): fence-aware on the BRACKET path only — a fenced
2241
+ // AUTHORING EXAMPLE of the #1514 retirement gesture, spelled in bracket
2242
+ // form, must not retire a real phase. Reuses markdown-sectionizer's
2243
+ // single-owner stripFencedCode rather than a second fence parser. Legacy
2244
+ // stays the raw `scope`, byte-identical — its own fenced-example hazard is
2245
+ // pre-existing and out of scope.
2246
+ const scanScope = convention === 'bracket' ? (0, markdown_sectionizer_cjs_1.stripFencedCode)(scope).text : scope;
2247
+ for (const line of scanScope.split(/\r?\n/)) {
1848
2248
  if (!isChecklistOrHeading.test(line))
1849
2249
  continue;
1850
2250
  const strikeSpan = /~~([^~]*?)~~/g;
1851
2251
  let s;
1852
2252
  while ((s = strikeSpan.exec(line)) !== null) {
1853
- const phaseRef = /^[\s*_]*Phase\s+([\w][\w.-]*)/i.exec(s[1]);
2253
+ const phaseRef = phaseRefRe.exec(s[1]);
1854
2254
  // Require a digit so struck prose like ~~Phase Overview~~ is ignored.
1855
2255
  if (phaseRef && /\d/.test(phaseRef[1]))
1856
2256
  retired.add(phaseKeyFromToken(phaseRef[1]));
@@ -1858,12 +2258,110 @@ function extractRetiredPhaseNumbers(scope) {
1858
2258
  }
1859
2259
  return retired;
1860
2260
  }
2261
+ /**
2262
+ * #612 (round-4 fix): the single shared implementation for the phase-heading
2263
+ * counter `buildStateFrontmatter` (read path) and `cmdStateSync` (write
2264
+ * path) each built inline as an independent copy. The comment at each call
2265
+ * site already claimed "the two counters must see the same phases or
2266
+ * `state json` and `state sync` report different totals for one repo
2267
+ * (#3242 Bug B)" — this makes that invariant STRUCTURAL (one implementation,
2268
+ * two call sites) instead of two copies a future edit could silently
2269
+ * diverge.
2270
+ *
2271
+ * Two DELIBERATELY DIFFERENT counting strategies, selected by `convention`:
2272
+ *
2273
+ * - BRACKET: counts via `tokenizeHeadings(scope)` at levels 2-4 (mirroring
2274
+ * `getMilestonePhaseFilter`'s own level bound, `roadmap-parser.cts:1090`),
2275
+ * testing each heading's (hash-stripped, fence-STRIPPED-by-construction)
2276
+ * text against the phase-heading-intro grammar directly. Fence-aware by
2277
+ * construction — `tokenizeHeadings` never produces a token for a fenced
2278
+ * line — closing round-4's Major 1: a fenced EXAMPLE phase heading in the
2279
+ * preamble (`` ### [GSD.02] 05: Example phase `` inside a
2280
+ * ` ```markdown ` block) previously inflated this count via the raw regex
2281
+ * below, which ran over the whole scope STRING with no fence awareness at
2282
+ * all (F9, F10 — `total_phases` read 3 where the milestone has 2 real
2283
+ * phases). The producer (`extractCurrentMilestone`'s returned scope
2284
+ * string) is deliberately NOT changed — every other consumer of that
2285
+ * string needs its full content fidelity, and the legacy path's identity
2286
+ * forbids touching the string all consumers share; this fixes the
2287
+ * COUNTING, not the scope.
2288
+ *
2289
+ * - LEGACY (any non-bracket convention, including unresolved/null): retain
2290
+ * the existing raw `content.exec()` counting strategy. On the read path,
2291
+ * route sentinel exclusion through #3185's canonical predicate; the sync
2292
+ * path intentionally retains its pre-existing absence of that exclusion.
2293
+ *
2294
+ * `applyConventionTokenSentinelRules` makes the remaining convention-specific
2295
+ * asymmetry explicit. Both read and sync exclude bare bracket token 999; only
2296
+ * the read path excludes canonical legacy sentinels. Both bracket paths also
2297
+ * retain the bracket-id and bare-0 rules. Sharing the implementation therefore
2298
+ * cannot silently move either convention's total.
2299
+ */
2300
+ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules) {
2301
+ let count = 0;
2302
+ if (convention === 'bracket') {
2303
+ const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true);
2304
+ const phaseHeadingPattern = new RegExp(`^${introSrc}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
2305
+ for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(scope)) {
2306
+ if (h.level < 2 || h.level > 4)
2307
+ continue;
2308
+ const m = phaseHeadingPattern.exec(h.text);
2309
+ if (!m)
2310
+ continue;
2311
+ const bracketId = m[1];
2312
+ const token = m[2];
2313
+ // Only count tokens that contain at least one digit — excludes
2314
+ // pure-word section headings (Overview, Details) while keeping
2315
+ // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
2316
+ if (!/\d/.test(token))
2317
+ continue;
2318
+ // #612 READING-B: a bracket heading carries its sentinel in the
2319
+ // bracket, so `### [GSD.999] 01:` is an icebox item even though its
2320
+ // token is `01`.
2321
+ if (bracketId && isSentinelPhaseId(`${bracketId}-${token}`, 'bracket'))
2322
+ continue;
2323
+ // #612: under bracket the token rule composes with the bracket-id
2324
+ // check as the engine's {0, 999} sentinel set.
2325
+ if (bracketId && /^0\b/.test(token))
2326
+ continue;
2327
+ if (applyConventionTokenSentinelRules && /^999\b/.test(token))
2328
+ continue;
2329
+ // #1514: retired/folded phases are struck through in the ROADMAP;
2330
+ // exclude them from the denominator (they can never be completed).
2331
+ if (retiredPhaseNums.has(phaseKeyFromToken(token)))
2332
+ continue;
2333
+ count++;
2334
+ }
2335
+ return count;
2336
+ }
2337
+ // LEGACY stays on the pre-round-4 raw exec loop. #3185 owns the read-path
2338
+ // sentinel predicate; sync deliberately preserves its prior behavior.
2339
+ const phaseHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
2340
+ let m;
2341
+ while ((m = phaseHeadingPattern.exec(scope)) !== null) {
2342
+ const token = m[1];
2343
+ if (!/\d/.test(token))
2344
+ continue;
2345
+ if (applyConventionTokenSentinelRules && isSentinelPhaseId(token))
2346
+ continue;
2347
+ if (retiredPhaseNums.has(phaseKeyFromToken(token)))
2348
+ continue;
2349
+ count++;
2350
+ }
2351
+ return count;
2352
+ }
1861
2353
  /**
1862
2354
  * Extract machine-readable fields from STATE.md markdown body and build
1863
2355
  * a YAML frontmatter object. Allows hooks and scripts to read state
1864
2356
  * reliably via `state json` instead of fragile regex parsing.
1865
2357
  */
1866
- function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases) {
2358
+ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases,
2359
+ // #4094: the stored siblings of storedTotalPhases, threaded from each call
2360
+ // site exactly the same way — see readStoredProgressCounter below. Under the
2361
+ // #3354/#3573 withhold condition the disk scan returns null for all four
2362
+ // counters, and these stored values are what the progress block falls back
2363
+ // to (else the keys are omitted).
2364
+ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
1867
2365
  // #2956: scope `Phase` extraction to ## Current Position (mirrors the read
1868
2366
  // path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
1869
2367
  // below). Phase canonically lives in ## Current Position (templates/state.md);
@@ -1953,6 +2451,10 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1953
2451
  // from the pre-existing frontmatter fields parsed above, a path this phase
1954
2452
  // does not touch and which predates listMilestonePhaseDirs entirely.
1955
2453
  let diskScope = SCOPE.COMPLETE;
2454
+ // #612: resolved ONCE per call, federated workstream -> root, and shared by
2455
+ // the heading counter, the retirement filter and the retired-directory skip so
2456
+ // no two of them can split on different answers.
2457
+ const phaseConvention = cwd ? resolvePhaseIdConvention(cwd) : null;
1956
2458
  if (cwd) {
1957
2459
  try {
1958
2460
  const phasesDir = planningPaths(cwd).phases;
@@ -1973,7 +2475,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1973
2475
  roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1974
2476
  if (roadmapRaw !== null) {
1975
2477
  roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1976
- retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
2478
+ retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention);
1977
2479
  }
1978
2480
  }
1979
2481
  catch { /* fall through: no roadmap scope → no retired exclusion */ }
@@ -1984,7 +2486,11 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1984
2486
  // CURRENT (stored) milestone" — routed through the canonical owner
1985
2487
  // instead of a hand-rolled readdirSync + isDirInMilestone filter
1986
2488
  // (which also never excluded sentinels, unlike the owner).
1987
- const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: storedMilestone ?? null });
2489
+ const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, {
2490
+ cwd,
2491
+ versionOverride: storedMilestone ?? null,
2492
+ phaseIdConvention: phaseConvention,
2493
+ });
1988
2494
  // Bug #2445: when stale phase dirs from a prior milestone remain in
1989
2495
  // .planning/phases/ alongside new dirs with the same phase number,
1990
2496
  // de-duplicate by normalized phase number keeping exactly one dir
@@ -1996,7 +2502,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1996
2502
  // artifact; drop it from the disk phase set so it counts toward
1997
2503
  // neither the denominator nor the numerator (mirrors the heading
1998
2504
  // exclusion below). Project-code-aware via phaseKeyFromDir.
1999
- if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
2505
+ if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
2000
2506
  continue;
2001
2507
  // #3185: dedup grouping routed through the canonical phaseKeyFromDir
2002
2508
  // (src/phase-id.cts) instead of a local leading-digits regex that
@@ -2005,7 +2511,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2005
2511
  // so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
2006
2512
  // multi-segment milestone dirs. Same key surface used two lines
2007
2513
  // above for the retiredPhaseNums exclusion, so both filters agree.
2008
- const key = phaseKeyFromDir(dir);
2514
+ const key = phaseKeyFromDir(dir, phaseConvention);
2009
2515
  if (!seenPhaseNums.has(key)) {
2010
2516
  seenPhaseNums.set(key, dir);
2011
2517
  }
@@ -2046,34 +2552,21 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2046
2552
  // own comment on that field). Folding this consumer onto the raw
2047
2553
  // summaries-met flag was the exact "consolidate two of three and
2048
2554
  // leave the third" gap §7.4's forcing function rules out.
2049
- if (isPhaseComplete(phaseDir).value.complete)
2555
+ // #612: `phaseConvention` threaded so a bracket phase dir resolves
2556
+ // and scopes its verification report like its legacy twin — the
2557
+ // read-side half of the same thread cmdStateSync gets below.
2558
+ if (isPhaseComplete(phaseDir, { convention: phaseConvention }).value.complete)
2050
2559
  diskCompletedPhases++;
2051
2560
  }
2052
- // Count phase headings from ROADMAP using a digit-containing pattern
2053
- // that matches both numeric phases (01, 05.1) and project-code phases
2054
- // (PROJ-42, CK-05) but excludes pure-word section headers like
2055
- // `## Phase Overview:` or `## Phase Details:` — single source of
2056
- // truth for total_phases (#549).
2057
- let roadmapPhaseCount = 0;
2058
- if (roadmapScope !== null) {
2059
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
2060
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
2061
- let m;
2062
- while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
2063
- // Only count tokens that contain at least one digit — excludes
2064
- // pure-word section headings (Overview, Details) while keeping
2065
- // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
2066
- // Also exclude sentinel phases (0 and 999.x backlog).
2067
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2068
- if (!/\d/.test(m[1]) || isSentinelPhaseId(m[1]))
2069
- continue;
2070
- // #1514: retired/folded phases are struck through in the ROADMAP;
2071
- // exclude them from the denominator (they can never be completed).
2072
- if (retiredPhaseNums.has(phaseKeyFromToken(m[1])))
2073
- continue;
2074
- roadmapPhaseCount++;
2075
- }
2076
- }
2561
+ // Count phase headings from ROADMAP — single source of truth for
2562
+ // total_phases (#549). #612 round-4: shared with cmdStateSync's
2563
+ // identical-purpose counter via countRoadmapPhaseHeadings (above
2564
+ // extractRetiredPhaseNumbers). The shared helper composes its
2565
+ // fence-aware bracket strategy with #3185's canonical legacy
2566
+ // sentinel predicate for this read-path call.
2567
+ const roadmapPhaseCount = roadmapScope !== null
2568
+ ? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true)
2569
+ : 0;
2077
2570
  cached = (() => {
2078
2571
  // #1761 read-path: mirror the cmdStateSync guard (#1794). When the
2079
2572
  // asserted milestone version can't be bounded to a versioned ROADMAP
@@ -2098,7 +2591,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2098
2591
  // the prior inline regex had no boundary assertion after the
2099
2592
  // version token, so `v2.0` matched inside `v2.0.1` (#2562-class
2100
2593
  // defect, design row 17).
2101
- milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(assertedMilestoneVersion).trim());
2594
+ milestoneBounded = isMilestoneBounded(roadmapRaw, String(assertedMilestoneVersion).trim(), phaseConvention);
2102
2595
  }
2103
2596
  // #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
2104
2597
  // at all — only Phase headings) from a MILESTONED-but-unbounded one
@@ -2140,7 +2633,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2140
2633
  // the disk count is the only source and remains correct.
2141
2634
  const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
2142
2635
  if (milestonedButUnbounded) {
2143
- process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3354/#3642)\n`);
2636
+ process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so the progress counters (total_phases, completed_phases, total_plans, completed_plans) are left at their stored values. (#3354/#3642/#4094)\n`);
2144
2637
  }
2145
2638
  // #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
2146
2639
  // absent/unreadable the #549 heading counter never ran (roadmapScope
@@ -2157,21 +2650,59 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2157
2650
  typeof storedMilestone === 'string' &&
2158
2651
  storedMilestone.trim() !== '';
2159
2652
  if (roadmapAbsentWithAssertedMilestone) {
2160
- process.stderr.write(`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3573)\n`);
2653
+ process.stderr.write(`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so the progress counters (total_phases, completed_phases, total_plans, completed_plans) are left at their stored values. (#3573) (#4094)\n`);
2161
2654
  }
2655
+ // #4094: the withhold condition covers ALL FOUR progress counters,
2656
+ // not just total_phases. completed_phases / total_plans /
2657
+ // completed_plans are accumulated from the exact same phaseDirs
2658
+ // walk as total_phases (same loop, same scope, same filters), so
2659
+ // whenever that walk's scope is known-untrustworthy — the exact
2660
+ // condition #3354 established — they are equally untrustworthy.
2661
+ // Pre-#4094 only totalPhases was nulled here, so every resyncing
2662
+ // write silently clobbered the three stored siblings with the
2663
+ // under-scoped disk numbers.
2664
+ const diskCountsWithheld = milestonedButUnbounded || roadmapAbsentWithAssertedMilestone;
2665
+ // #4129: floor the completed-phases numerator at the ROADMAP's own
2666
+ // milestone Complete-row count. The disk numerator counts ONLY
2667
+ // phase dirs whose *-VERIFICATION.md routes `passed` (isPhaseComplete,
2668
+ // #2957 disk-strict — the gate stays untouched), so a completed
2669
+ // phase whose verification reads `stale` (a SUMMARY committed or
2670
+ // edited after it, #2348 clean-commit-time clock) or `missing`
2671
+ // (pre-verification era, hand-flipped ROADMAP row) drops out of the
2672
+ // count forever — while every other surface (the ROADMAP row
2673
+ // `phase complete` just flipped, the body `Completed Phases` field
2674
+ // completePhaseCore derives from deriveProgressFromRoadmap) still
2675
+ // asserts the phase complete. max(disk, ROADMAP) keeps the disk
2676
+ // signal for gap detection (a verification-passed phase whose ROADMAP
2677
+ // row is not yet flipped still counts) while never UNDER-counting
2678
+ // what the ROADMAP asserts. Scoped exactly like the denominator:
2679
+ // the same milestone window (roadmapScope), the same
2680
+ // safeToUseRoadmapCount gate, and never under the #3354/#3573
2681
+ // withhold — a whole-document Complete-row count must not leak
2682
+ // through an untrustworthy scope. Reuses deriveProgressFromRoadmap
2683
+ // (phase-lifecycle.cts, the one owner of "read the Progress table")
2684
+ // — no second ROADMAP parser here. A ROADMAP without a canonical
2685
+ // `## Progress` table resolves no table → floor inert (disk count
2686
+ // stands), the owner's own answer to "what is countable".
2687
+ const roadmapCompletedPhases = roadmapScope !== null && safeToUseRoadmapCount && !diskCountsWithheld
2688
+ ? deriveProgressFromRoadmap(roadmapScope).completedPhases
2689
+ : null;
2690
+ const flooredCompletedPhases = roadmapCompletedPhases !== null
2691
+ ? Math.max(diskCompletedPhases, roadmapCompletedPhases)
2692
+ : diskCompletedPhases;
2162
2693
  return {
2163
2694
  // The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
2164
2695
  // roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
2165
2696
  // safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
2166
2697
  // vacuously true (its gate requires roadmapRaw), so the safe-count
2167
2698
  // arm would otherwise swallow the withhold.
2168
- totalPhases: (milestonedButUnbounded || roadmapAbsentWithAssertedMilestone)
2699
+ totalPhases: diskCountsWithheld
2169
2700
  ? null
2170
2701
  : (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
2171
2702
  milestoneBounded,
2172
- completedPhases: diskCompletedPhases,
2173
- totalPlans: diskTotalPlans,
2174
- completedPlans: diskTotalSummaries,
2703
+ completedPhases: diskCountsWithheld ? null : flooredCompletedPhases,
2704
+ totalPlans: diskCountsWithheld ? null : diskTotalPlans,
2705
+ completedPlans: diskCountsWithheld ? null : diskTotalSummaries,
2175
2706
  phaseDirScope,
2176
2707
  };
2177
2708
  })();
@@ -2189,9 +2720,31 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2189
2720
  else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
2190
2721
  totalPhases = storedTotalPhases;
2191
2722
  }
2192
- completedPhases = cached.completedPhases;
2193
- totalPlans = cached.totalPlans;
2194
- completedPlans = cached.completedPlans;
2723
+ // #4094: the same withhold-then-fall-back-to-stored pattern for the
2724
+ // three sibling counters. They are derived from the identical
2725
+ // phaseDirs walk, so cached.* === null here means the SAME withheld
2726
+ // condition — keep the stored frontmatter value when the caller can
2727
+ // supply it; else leave null (key omitted). Note completedPhases /
2728
+ // completedPlans have NO body-annotation fallback (only the totals
2729
+ // have body annotations), so an unstored-withheld counter is omitted.
2730
+ if (cached.completedPhases !== null) {
2731
+ completedPhases = cached.completedPhases;
2732
+ }
2733
+ else if (storedCompletedPhases !== null && storedCompletedPhases !== undefined) {
2734
+ completedPhases = storedCompletedPhases;
2735
+ }
2736
+ if (cached.totalPlans !== null) {
2737
+ totalPlans = cached.totalPlans;
2738
+ }
2739
+ else if (storedTotalPlans !== null && storedTotalPlans !== undefined) {
2740
+ totalPlans = storedTotalPlans;
2741
+ }
2742
+ if (cached.completedPlans !== null) {
2743
+ completedPlans = cached.completedPlans;
2744
+ }
2745
+ else if (storedCompletedPlans !== null && storedCompletedPlans !== undefined) {
2746
+ completedPlans = storedCompletedPlans;
2747
+ }
2195
2748
  milestoneUnbounded = cached.milestoneBounded === false;
2196
2749
  diskScope = cached.phaseDirScope;
2197
2750
  }
@@ -2240,19 +2793,19 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2240
2793
  progressPercent = parseInt(pctMatch[1], 10);
2241
2794
  }
2242
2795
  let normalizedStatus = (0, state_document_cjs_1.normalizeStateStatus)(status, pausedAt);
2243
- // #3578: normalizeStateStatus matches 'complete' as a case-insensitive
2244
- // SUBSTRING, so the phase-completion prose cmdStateCompletePhase writes to
2245
- // the body (`Phase ${N} complete`) collapses to the milestone-level
2246
- // 'completed' status even when other phases remain open. Phase-level
2247
- // prose must never decide milestone-level status — completedPhases /
2248
- // totalPhases / diskScope, already derived above from a disk scan, are
2249
- // the authority on whether the MILESTONE is actually done. Only override
2250
- // when: (a) normalizeStateStatus actually landed on 'completed'; (b) the
2251
- // raw prose is UNAMBIGUOUSLY phase-completion prose — the anchored
2252
- // pattern below deliberately excludes "All phases complete" (no `\S+`
2253
- // phase token) and milestone-close prose like "v1.0 milestone complete"
2254
- // (no leading "phase"); and (c) the counters are trustworthy (a COMPLETE
2255
- // disk scope, both counts are finite numbers, and a positive
2796
+ // #3578: the declared status vocabulary (#4186) recognizes
2797
+ // `Phase ${N} complete` (state.cts's own phase-completion write) and maps
2798
+ // it to `completed`, so the phase-completion prose still collapses to the
2799
+ // milestone-level status even when other phases remain open — this guard
2800
+ // demotes it back. Phase-level prose must never decide milestone-level
2801
+ // status — completedPhases / totalPhases / diskScope, already derived above
2802
+ // from a disk scan, are the authority on whether the MILESTONE is actually
2803
+ // done. Only override when: (a) normalizeStateStatus actually landed on
2804
+ // 'completed'; (b) the raw prose is UNAMBIGUOUSLY phase-completion prose —
2805
+ // the anchored pattern below deliberately excludes "All phases complete"
2806
+ // (no `\S+` phase token) and milestone-close prose like "v1.0 milestone
2807
+ // complete" (no leading "phase"); and (c) the counters are trustworthy (a
2808
+ // COMPLETE disk scope, both counts are finite numbers, and a positive
2256
2809
  // denominator) and affirmatively disagree with 'completed'. In every
2257
2810
  // other case normalizedStatus is left exactly as normalizeStateStatus
2258
2811
  // returned it.
@@ -2483,12 +3036,21 @@ function readStateHeadFreshness(cwd, stateHead) {
2483
3036
  * instead of being clobbered by the on-disk phase-directory count.
2484
3037
  */
2485
3038
  function readStoredTotalPhases(existingFm) {
3039
+ return readStoredProgressCounter(existingFm, 'total_phases');
3040
+ }
3041
+ /**
3042
+ * #4094: the three sibling readers of readStoredTotalPhases, one per progress
3043
+ * counter the #3354/#3573 withhold now protects. All four counters come from
3044
+ * the same disk-scan walk and are withheld together; these readers feed the
3045
+ * stored-value fallback for the three that previously had none.
3046
+ */
3047
+ function readStoredProgressCounter(existingFm, key) {
2486
3048
  if (!existingFm || typeof existingFm !== 'object')
2487
3049
  return null;
2488
3050
  const progress = existingFm['progress'];
2489
3051
  if (!progress || typeof progress !== 'object')
2490
3052
  return null;
2491
- const raw = progress['total_phases'];
3053
+ const raw = progress[key];
2492
3054
  if (raw === null || raw === undefined)
2493
3055
  return null;
2494
3056
  if (typeof raw === 'string' && raw.trim() === '')
@@ -2496,6 +3058,84 @@ function readStoredTotalPhases(existingFm) {
2496
3058
  const n = Number(raw);
2497
3059
  return Number.isFinite(n) ? n : null;
2498
3060
  }
3061
+ function readStoredCompletedPhases(existingFm) {
3062
+ return readStoredProgressCounter(existingFm, 'completed_phases');
3063
+ }
3064
+ function readStoredTotalPlans(existingFm) {
3065
+ return readStoredProgressCounter(existingFm, 'total_plans');
3066
+ }
3067
+ function readStoredCompletedPlans(existingFm) {
3068
+ return readStoredProgressCounter(existingFm, 'completed_plans');
3069
+ }
3070
+ /**
3071
+ * #4129: is this authoritativeFm value a PARTIAL progress intent? The #2736
3072
+ * seam was string-only (names); #4129 extends it with one object direction —
3073
+ * the `progress` key carrying the sub-keys a transition resolved
3074
+ * authoritatively (completePhase's ROADMAP-derived completed_phases/percent).
3075
+ * Anything else keeps the seam's existing contract untouched.
3076
+ */
3077
+ function isPartialProgressIntent(value) {
3078
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3079
+ }
3080
+ /**
3081
+ * #4129: merge a PARTIAL progress intent (see isPartialProgressIntent) into a
3082
+ * frontmatter object's `progress` block. Sub-keys are accepted only when they
3083
+ * are a declared `progress.*` row in FIELD_CLASSIFICATION — the single policy
3084
+ * source (ADR-3408 §8.5) decides which leaves exist; an intent may not invent
3085
+ * one. Returns whether anything changed.
3086
+ *
3087
+ * `completedOnlyRaise` (both application sites use it): completed counters
3088
+ * apply only when strictly greater than what is already in the block, so no
3089
+ * intent can LOWER a count another trustworthy signal already established —
3090
+ * at the pre-preservation site the disk derivation's own count (a
3091
+ * verification-passed phase whose ROADMAP row drifted behind), at the
3092
+ * post-preservation re-assert the #2969 monotonic property preservation just
3093
+ * enforced. `percent` follows its sibling: it is applied when a completed
3094
+ * counter moved this call (the intent percent was computed from the intent
3095
+ * counters and is coherent with them) or when the block has no percent to
3096
+ * lose (a repair, never a regression of an upstream withhold — the withhold
3097
+ * nulled percent upstream precisely so no write would re-assert one over
3098
+ * untrustworthy counts; here the intent's own counts ARE the trustworthy
3099
+ * source, the post-completion ROADMAP).
3100
+ */
3101
+ function applyAuthoritativeProgressSubkeys(fm, intent, opts) {
3102
+ const current = fm['progress'];
3103
+ const base = isPartialProgressIntent(current)
3104
+ ? { ...current }
3105
+ : {};
3106
+ let changed = false;
3107
+ let completedMoved = false;
3108
+ for (const [subkey, value] of Object.entries(intent)) {
3109
+ if (typeof value !== 'number' || !Number.isFinite(value))
3110
+ continue;
3111
+ if (!getFieldClassification(`progress.${subkey}`))
3112
+ continue;
3113
+ const isCompletedCounter = subkey === 'completed_phases' || subkey === 'completed_plans';
3114
+ if (isCompletedCounter && opts.completedOnlyRaise) {
3115
+ const currentNum = (0, state_document_cjs_1.toFiniteNumber)(base[subkey]);
3116
+ if (currentNum !== null && currentNum >= value)
3117
+ continue;
3118
+ }
3119
+ if (isCompletedCounter && !Object.is(base[subkey], value))
3120
+ completedMoved = true;
3121
+ if (!Object.is(base[subkey], value)) {
3122
+ base[subkey] = value;
3123
+ changed = true;
3124
+ }
3125
+ }
3126
+ // percent: applied only when a completed counter moved (coherent with the
3127
+ // counters that just landed) or when no percent exists to contradict.
3128
+ const intentPercent = intent['percent'];
3129
+ if (typeof intentPercent === 'number' && Number.isFinite(intentPercent) && (completedMoved || (0, state_document_cjs_1.toFiniteNumber)(base['percent']) === null)) {
3130
+ if (!Object.is(base['percent'], intentPercent)) {
3131
+ base['percent'] = intentPercent;
3132
+ changed = true;
3133
+ }
3134
+ }
3135
+ if (changed)
3136
+ fm['progress'] = base;
3137
+ return changed;
3138
+ }
2499
3139
  function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
2500
3140
  // Read existing frontmatter BEFORE stripping — it may contain values
2501
3141
  // that the body no longer has (e.g., Status field removed by an agent).
@@ -2533,7 +3173,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
2533
3173
  // milestoned-but-unbounded withhold can preserve it across the write
2534
3174
  // (the derived progress sub-block replaces the stored one wholesale below,
2535
3175
  // so an omitted key would otherwise DELETE the stored value).
2536
- const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
3176
+ const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
2537
3177
  // Preserve existing frontmatter status when body-derived status is 'unknown'.
2538
3178
  // This prevents a missing Status: field in the body from overwriting a
2539
3179
  // previously valid status (e.g., 'executing' → 'unknown').
@@ -2701,11 +3341,22 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
2701
3341
  // parenthetical (`Closer-ruling measurement (D1a)` → `D1a`) — never runs
2702
3342
  // the final word on a field the transition just resolved. The prose parser
2703
3343
  // remains the fallback for genuinely unknown prose only.
3344
+ // #4129: the `progress` key carries a PARTIAL block (the object direction of
3345
+ // this seam — see applyAuthoritativeProgressSubkeys) for the same reason:
3346
+ // completePhase holds the POST-completion ROADMAP, and the disk scan this
3347
+ // function drives reads the PRE-completion one. The intent is applied as a
3348
+ // FLOOR here too (completedOnlyRaise): a derivation that already counted
3349
+ // MORE completed phases than the ROADMAP table asserts (verification-passed
3350
+ // phases whose table rows drifted behind) must not be lowered by the intent
3351
+ // — the two signals agree on direction (up), never on subtraction.
2704
3352
  if (authoritativeFm) {
2705
3353
  for (const [key, value] of Object.entries(authoritativeFm)) {
2706
3354
  if (typeof value === 'string' && value.trim().length > 0) {
2707
3355
  derivedFm[key] = value;
2708
3356
  }
3357
+ else if (key === 'progress' && isPartialProgressIntent(value)) {
3358
+ applyAuthoritativeProgressSubkeys(derivedFm, value, { completedOnlyRaise: true });
3359
+ }
2709
3360
  }
2710
3361
  }
2711
3362
  // #3257: propagate full-line frontmatter comments from the extracted source onto the
@@ -3282,6 +3933,10 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3282
3933
  // (equal), so the #1695 restore fires and would put the stale pre-transition
3283
3934
  // name back over the authoritative one. Intent beats both the prose
3284
3935
  // re-derivation and the curated restore — the transition just resolved it.
3936
+ // #4129: for the `progress` key the re-assert is a FLOOR, not an override —
3937
+ // the #2969 monotonic property preservation just enforced (completed
3938
+ // counters never move down) must not be undone by the intent, so completed
3939
+ // sub-keys apply only-raise here (see applyAuthoritativeProgressSubkeys).
3285
3940
  let authoritativeReasserted = false;
3286
3941
  if (authoritativeFm) {
3287
3942
  for (const [key, value] of Object.entries(authoritativeFm)) {
@@ -3289,8 +3944,14 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3289
3944
  preservation.postFm[key] = value;
3290
3945
  authoritativeReasserted = true;
3291
3946
  }
3947
+ else if (key === 'progress' && isPartialProgressIntent(value)) {
3948
+ if (applyAuthoritativeProgressSubkeys(preservation.postFm, value, { completedOnlyRaise: true })) {
3949
+ authoritativeReasserted = true;
3950
+ }
3951
+ }
3292
3952
  }
3293
3953
  }
3954
+ let finalContent = syncedContent;
3294
3955
  if (preservation.mutated || authoritativeReasserted) {
3295
3956
  // #3742: preservation RESTORES frontmatter keys the body-derived rebuild
3296
3957
  // could not produce (e.g. `current_phase` on a layout with no body
@@ -3311,9 +3972,15 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3311
3972
  }
3312
3973
  const yamlStr = reconstructFrontmatter(preservation.postFm);
3313
3974
  const body = stripFrontmatter(syncedContent);
3314
- return `---\n${yamlStr}\n---\n\n${body}`;
3975
+ finalContent = `---\n${yamlStr}\n---\n\n${body}`;
3976
+ }
3977
+ const persistedPercent = (0, state_document_cjs_1.toFiniteNumber)(preservation.postFm['progress'] && preservation.postFm['progress']['percent']);
3978
+ if (persistedPercent !== null) {
3979
+ const reconciled = stateReplaceProgressPercent(finalContent, persistedPercent);
3980
+ if (reconciled !== null)
3981
+ finalContent = reconciled;
3315
3982
  }
3316
- return syncedContent;
3983
+ return finalContent;
3317
3984
  }
3318
3985
  /**
3319
3986
  * ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
@@ -3858,7 +4525,7 @@ function cmdStateJson(cwd, raw) {
3858
4525
  // reports the phase-directory count while the persisted file preserves the
3859
4526
  // stored total, exactly the write/read divergence #3354 closed for its shape.
3860
4527
  const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
3861
- const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
4528
+ const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
3862
4529
  // ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
3863
4530
  // current_phase_name / current_plan through the SAME `preserve-when-unchanged`
3864
4531
  // executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
@@ -3947,6 +4614,26 @@ function cmdStateJson(cwd, raw) {
3947
4614
  * Fixes: #1102 (plan counts), #1103 (status/last_activity), #1104 (body text).
3948
4615
  */
3949
4616
  function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
4617
+ // #4138: `--phase` is this verb's one required argument, and an invocation
4618
+ // that names no phase must fail closed BEFORE any read-modify-write runs —
4619
+ // previously the missing flag flowed through as null and the transition
4620
+ // serialised `String(null)` into the body (`Phase: null — EXECUTING`,
4621
+ // `Status: Executing Phase null`, `last_activity_desc: Phase null execution
4622
+ // started`) while the post-sync frontmatter rebuild dropped current_phase /
4623
+ // current_phase_name entirely, so a single argument-less call un-set the
4624
+ // phase identity. The guard mirrors the sibling usage errors that already
4625
+ // exit non-zero (`state update`'s "field and value required", the router's
4626
+ // "unexpected positional argument" / "Invalid --plans value"), NOT
4627
+ // `cmdStateMilestoneSwitch`'s `output({error})` form, which exits 0 — the
4628
+ // issue's Expected is explicit: "Exit non-zero with a usage message and
4629
+ // write nothing." Empty and whitespace-only values are the same missing
4630
+ // argument (CONTRIBUTING.md CLI matrix); a flag-shaped `--phase --name x`
4631
+ // resolves to null in parseNamedArgs and lands here too. Runs before the
4632
+ // STATE.md existence check so argument validation always precedes I/O, and
4633
+ // before claimMilestonePhase so no phase-"null" milestone claim is taken.
4634
+ if (phaseNumber == null || String(phaseNumber).trim() === '') {
4635
+ error('phase required (--phase <N>)');
4636
+ }
3950
4637
  const statePath = planningPaths(cwd).state;
3951
4638
  if (!node_fs_1.default.existsSync(statePath)) {
3952
4639
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -3960,7 +4647,11 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
3960
4647
  // #1230 post-sync preservation, and the no-op write guard.
3961
4648
  const intent = {
3962
4649
  kind: 'beginPhase',
3963
- phaseNumber,
4650
+ // The guard above made this non-null/non-empty; `error` is never-returning
4651
+ // at runtime but this module's destructured io binding does not narrow CFA,
4652
+ // so the narrowed fact is restated once (cmdStateUpdate's `field as string`
4653
+ // idiom, state.cts:782).
4654
+ phaseNumber: phaseNumber,
3964
4655
  phaseName: phaseName ?? null,
3965
4656
  planCount: planCount ?? null,
3966
4657
  };
@@ -4256,6 +4947,12 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
4256
4947
  * Updates Status to "Ready to execute", Total Plans, Last Activity.
4257
4948
  */
4258
4949
  function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
4950
+ // #4383: mirror begin-phase's command-boundary guard. A missing phase must
4951
+ // fail before even looking up STATE.md so no invalid invocation can enter
4952
+ // the read-modify-write path and serialize a null/blank phase identity.
4953
+ if (phaseNumber == null || String(phaseNumber).trim() === '') {
4954
+ error('phase required (--phase <N>)');
4955
+ }
4259
4956
  const statePath = planningPaths(cwd).state;
4260
4957
  if (!node_fs_1.default.existsSync(statePath)) {
4261
4958
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -4271,7 +4968,7 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
4271
4968
  // still owns the lock, the #1230 preservation, and the no-op write guard.
4272
4969
  const intent = {
4273
4970
  kind: 'plannedPhase',
4274
- phaseNumber,
4971
+ phaseNumber: phaseNumber,
4275
4972
  phaseName: phaseName ?? null,
4276
4973
  planCount: planCount ?? null,
4277
4974
  };
@@ -4558,10 +5255,28 @@ function cmdStateValidate(cwd, raw, opts = {}) {
4558
5255
  emit({ valid: false, warnings, scope });
4559
5256
  return;
4560
5257
  }
5258
+ // #612: #3208 replaced this lookup's `startsWith` prefix test with the
5259
+ // canonical key comparison — which is the right surface, and is exactly why it
5260
+ // now needs the convention. `phaseKeyFromDir` refuses to read a bracket
5261
+ // directory without an explicit signal (a bracket dir is string-
5262
+ // indistinguishable from the legacy letter-prefixed-decimal family, ADR-2121),
5263
+ // so un-threaded it returns the WHOLE dir name as the key —
5264
+ // `GSD.02-05-delta` -> `GSD.02-5-DELTA` — while `selectedPhaseKey` is the bare
5265
+ // `05` that `parsePhaseFromProse` yields. The two sides of one comparison were
5266
+ // derived under different conventions, which is #2562's defect class and the
5267
+ // thing this file's other three `phaseKeyFromDir` call sites already thread
5268
+ // against. Un-threaded, a bracket repo whose phase directory plainly exists
5269
+ // reports `no phase directory matches phase 05` and `valid: false` — a
5270
+ // wrong-and-confident answer on precisely the repos this convention supports.
5271
+ // Resolved here rather than reusing a caller's value because cmdStateValidate
5272
+ // has no other convention-dependent read. Non-bracket conventions (null,
5273
+ // 'milestone-prefixed', unresolvable) are byte-identical to the un-threaded
5274
+ // call by construction: `extractPhaseToken` branches only on `=== 'bracket'`.
5275
+ const validateConvention = resolvePhaseIdConvention(cwd);
4561
5276
  let phaseDirPath;
4562
5277
  try {
4563
5278
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
4564
- const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
5279
+ const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention) === selectedPhaseKey);
4565
5280
  if (!phaseDir) {
4566
5281
  warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: no phase directory matches phase ${currentPhase}`, 'Create a phase directory matching the current phase or correct current_phase'));
4567
5282
  emit({ valid: false, warnings, scope });
@@ -4609,7 +5324,10 @@ function cmdStateValidate(cwd, raw, opts = {}) {
4609
5324
  // ("verification passed" drift), not a false S007.
4610
5325
  const files = node_fs_1.default.readdirSync(phaseDirPath);
4611
5326
  const phaseDirBaseName = node_path_1.default.basename(phaseDirPath);
4612
- const verificationFiles = scopeToPhase(files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')), phaseDirBaseName);
5327
+ // #612: `validateConvention` threaded (already resolved above for
5328
+ // `phaseKeyFromDir`) so the S006/S007 scan scopes bracket dirs by
5329
+ // their real token instead of the include-everything fail-safe.
5330
+ const verificationFiles = scopeToPhase(files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')), phaseDirBaseName, validateConvention);
4613
5331
  for (const vf of verificationFiles) {
4614
5332
  try {
4615
5333
  const vContent = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDirPath, vf), 'utf-8');
@@ -4730,22 +5448,51 @@ function cmdStateSync(cwd, options, raw) {
4730
5448
  let syncRoadmapScope = null;
4731
5449
  let syncRoadmapRaw = null;
4732
5450
  let syncRetiredPhaseNums = new Set();
5451
+ const syncConvention = resolvePhaseIdConvention(cwd);
4733
5452
  try {
4734
5453
  const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
4735
5454
  if (roadmapRaw !== null) {
4736
5455
  syncRoadmapRaw = roadmapRaw;
4737
5456
  syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
4738
- syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
5457
+ syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention);
4739
5458
  }
4740
5459
  }
4741
5460
  catch { /* fall through: no roadmap scope → no retired exclusion */ }
5461
+ // #2761 Major 1 (round-2 adversarial review): this disk scan fed
5462
+ // totalDiskPlans/totalDiskSummaries/diskCompletedPhases/syncTotalPhases
5463
+ // below UNFILTERED — no milestone-window filter, unlike
5464
+ // buildStateFrontmatter's identical-purpose scan a few hundred lines above
5465
+ // (`:1698`). One command (`state sync`) therefore wrote TWO contradictory
5466
+ // numbers into the same STATE.md: frontmatter total_phases/completed_phases
5467
+ // milestone-scoped correctly (via the READ derivation), body Progress
5468
+ // percent computed from the whole disk. On the ADR-canonical version-less
5469
+ // bracket fixture (4 dirs, 3 complete; asserted milestone = 2 phases, both
5470
+ // complete): body wrote 75% where 100% is true (repro3).
5471
+ //
5472
+ // GATED on `syncConvention === 'bracket'` — an unconditional filter would
5473
+ // ALSO move LEGACY sync percents, since the milestone-scoping-vs-whole-disk
5474
+ // divergence this fixes is engine-wide, not bracket-specific; the gate
5475
+ // keeps legacy byte-identical, which is the binding constraint here. This
5476
+ // is a DEVIATION from an earlier "mirror :1698 unconditionally" phrasing —
5477
+ // deliberate, not an oversight: legacy repos are DOWNSTREAM of a Progress
5478
+ // percent that has read this way for a long time, and moving it as a side
5479
+ // effect of a bracket-only PR is out of this fix's scope.
5480
+ // Upstream #3185 made `listMilestonePhaseDirs` the sole phase-directory
5481
+ // enumeration owner; it delegates window membership to
5482
+ // getMilestonePhaseFilter. Cache that owner's bracket result as a set and
5483
+ // compose it with this scan, rather than restoring the retired direct
5484
+ // parser dependency. Legacy retains this scan's prior pass-all behavior.
5485
+ const syncMilestonePhaseDirs = syncConvention === 'bracket'
5486
+ ? new Set(listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: syncConvention }).value)
5487
+ : null;
4742
5488
  // Scan all phases
4743
5489
  let entries;
4744
5490
  try {
4745
5491
  entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
4746
5492
  .filter(e => e.isDirectory())
4747
5493
  .map(e => e.name)
4748
- .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name))))
5494
+ .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention))))
5495
+ .filter(name => syncMilestonePhaseDirs === null || syncMilestonePhaseDirs.has(name))
4749
5496
  .sort();
4750
5497
  }
4751
5498
  catch {
@@ -4771,7 +5518,10 @@ function cmdStateSync(cwd, options, raw) {
4771
5518
  // was a second, independent consumer of the same raw field the initial
4772
5519
  // migration missed — without it, `state sync` and `state json` disagreed
4773
5520
  // on completed_phases for the identical disk state.
4774
- if (isPhaseComplete(dirPath).value.complete)
5521
+ // #612: `syncConvention` threaded — the write-side half of
5522
+ // buildStateFrontmatter's thread above, so `state sync` and `state json`
5523
+ // keep agreeing on completed_phases under the bracket convention.
5524
+ if (isPhaseComplete(dirPath, { convention: syncConvention }).value.complete)
4775
5525
  diskCompletedPhases++;
4776
5526
  // Track the highest phase with incomplete plans (or any plans)
4777
5527
  const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
@@ -4793,28 +5543,18 @@ function cmdStateSync(cwd, options, raw) {
4793
5543
  }
4794
5544
  }
4795
5545
  // Determine total phases from ROADMAP (may be larger than realized disk dirs).
4796
- // Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B).
4797
- // DEAD catch removed (#2245 audit): every operation in this block is a regex
4798
- // exec/test over an already-read string plus pure Set/Math ops — none of
4799
- // which can throw — so the try/catch could never be triggered.
5546
+ // #612 round-4: shares countRoadmapPhaseHeadings with buildStateFrontmatter
5547
+ // (defined just above extractRetiredPhaseNumbers) so both report
5548
+ // consistent totals off the SAME implementation, not two independently
5549
+ // maintained copies (#3242 Bug B).
5550
+ // #612 round-5: bracket sync enables the same bare-token 999 exclusion as
5551
+ // the read path and getMilestonePhaseFilter, preventing frontmatter/body
5552
+ // disagreement. Non-bracket conventions still pass false, preserving the
5553
+ // pre-existing legacy sync behavior while #3185 remains the read-path owner.
4800
5554
  let syncTotalPhases = null;
4801
- let roadmapPhaseCount = 0;
4802
- if (syncRoadmapScope !== null) {
4803
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
4804
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
4805
- let m;
4806
- while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
4807
- // Only count tokens that contain at least one digit — excludes
4808
- // pure-word section headings (Overview, Details) while keeping
4809
- // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
4810
- if (!/\d/.test(m[1]))
4811
- continue;
4812
- // #1514: retired/folded phases are struck through; exclude from total.
4813
- if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1])))
4814
- continue;
4815
- roadmapPhaseCount++;
4816
- }
4817
- }
5555
+ const roadmapPhaseCount = syncRoadmapScope !== null
5556
+ ? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket')
5557
+ : 0;
4818
5558
  if (roadmapPhaseCount > 0) {
4819
5559
  syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
4820
5560
  }
@@ -4834,8 +5574,9 @@ function cmdStateSync(cwd, options, raw) {
4834
5574
  if (versionStr !== null && syncRoadmapRaw !== null) {
4835
5575
  // #3184: routed through the single owner (roadmap-parser.cjs) instead of
4836
5576
  // a hand-rolled, unbounded-substring re-derivation — see the identical
4837
- // fix in buildStateFrontmatter above.
4838
- milestoneBounded = isMilestoneBoundedInRoadmap(syncRoadmapRaw, versionStr);
5577
+ // fix in buildStateFrontmatter above. #612 composes its gated bracket
5578
+ // extension on top inside isMilestoneBounded.
5579
+ milestoneBounded = isMilestoneBounded(syncRoadmapRaw, versionStr, syncConvention);
4839
5580
  }
4840
5581
  let percent = null;
4841
5582
  if (!milestoneBounded) {
@@ -4853,6 +5594,20 @@ function cmdStateSync(cwd, options, raw) {
4853
5594
  // it here (discarding `.value`, which duplicates `entries`'s own
4854
5595
  // retired-phase-filtered listing) gets the real scope without changing
4855
5596
  // the disk-scan totals computed above.
5597
+ //
5598
+ // #2761 (round-11 M2 follow-up): deliberately NOT threading
5599
+ // `phaseIdConvention` here, unlike the other call sites this same PR
5600
+ // converts. Only `.scope` is consumed (the `.value` directory list is
5601
+ // thrown away), and inside `getMilestonePhaseFilter` `scope` is computed
5602
+ // from `extractCurrentMilestoneScoped`/`classifyMilestoneWindow` BEFORE
5603
+ // `headingConvention` is resolved — `phaseIdConvention` only reaches the
5604
+ // heading/dir MEMBERSHIP scan (`scanMilestonePhaseIds`, `isDirInMilestone`)
5605
+ // that produces `.value`, never the scope discriminator itself. So the
5606
+ // `undefined` default here (lazy resolve-from-config) and an explicitly
5607
+ // threaded `syncConvention` would compute the identical `scope` either
5608
+ // way — there is no silent-inherit exposure to close at this site, only
5609
+ // at sites (milestone.cts, cmdStateUpdateProgress above) that also
5610
+ // consume `.value`.
4856
5611
  const syncScope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
4857
5612
  if (syncScope !== SCOPE.COMPLETE) {
4858
5613
  changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);