@opengsd/gsd-core 1.12.0 → 1.13.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 (286) 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-executor.md +63 -35
  5. package/agents/gsd-plan-checker.md +76 -57
  6. package/agents/gsd-planner.md +14 -0
  7. package/agents/gsd-ui-checker.md +19 -3
  8. package/agents/gsd-ui-researcher.md +29 -0
  9. package/agents/gsd-verifier.md +23 -1
  10. package/bin/install.js +239 -67
  11. package/commands/gsd/execute-phase.md +1 -1
  12. package/commands/gsd/ns-workflow.md +2 -1
  13. package/commands/gsd/phase.md +1 -1
  14. package/commands/gsd/quick-batch.md +105 -0
  15. package/commands/gsd/surface.md +18 -8
  16. package/gsd-core/bin/gsd-tools.cjs +195 -50
  17. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +514 -114
  19. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  20. package/gsd-core/bin/lib/capability-validator.cjs +120 -4
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  22. package/gsd-core/bin/lib/check-command-router.cjs +85 -2
  23. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  24. package/gsd-core/bin/lib/clusters.cjs +1 -0
  25. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  26. package/gsd-core/bin/lib/commands.cjs +337 -13
  27. package/gsd-core/bin/lib/config-loader.cjs +3 -0
  28. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  29. package/gsd-core/bin/lib/decisions.cjs +213 -1
  30. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  31. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  32. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  33. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  34. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  35. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  36. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  37. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  38. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  39. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  40. package/gsd-core/bin/lib/init.cjs +132 -15
  41. package/gsd-core/bin/lib/install-engine.cjs +184 -12
  42. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  43. package/gsd-core/bin/lib/install-profiles.cjs +22 -14
  44. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  45. package/gsd-core/bin/lib/io.cjs +35 -0
  46. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  47. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  48. package/gsd-core/bin/lib/milestone.cjs +22 -2
  49. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  50. package/gsd-core/bin/lib/phase-id.cjs +251 -9
  51. package/gsd-core/bin/lib/phase.cjs +774 -35
  52. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  53. package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
  54. package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
  55. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  56. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  57. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  58. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  59. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  60. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  61. package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
  62. package/gsd-core/bin/lib/roadmap.cjs +187 -58
  63. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
  64. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  65. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
  66. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
  67. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  68. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  69. package/gsd-core/bin/lib/state-document.cjs +30 -5
  70. package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
  71. package/gsd-core/bin/lib/state-transition.cjs +333 -44
  72. package/gsd-core/bin/lib/state.cjs +684 -125
  73. package/gsd-core/bin/lib/surface.cjs +23 -8
  74. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  75. package/gsd-core/bin/lib/uat.cjs +1419 -515
  76. package/gsd-core/bin/lib/update-context.cjs +6 -2
  77. package/gsd-core/bin/lib/validate.cjs +230 -12
  78. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  79. package/gsd-core/bin/lib/verification.cjs +273 -12
  80. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  81. package/gsd-core/bin/lib/verify.cjs +346 -16
  82. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
  84. package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
  85. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  86. package/gsd-core/references/agent-contracts.md +3 -3
  87. package/gsd-core/references/edge-probe.md +17 -13
  88. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  89. package/gsd-core/references/execute-phase-response-language.md +6 -0
  90. package/gsd-core/references/executor-examples.md +42 -0
  91. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  92. package/gsd-core/references/mvp-concepts.md +2 -2
  93. package/gsd-core/references/plan-checker-examples.md +41 -0
  94. package/gsd-core/references/planner-antipatterns.md +25 -0
  95. package/gsd-core/references/planner-chunked.md +5 -1
  96. package/gsd-core/references/planner-coupling.md +42 -0
  97. package/gsd-core/references/planner-quick-batch.md +71 -0
  98. package/gsd-core/references/planner-reviews.md +47 -0
  99. package/gsd-core/references/planner-revision.md +75 -2
  100. package/gsd-core/references/planning-config.md +2 -1
  101. package/gsd-core/references/response-language-directive.md +9 -0
  102. package/gsd-core/references/revision-loop.md +118 -11
  103. package/gsd-core/references/tdd.md +14 -9
  104. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  105. package/gsd-core/templates/phase-prompt.md +4 -0
  106. package/gsd-core/templates/verification-report.md +5 -0
  107. package/gsd-core/workflows/add-backlog.md +2 -0
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +1 -1
  110. package/gsd-core/workflows/add-todo.md +1 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  112. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  113. package/gsd-core/workflows/audit-fix.md +2 -0
  114. package/gsd-core/workflows/audit-milestone.md +2 -0
  115. package/gsd-core/workflows/audit-uat.md +2 -0
  116. package/gsd-core/workflows/autonomous.md +2 -0
  117. package/gsd-core/workflows/check-todos.md +1 -1
  118. package/gsd-core/workflows/cleanup.md +1 -1
  119. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
  120. package/gsd-core/workflows/code-review-fix.md +2 -0
  121. package/gsd-core/workflows/code-review.md +73 -31
  122. package/gsd-core/workflows/complete-milestone.md +13 -4
  123. package/gsd-core/workflows/debug.md +1 -1
  124. package/gsd-core/workflows/diagnose-issues.md +5 -1
  125. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  126. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  127. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  128. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  129. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  130. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  131. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  132. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  133. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  134. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  135. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  136. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  137. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  138. package/gsd-core/workflows/discuss-phase.md +1 -1
  139. package/gsd-core/workflows/do.md +43 -13
  140. package/gsd-core/workflows/docs-update.md +1 -1
  141. package/gsd-core/workflows/edit-phase.md +2 -0
  142. package/gsd-core/workflows/eval-review.md +1 -1
  143. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
  144. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
  145. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  146. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  147. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  148. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  149. package/gsd-core/workflows/execute-phase.md +32 -14
  150. package/gsd-core/workflows/execute-plan.md +8 -8
  151. package/gsd-core/workflows/explore.md +2 -0
  152. package/gsd-core/workflows/extract-learnings.md +2 -0
  153. package/gsd-core/workflows/fast.md +6 -0
  154. package/gsd-core/workflows/forensics.md +2 -0
  155. package/gsd-core/workflows/graduation.md +1 -1
  156. package/gsd-core/workflows/health.md +1 -1
  157. package/gsd-core/workflows/help/modes/brief.md +2 -0
  158. package/gsd-core/workflows/help/modes/default.md +2 -0
  159. package/gsd-core/workflows/help/modes/full.md +12 -0
  160. package/gsd-core/workflows/help/modes/topic.md +2 -0
  161. package/gsd-core/workflows/help.md +2 -0
  162. package/gsd-core/workflows/import.md +3 -3
  163. package/gsd-core/workflows/inbox.md +1 -1
  164. package/gsd-core/workflows/ingest-docs.md +1 -1
  165. package/gsd-core/workflows/insert-phase.md +2 -0
  166. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  167. package/gsd-core/workflows/list-seeds.md +2 -0
  168. package/gsd-core/workflows/list-workspaces.md +2 -0
  169. package/gsd-core/workflows/manager.md +3 -3
  170. package/gsd-core/workflows/map-codebase.md +2 -0
  171. package/gsd-core/workflows/milestone-summary.md +2 -0
  172. package/gsd-core/workflows/mvp-phase.md +1 -1
  173. package/gsd-core/workflows/new-milestone.md +1 -1
  174. package/gsd-core/workflows/new-project.md +5 -3
  175. package/gsd-core/workflows/new-workspace.md +1 -1
  176. package/gsd-core/workflows/next.md +2 -0
  177. package/gsd-core/workflows/node-repair.md +2 -0
  178. package/gsd-core/workflows/note.md +2 -0
  179. package/gsd-core/workflows/onboard.md +1 -1
  180. package/gsd-core/workflows/pause-work.md +19 -4
  181. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  182. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  183. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  184. package/gsd-core/workflows/plan-phase.md +130 -12
  185. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  186. package/gsd-core/workflows/plant-seed.md +1 -1
  187. package/gsd-core/workflows/pr-branch.md +11 -3
  188. package/gsd-core/workflows/profile-user.md +1 -1
  189. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  190. package/gsd-core/workflows/progress.md +25 -3
  191. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  192. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  193. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  194. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  195. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  196. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  197. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  198. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  199. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  200. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  201. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  202. package/gsd-core/workflows/quick-batch.md +203 -0
  203. package/gsd-core/workflows/quick.md +13 -3
  204. package/gsd-core/workflows/reapply-patches.md +2 -0
  205. package/gsd-core/workflows/remove-phase.md +2 -0
  206. package/gsd-core/workflows/remove-workspace.md +1 -1
  207. package/gsd-core/workflows/resume-project.md +6 -2
  208. package/gsd-core/workflows/review.md +215 -10
  209. package/gsd-core/workflows/scan.md +2 -0
  210. package/gsd-core/workflows/section-manifest.json +12 -0
  211. package/gsd-core/workflows/secure-phase.md +1 -1
  212. package/gsd-core/workflows/session-report.md +2 -0
  213. package/gsd-core/workflows/settings-advanced.md +2 -0
  214. package/gsd-core/workflows/settings-integrations.md +9 -8
  215. package/gsd-core/workflows/settings.md +1 -1
  216. package/gsd-core/workflows/ship.md +10 -10
  217. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  218. package/gsd-core/workflows/sketch.md +1 -1
  219. package/gsd-core/workflows/smart-entry.md +1 -1
  220. package/gsd-core/workflows/spec-phase.md +24 -19
  221. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  222. package/gsd-core/workflows/spike.md +1 -1
  223. package/gsd-core/workflows/stats.md +2 -0
  224. package/gsd-core/workflows/sync-skills.md +12 -4
  225. package/gsd-core/workflows/thread.md +2 -0
  226. package/gsd-core/workflows/transition.md +2 -0
  227. package/gsd-core/workflows/ui-phase.md +26 -5
  228. package/gsd-core/workflows/ui-review.md +1 -1
  229. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  230. package/gsd-core/workflows/undo.md +1 -1
  231. package/gsd-core/workflows/update.md +41 -38
  232. package/gsd-core/workflows/validate-phase.md +1 -1
  233. package/gsd-core/workflows/verify-work.md +49 -3
  234. package/hooks/dist/gsd-check-update-worker.js +19 -2
  235. package/hooks/dist/gsd-context-monitor.js +283 -12
  236. package/hooks/dist/gsd-node-runner.sh +1 -0
  237. package/hooks/dist/gsd-prompt-guard.js +30 -5
  238. package/hooks/dist/gsd-read-guard.js +2 -0
  239. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  240. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  241. package/hooks/dist/gsd-statusline.js +7 -3
  242. package/hooks/dist/gsd-validate-commit.sh +444 -7
  243. package/hooks/dist/gsd-workflow-guard.js +2 -1
  244. package/hooks/dist/lib/git-cmd.js +210 -1
  245. package/hooks/dist/lib/injection-patterns.js +36 -6
  246. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  247. package/hooks/gsd-check-update-worker.js +19 -2
  248. package/hooks/gsd-context-monitor.js +283 -12
  249. package/hooks/gsd-node-runner.sh +1 -0
  250. package/hooks/gsd-prompt-guard.js +30 -5
  251. package/hooks/gsd-read-guard.js +2 -0
  252. package/hooks/gsd-read-injection-scanner.js +5 -5
  253. package/hooks/gsd-secret-read-guard.js +1079 -0
  254. package/hooks/gsd-statusline.js +7 -3
  255. package/hooks/gsd-validate-commit.sh +444 -7
  256. package/hooks/gsd-workflow-guard.js +2 -1
  257. package/hooks/hooks.json +6 -0
  258. package/hooks/lib/git-cmd.js +210 -1
  259. package/hooks/lib/injection-patterns.js +36 -6
  260. package/hooks/managed-hooks-registry.cjs +1 -0
  261. package/package.json +5 -5
  262. package/scripts/build-hooks.js +11 -4
  263. package/scripts/ci-test-scope.cjs +7 -0
  264. package/scripts/docs-guard-registry.cjs +10 -0
  265. package/scripts/gen-loop-host-contract.cjs +67 -15
  266. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  267. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  268. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  269. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  270. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
  271. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  272. package/scripts/lint-phase-id-drift.cjs +133 -8
  273. package/scripts/lint-portable-grep.cjs +176 -0
  274. package/scripts/lint-response-language-coverage.cjs +524 -0
  275. package/scripts/lint-test-file-count.allowlist.json +3 -1
  276. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  277. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  278. package/scripts/npm-audit-baseline.cjs +376 -0
  279. package/scripts/prompt-injection-scan.sh +8 -0
  280. package/scripts/require-issue-link-policy.cjs +16 -1
  281. package/skills/gsd-execute-phase/SKILL.md +1 -1
  282. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  283. package/skills/gsd-phase/SKILL.md +1 -1
  284. package/skills/gsd-quick-batch/SKILL.md +105 -0
  285. package/skills/gsd-surface/SKILL.md +18 -8
  286. package/vscode/package.json +1 -1
@@ -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,6 +55,10 @@ 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;
58
64
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -75,7 +81,7 @@ const project_root_cjs_1 = require("./project-root.cjs");
75
81
  // it introduces no cycle on this path.
76
82
  // eslint-disable-next-line @typescript-eslint/no-require-imports
77
83
  const milestoneLockMod = require("./milestone-lock.cjs");
78
- const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
84
+ const { transitionCore, applyStatePreservation, sliceCurrentPositionSection, stateReplaceProgressPercent, formatProgressMachineSegment } = stateTransitionMod;
79
85
  // #3699: the frontmatter-key <-> body-field routing behind `state update`'s
80
86
  // failure explanation, and the classification table it falls back to.
81
87
  const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
@@ -520,6 +526,42 @@ function cmdStateUpdate(cwd, field, value) {
520
526
  }
521
527
  }
522
528
  // ─── State Progression Engine ────────────────────────────────────────────────
529
+ /**
530
+ * The "I could not read the plan position" message, DERIVED from
531
+ * `STATE_FIELD_SCHEMA.current_plan.acceptedShapes` rather than transcribed
532
+ * beside it.
533
+ *
534
+ * The accepted-shape set had two owners: the parser branches in
535
+ * `advancePlanCore` and an English list hand-written here. Nothing coupled
536
+ * them, so adding a branch left this message stale and removing one left it
537
+ * advertising a shape that errors — and no test could see either. ADR-3473
538
+ * §8.3 is "one implementation per rule"; the schema row is that one owner, and
539
+ * rows 23/24/25 already hold the parser to it.
540
+ *
541
+ * `Plan: N of M` is spelled out separately because there is no schema row for
542
+ * the body-only `Plan` field: `buildStateFrontmatter` never reads it into
543
+ * frontmatter, so it has no `current_*` key to hang a row on. That asymmetry is
544
+ * the schema's, not this function's.
545
+ */
546
+ function advancePlanShapeError() {
547
+ const shapes = stateMdSchemaMod.STATE_FIELD_SCHEMA['current_plan']?.acceptedShapes ?? [];
548
+ const spellings = shapes.map((shape) => (shape === 'N'
549
+ ? '`Current Plan: N` with `Total Plans in Phase: M`'
550
+ : `\`Current Plan: ${shape}\``));
551
+ // The body-only `Plan` field has no schema row to derive from:
552
+ // `buildStateFrontmatter` never reads it into frontmatter, so there is no
553
+ // `current_*` key to hang a row on. Its ONE accepted spelling is named here.
554
+ //
555
+ // `Plan: N` with a `Total Plans in Phase: M` sibling is deliberately NOT
556
+ // listed (#3791 review round 6, M2): the parser does not accept it. A
557
+ // revision of this PR added both the branch and this spelling together, on
558
+ // the reasoning that the message must advertise exactly what the parser
559
+ // accepts. That reasoning still holds — which is why removing the branch
560
+ // removes the spelling in the same commit. The invariant is the lockstep,
561
+ // not the length of the list.
562
+ spellings.push('`Plan: N of M`');
563
+ return `Cannot read the plan position from STATE.md. Expected one of: ${spellings.join(', ')}.`;
564
+ }
523
565
  /**
524
566
  * Replace a STATE.md field with fallback field name support.
525
567
  * Tries `primary` first, then `fallback` (if provided), returns content unchanged
@@ -541,6 +583,86 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
541
583
  `This may indicate STATE.md was externally modified or uses an unexpected format.\n`);
542
584
  return content;
543
585
  }
586
+ /**
587
+ * #4067: disk-derived plan-completion answer for advance-plan's phase-complete
588
+ * guard.
589
+ *
590
+ * `advancePlanCore` decides "phase complete" purely from STATE.md's scalar plan
591
+ * counter (`currentPlan >= totalPlans`). That counter cannot represent
592
+ * wave-parallel execution — a stale counter carried over from the prior phase
593
+ * (the reported trigger: `Plan: 7 of 7` surviving into a 10-plan phase) or a
594
+ * counter raced by N concurrent executors both let the phase-complete branch
595
+ * fire while sibling plans are mid-flight. This helper answers the completion
596
+ * question from disk instead, exactly the way `state update-progress`
597
+ * recalculates it: every plan in the Current Position phase's directory has a
598
+ * SUMMARY.md.
599
+ *
600
+ * Single-derivation discipline: plan/summary counting is owned by
601
+ * `scanPhasePlans` (src/plan-scan.cts, ADR-3180 §7.5) — this helper consumes
602
+ * it, never re-derives. It deliberately does NOT consult `isPhaseComplete`
603
+ * (§7.4): that owner answers the *verification* question (passing
604
+ * `*-VERIFICATION.md`), a different question from "are all plans executed?".
605
+ * Blocked summaries (#3345) are filtered from the pairing set with the same
606
+ * shared predicate `scanPhasePlans` uses, so the named outstanding list can
607
+ * never disagree with the count-based decision.
608
+ *
609
+ * FAIL-OPEN contract: returns `null` when the disk answer is UNAVAILABLE — no
610
+ * readable phases dir, no directory matching the position phase, or a scan
611
+ * whose scope is not COMPLETE (the scan may be blind to plans it knows exist).
612
+ * `null` means "the caller must fall back to the counter-derived decision",
613
+ * NOT "plans are outstanding"; worlds the seam cannot see (STATE.md with no
614
+ * Current Position `Phase:` line, milestone-archived layouts) keep today's
615
+ * behavior rather than being newly refused.
616
+ *
617
+ * Returns `{ dir, outstanding, planCount, summaryCount }` where `outstanding`
618
+ * is empty when every plan on disk is summarized (vacuously so for a zero-plan
619
+ * phase — #3168's zero-plan-phase posture). `planCount`/`summaryCount` are the
620
+ * countable disk facts behind `outstanding` (live plan files; summaries after
621
+ * the #3345 blocked filter) — #4093's recovery decline reports them to a
622
+ * caller whose STATE.md has lost its labeled plan position, so the suggested
623
+ * repair values are computed from the SAME set `outstanding` was, and can
624
+ * never disagree with a count-based decision either.
625
+ */
626
+ function scanOutstanding(phasesDir, dir) {
627
+ const phaseDirPath = node_path_1.default.join(phasesDir, dir);
628
+ const scan = scanPhasePlans(phaseDirPath);
629
+ if (scan.scope !== SCOPE.COMPLETE)
630
+ return null;
631
+ // Blocked summaries (#3345) are filtered with the same shared predicate
632
+ // scanPhasePlans uses for its own count, so the named outstanding list can
633
+ // never disagree with a count-based decision.
634
+ const countableSummaries = scan.summaryFiles.filter((f) => !planDependencyGraphMod.isSummaryFileBlocked(node_path_1.default.join(phaseDirPath, f)));
635
+ const outstanding = coreUtilsMod.findUnsummarizedPlans(scan.planFiles, countableSummaries);
636
+ return { dir, outstanding, planCount: scan.planFiles.length, summaryCount: countableSummaries.length };
637
+ }
638
+ function unsummarizedPlansForPositionPhase(cwd, positionPhase) {
639
+ const phasesDir = planningPaths(cwd).phases;
640
+ // #3185 (ADR-3180 Decision 1): "which phase directories exist" is owned by
641
+ // listMilestonePhaseDirs — no hand-rolled readdirSync here. The owner
642
+ // handles an absent phasesDir as a real empty and refuses sentinels.
643
+ //
644
+ // Two passes, narrowest first: the CURRENT-MILESTONE window (so an archived
645
+ // milestone's stale `01-*` directory cannot shadow the live one), then —
646
+ // only when the window cannot answer (no bounded ROADMAP, or the position
647
+ // phase is simply not in it) — an unscoped read, which the owner documents
648
+ // as a real answer. This is a lookup of ONE phase token STATE.md names, not
649
+ // a milestone enumeration, so the unscoped retry is in-contract.
650
+ const convention = resolvePhaseIdConvention(cwd);
651
+ const windowed = listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: convention });
652
+ const candidateDirs = windowed.scope === SCOPE.COMPLETE ? windowed.value : [];
653
+ // Canonical phase-token → directory matching (phase-id owner, #2562): both
654
+ // sides of the comparison derived by the same function, never a local regex.
655
+ const { matches } = matchPhaseDirs(candidateDirs, positionPhase, convention);
656
+ if (matches.length > 0)
657
+ return scanOutstanding(phasesDir, matches[0]);
658
+ const unscoped = listMilestonePhaseDirs(phasesDir);
659
+ if (unscoped.scope !== SCOPE.COMPLETE)
660
+ return null;
661
+ const retry = matchPhaseDirs(unscoped.value, positionPhase, convention);
662
+ if (retry.matches.length === 0)
663
+ return null;
664
+ return scanOutstanding(phasesDir, retry.matches[0]);
665
+ }
544
666
  function cmdStateAdvancePlan(cwd, raw) {
545
667
  const statePath = planningPaths(cwd).state;
546
668
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -567,6 +689,16 @@ function cmdStateAdvancePlan(cwd, raw) {
567
689
  // STATE.md lock, so the position read and the claim read cannot interleave
568
690
  // with another session's Current Position write.
569
691
  let milestoneConflict = null;
692
+ // #4067: set when the disk-derived guard declines the phase-complete branch —
693
+ // named here so the post-lock output path can report it without re-deriving.
694
+ // Holder (not a bare let) so TypeScript's closure-unaware narrowing cannot
695
+ // collapse the post-lock read to `never` — the callback assigns it.
696
+ const outstandingRef = { value: null };
697
+ // #4093: the position phase token the callback resolved (Current Position
698
+ // `Phase:` line first, frontmatter `current_phase` as fallback), carried out
699
+ // so the generic parse-failure decline can derive recovery facts from disk
700
+ // without re-reading STATE.md outside the lock. Same holder idiom as above.
701
+ const positionPhaseRef = { value: null };
570
702
  const wrote = readModifyWriteStateMd(statePath, (content) => {
571
703
  // advance-plan has no phase argument of its own — the phase it advances is
572
704
  // whatever ## Current Position names. Compare that against the milestone
@@ -576,6 +708,17 @@ function cmdStateAdvancePlan(cwd, raw) {
576
708
  const body = stripFrontmatter(content);
577
709
  const positionScope = matchCurrentPositionSection(body) ?? body;
578
710
  const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
711
+ // #4093: a Current Position section with ZERO labeled fields has no
712
+ // `Phase:` line either; frontmatter `current_phase` is the documented
713
+ // survivor of body drift (the reporter's document still carried it, and
714
+ // `buildStateFrontmatter` re-derives it from the body only when the body
715
+ // HAS the line). It feeds the recovery DECLINE only — never a write.
716
+ let fmPhase = null;
717
+ if (positionPhase === null) {
718
+ const fmToken = extractFrontmatter(content, statePath)['current_phase'];
719
+ fmPhase = typeof fmToken === 'string' && fmToken.trim() !== '' ? fmToken.trim() : null;
720
+ }
721
+ positionPhaseRef.value = positionPhase ?? fmPhase;
579
722
  if (positionPhase !== null) {
580
723
  milestoneConflict = milestoneLockMod.checkMilestonePosition(cwd, positionPhase);
581
724
  if (milestoneConflict) {
@@ -583,10 +726,53 @@ function cmdStateAdvancePlan(cwd, raw) {
583
726
  }
584
727
  }
585
728
  const result = transitionCore(content, intent, deps);
729
+ // #4067: the transform's phase-complete branch is decided by STATE.md's
730
+ // scalar plan counter, which can neither carry a stale value across phases
731
+ // nor represent wave-parallel execution. Before letting that branch write
732
+ // "Phase complete — ready for verification", re-decide from disk (the same
733
+ // source state.update-progress recalculates from): every plan in the
734
+ // position phase's directory must have a SUMMARY.md. A non-empty
735
+ // outstanding list declines the ENTIRE write — STATE.md is returned
736
+ // byte-identical, so the decline is idempotent and safe for any number of
737
+ // concurrent callers (the disk answer is re-read under the STATE.md lock
738
+ // each call; the counter stays display-only). `null` (disk answer
739
+ // unavailable) fails open to the counter-derived decision, so every
740
+ // world this seam cannot see keeps today's behavior.
741
+ if (result.data?.['advanced'] === false
742
+ && result.data?.['reason'] === 'last_plan'
743
+ && positionPhase !== null) {
744
+ const diskAnswer = unsummarizedPlansForPositionPhase(cwd, positionPhase);
745
+ if (diskAnswer !== null && diskAnswer.outstanding.length > 0) {
746
+ outstandingRef.value = diskAnswer;
747
+ resultData = result.data;
748
+ precomputedUpdated = [];
749
+ return content;
750
+ }
751
+ }
586
752
  resultData = result.data;
587
753
  precomputedUpdated = result.updated;
588
754
  return result.content;
589
755
  }, cwd, { divergedFields, preWriteState });
756
+ // #4067 decline path: plans remain unexecuted on disk. Shaped like the
757
+ // existing `last_plan` decline (advanced:false + machine-readable reason,
758
+ // exit 0) rather than a hard error — the caller did nothing wrong and
759
+ // STATE.md needs no repair; the remaining plans' executors will re-run this
760
+ // command, and the final one finds a fully-summarized phase and completes it.
761
+ const plansOutstanding = outstandingRef.value;
762
+ if (plansOutstanding !== null) {
763
+ 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.`, {
764
+ advanced: false,
765
+ phase_dir: plansOutstanding.dir,
766
+ outstanding_plans: plansOutstanding.outstanding,
767
+ milestone_conflict: milestoneConflict,
768
+ });
769
+ return;
770
+ }
771
+ // `!resultData` is a type guard, not a second failure mode: the callback
772
+ // above assigns it unconditionally and only runs once STATE.md is known to
773
+ // exist (the missing-file case returns "STATE.md not found" earlier), and
774
+ // every `advancePlanCore` return path sets `data`. So the message below is
775
+ // the one a caller can actually receive.
590
776
  if (!resultData || resultData['error']) {
591
777
  // #3807: a multi-`Phase:` Current Position section carries its own cause
592
778
  // and its own remedy (name the candidates; the caller resolves them).
@@ -598,7 +784,70 @@ function cmdStateAdvancePlan(cwd, raw) {
598
784
  }, raw, undefined);
599
785
  return;
600
786
  }
601
- output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
787
+ // #3791 review round 6 (B1): the document carries both plan-position
788
+ // spellings with DIFFERENT numbers. Same posture as the case above — name
789
+ // the candidates and let the caller resolve them. Advancing either one
790
+ // would write a number into the other that nothing derived for it.
791
+ if (resultData && resultData['reason'] === 'ambiguous_plan_position') {
792
+ output({
793
+ error: 'STATE.md carries two plan positions with different numbers — refusing to advance either. Resolve them to a single current plan and re-run.',
794
+ reason: resultData['reason'],
795
+ plan_candidates: resultData['plan_candidates'],
796
+ }, raw, undefined);
797
+ return;
798
+ }
799
+ // #4093: the generic terminus — no accepted labeled plan-position shape
800
+ // parsed anywhere in the document (the reporter's case: ## Current
801
+ // Position drifted to pure narrative prose with zero labeled fields).
802
+ // Every OTHER refusal above carries a machine-readable reason and the
803
+ // evidence to act on; this one stranded the caller at a bare sentence
804
+ // with no recovery path. Give it the same posture: a `reason` the caller
805
+ // can branch on, plus — when the position phase can be resolved and its
806
+ // directory scanned — the disk-derived facts and the exact labeled lines
807
+ // to re-insert. Nothing is WRITTEN: STATE.md is returned byte-identical
808
+ // (the callback already returned the original content for this path),
809
+ // so the decline is idempotent and no repair is guessed into the file —
810
+ // the caller (human or agent) applies the suggested lines and re-runs.
811
+ // Disk is the recovery source per #4067's posture; the values below are
812
+ // computed from the SAME `scanOutstanding` counts the plans_outstanding
813
+ // guard uses, so the two declines can never disagree about a phase.
814
+ const positionToken = positionPhaseRef.value;
815
+ const diskFacts = positionToken !== null
816
+ ? unsummarizedPlansForPositionPhase(cwd, positionToken)
817
+ : null;
818
+ if (diskFacts === null) {
819
+ // No resolvable phase (no Phase: line, no current_phase frontmatter, or
820
+ // no matching phase directory / incomplete scan): keep today's shape
821
+ // error, plus the reason so callers can tell this refusal from the
822
+ // ambiguous_* ones without string-matching the sentence.
823
+ output({ error: advancePlanShapeError(), reason: 'plan_position_unreadable' }, raw, undefined);
824
+ return;
825
+ }
826
+ const planCount = diskFacts.planCount;
827
+ const summarized = diskFacts.summaryCount;
828
+ // A summarized count below the plan count means the next plan to execute
829
+ // is summarized+1; an equal count means the phase is done on disk and the
830
+ // position line should say so (current = total; the next advance-plan run
831
+ // takes the #4067-guarded phase-complete branch from it). Zero plan files
832
+ // means disk has no opinion — suggest nothing rather than `1 of 0`.
833
+ const payload = {
834
+ error: advancePlanShapeError(),
835
+ reason: 'plan_position_unreadable',
836
+ phase_dir: diskFacts.dir,
837
+ disk: { plan_count: planCount, summarized_count: summarized },
838
+ };
839
+ if (planCount > 0) {
840
+ const current = summarized < planCount ? summarized + 1 : planCount;
841
+ payload['suggested'] = {
842
+ current_plan: current,
843
+ total_plans: planCount,
844
+ lines: [`Current Plan: ${current}`, `Total Plans in Phase: ${planCount}`],
845
+ };
846
+ payload['error'] =
847
+ `${advancePlanShapeError()} Disk for phase ${diskFacts.dir}: ${summarized} of ${planCount} plan(s) summarized. ` +
848
+ `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.`;
849
+ }
850
+ output(payload, raw, undefined);
602
851
  return;
603
852
  }
604
853
  // ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
@@ -803,7 +1052,7 @@ function computeUpdateProgressPreview(statePath, cwd) {
803
1052
  const existingFm = extractFrontmatter(preContent, statePath);
804
1053
  const preBody = stripFrontmatter(preContent);
805
1054
  const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
806
- const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
1055
+ const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
807
1056
  const progress = builtFm['progress'];
808
1057
  const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
809
1058
  const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
@@ -844,7 +1093,39 @@ function cmdStateUpdateProgress(cwd, raw) {
844
1093
  // excluded sentinels, unlike the owner). The owner already handles an
845
1094
  // absent phasesDir as a real empty, so the fs.existsSync guard folds
846
1095
  // into it.
847
- const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
1096
+ //
1097
+ // #2761 (round-11 BLOCKER, single-derivation hygiene): `phaseIdConvention`
1098
+ // threaded explicitly (resolved ambiently off `cwd` — this call site has
1099
+ // no `ws` of its own, same contract `resolvePhaseIdConvention` uses
1100
+ // elsewhere in this file, e.g. the `phaseConvention` ONCE-and-THREAD
1101
+ // pattern at ~:2267/:2300) rather than left `undefined`.
1102
+ //
1103
+ // This does NOT change `phaseScope` — `scope` (roadmap-parser.cts
1104
+ // `getMilestonePhaseFilter`) is assigned at :1979/:2030, both BEFORE
1105
+ // `headingConvention` resolves at ~:2048, so the #3217 withhold gate a
1106
+ // few lines below is convention-independent either way (verified
1107
+ // empirically: forcing `phaseIdConvention: null` here left every
1108
+ // `state update-progress` assertion in
1109
+ // tests/adr-612-bracket-phase-counting.test.cjs's round-11 BLOCKER block
1110
+ // unchanged). What DOES depend on convention is `phaseDirs`/`totalPlans`
1111
+ // — the enumerated `.value` these two lines feed into the #3233
1112
+ // zero-plans no-op check just below. The actual `percent` this command
1113
+ // reports/writes comes from a separate, already-correctly-threaded scan
1114
+ // (`computeUpdateProgressPreview` -> `buildStateFrontmatter`, which
1115
+ // resolves its own `phaseConvention` at :2267). Threading here removes a
1116
+ // second, silent, lazily-resolved answer for the SAME question that scan
1117
+ // already answers explicitly — the single-derivation discipline this
1118
+ // file's own :2300 comment states as a rule — rather than fixing an
1119
+ // observed defect. #2761 round-12: the #3233 gate IS the one place this
1120
+ // is observable, so it — not the reported percent — is what
1121
+ // tests/adr-612-bracket-phase-counting.test.cjs's round-12 addition to
1122
+ // the round-11 BLOCKER block pins: a bracket milestone with no plans on
1123
+ // disk versus a decoy directory outside the milestone window that must
1124
+ // not be swept in by a pass-all degrade.
1125
+ const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, {
1126
+ cwd,
1127
+ phaseIdConvention: cwd ? resolvePhaseIdConvention(cwd) : null,
1128
+ });
848
1129
  phaseScope = scope;
849
1130
  for (const dir of phaseDirs) {
850
1131
  const { planCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
@@ -861,10 +1142,10 @@ function cmdStateUpdateProgress(cwd, raw) {
861
1142
  // never read, and STATE.md's Progress field goes stale with no
862
1143
  // user-visible signal beyond it. Mirrors the established
863
1144
  // `[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');
1145
+ // (stateReplaceFieldWithFallback above) for a comparable silent no-op —
1146
+ // now routed through the shared `declineNoOp` helper (#3957) so the
1147
+ // pairing is structural rather than hand-written per arm.
1148
+ 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
1149
  return;
869
1150
  }
870
1151
  // #3233: zero plans in the current-milestone phases means there is nothing to
@@ -876,9 +1157,7 @@ function cmdStateUpdateProgress(cwd, raw) {
876
1157
  // ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist,
877
1158
  // none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0.
878
1159
  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');
1160
+ 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
1161
  return;
883
1162
  }
884
1163
  // #3583: percent AND the completed/total counts reported alongside it both
@@ -889,48 +1168,30 @@ function cmdStateUpdateProgress(cwd, raw) {
889
1168
  // disagrees with its own completed/total.
890
1169
  const preview = computeUpdateProgressPreview(statePath, cwd);
891
1170
  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');
1171
+ declineNoOp(raw, 'updated', preview.reason, `state update-progress skipped — ${preview.reason}`);
894
1172
  return;
895
1173
  }
896
1174
  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}%`;
1175
+ const progressStr = formatProgressMachineSegment(percent);
901
1176
  let updated = false;
902
1177
  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)
1178
+ const result = stateReplaceProgressPercent(content, percent);
1179
+ if (result === null)
925
1180
  return content;
926
1181
  updated = true;
927
- return fmPrefix + body.replace(pattern, (_match, prefix, value) => `${prefix}${replaceValue(value)}`);
1182
+ return result;
928
1183
  }, cwd);
929
1184
  if (updated) {
930
1185
  output({ updated: true, percent, completed: fmCompletedPlans, total: fmTotalPlans, bar: progressStr }, raw, progressStr);
931
1186
  }
932
1187
  else {
933
- output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
1188
+ // #3957: the frontmatter progress data was already confirmed present a
1189
+ // few lines above (computeUpdateProgressPreview didn't withhold) — what's
1190
+ // actually missing here is the BODY `Progress:`/`**Progress:**` line
1191
+ // itself. The prior 'Progress field not found in STATE.md' reason named
1192
+ // the wrong layer and silently discarded percent/completed/total, which
1193
+ // the sibling success arm above reports from the same preview.
1194
+ 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
1195
  }
935
1196
  }
936
1197
  function cmdStateAddDecision(cwd, options, raw) {
@@ -1236,7 +1497,15 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1236
1497
  output({ error: 'text required' }, raw, undefined);
1237
1498
  return;
1238
1499
  }
1239
- let resolved = false;
1500
+ // #3957: track section-found and bullet-matched SEPARATELY. Previously
1501
+ // `resolved` was set unconditionally as soon as the heading was located —
1502
+ // before checking whether any bullet line actually matched `text` — so a
1503
+ // call naming a non-existent blocker reported `resolved: true` (a false
1504
+ // success). Only a real bullet match makes `resolved` true and the
1505
+ // rewrite happen; otherwise the transform returns `content` unchanged
1506
+ // (this repo's established no-op-return idiom).
1507
+ let sectionFound = false;
1508
+ let matched = false;
1240
1509
  readModifyWriteStateMd(statePath, (content) => {
1241
1510
  // ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3.
1242
1511
  // Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
@@ -1244,6 +1513,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1244
1513
  const i = hs.findIndex(h => (h.level === 2 || h.level === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(h.text));
1245
1514
  if (i === -1)
1246
1515
  return content;
1516
+ sectionFound = true;
1247
1517
  const h = hs[i];
1248
1518
  const ls = content.split('\n');
1249
1519
  const hl = ls[h.line - 1];
@@ -1260,21 +1530,33 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1260
1530
  const filtered = lines.filter(line => {
1261
1531
  if (!line.startsWith('- '))
1262
1532
  return true;
1263
- return !line.toLowerCase().includes(text.toLowerCase());
1533
+ // Case-insensitive substring match — unchanged from before the fix;
1534
+ // only whether a match occurred is now tracked accurately.
1535
+ const isMatch = line.toLowerCase().includes(text.toLowerCase());
1536
+ if (isMatch)
1537
+ matched = true;
1538
+ return !isMatch;
1264
1539
  });
1540
+ if (!matched)
1541
+ return content;
1265
1542
  let newBody = filtered.join('\n');
1266
1543
  // If section is now empty, add placeholder
1267
1544
  if (!newBody.trim() || !newBody.includes('- ')) {
1268
1545
  newBody = 'None\n';
1269
1546
  }
1270
- resolved = true;
1271
1547
  return content.slice(0, bs) + newBody + content.slice(se);
1272
1548
  }, cwd);
1273
- if (resolved) {
1549
+ if (matched) {
1274
1550
  output({ resolved: true, blocker: text }, raw, 'true');
1275
1551
  }
1552
+ else if (!sectionFound) {
1553
+ declineNoOp(raw, 'resolved', 'no Blockers/Concerns section found in STATE.md', 'state resolve-blocker skipped — no Blockers/Concerns section found in STATE.md.');
1554
+ }
1276
1555
  else {
1277
- output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
1556
+ // `formatDiagnosticToken` only guards the STDERR disclosure — the JSON
1557
+ // `reason` field can embed `text` raw since output()'s own
1558
+ // JSON.stringify serialization already escapes it correctly.
1559
+ 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
1560
  }
1279
1561
  }
1280
1562
  function cmdStateRecordSession(cwd, options, raw) {
@@ -1516,8 +1798,22 @@ function cmdStateRecordSession(cwd, options, raw) {
1516
1798
  result['created'] = true;
1517
1799
  output(result, raw, 'true');
1518
1800
  }
1801
+ else if (updated.length === 0) {
1802
+ // Nothing was ever attempted — no --stopped-at/--resume-file supplied
1803
+ // and no existing Last session/Last Date/Stopped At/Resume File labels
1804
+ // to touch.
1805
+ 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.');
1806
+ }
1519
1807
  else {
1520
- output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
1808
+ // #3957: `updated` (pre-reconciliation) was non-empty — a rewrite
1809
+ // matched a session field and reported it as changed — but
1810
+ // `reconcileReportedFields` found the persisted bytes byte-identical to
1811
+ // what was already on disk (the matched field's supplied value equals
1812
+ // its already-recorded value), so nothing actually changed. Distinct
1813
+ // from the "nothing was ever attempted" case above: the prior single
1814
+ // reason collapsed both into 'No session fields found in STATE.md',
1815
+ // which was simply wrong for this case.
1816
+ 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
1817
  }
1522
1818
  }
1523
1819
  /**
@@ -1819,7 +2115,56 @@ function cmdStateSnapshot(cwd, raw) {
1819
2115
  // ROADMAP phase token against an on-disk phase directory — moved to the
1820
2116
  // phase-id owner module in #2562 so every consumer derives BOTH sides of a
1821
2117
  // phase comparison from the same function (see phase-id.cts). Imported at the
1822
- // top of this file; call sites below are unchanged.
2118
+ // top of this file; call sites below are unchanged. #612 threads the optional
2119
+ // `convention` through that owner's `phaseKeyFromDir` (see phase-id.cts) rather
2120
+ // than re-deriving a bracket-aware key here.
2121
+ /**
2122
+ * #612: is the asserted milestone bounded to a heading in this ROADMAP?
2123
+ *
2124
+ * The legacy rule matches STATE's milestone STRING (`v2.0`) inside a heading.
2125
+ * The ADR-canonical bracket milestone heading is `## [GSD.02] Foundation` — a
2126
+ * name, no version — so that rule finds nothing, the milestone reads as
2127
+ * unbounded, and total_phases falls back to the on-disk directory count. Under
2128
+ * the bracket convention the milestone integer in the bracket is matched against
2129
+ * the `vN` of the milestone string instead (READING-B parity). Gated, and only
2130
+ * consulted after the legacy rule has already failed, so no non-bracket repo
2131
+ * changes answer.
2132
+ */
2133
+ function isMilestoneBounded(roadmapRaw, milestone, convention) {
2134
+ // #3184: preserve roadmap-parser's canonical legacy answer and compose the
2135
+ // gated bracket extension on top of it. Re-deriving the version-heading
2136
+ // grammar here would restore the boundary drift that #3184 removed.
2137
+ if (isMilestoneBoundedInRoadmap(roadmapRaw, String(milestone).trim()))
2138
+ return true;
2139
+ if (convention !== 'bracket')
2140
+ return false;
2141
+ const vMatch = String(milestone).trim().match(/^v(\d+)/i);
2142
+ const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
2143
+ if (!Number.isSafeInteger(milestoneInt))
2144
+ return false;
2145
+ // Canonical spelling only — see the note in roadmap-parser's scoping branch.
2146
+ // Accepting `0*N` here bounded a milestone whose phases were invisible, which
2147
+ // un-suppressed a progress percent computed off an unscoped disk count.
2148
+ // #2761 M3: that padding rule and the grammar both come from the owner's
2149
+ // `bracketMilestoneIntroSrcFor`. This line and roadmap-parser's selector were
2150
+ // character-identical re-typings of one pattern, so "canonical spelling only"
2151
+ // was a convention two files had to keep agreeing on by hand — and the drift
2152
+ // guard could not see either copy.
2153
+ // #612 round-4 (Major 1, F12): fence-aware via tokenizeHeadings, not a raw
2154
+ // `.test(roadmapRaw)` — a FENCED `[GSD.02]` example heading (the ONLY one
2155
+ // in the document, with no real section for the asserted milestone at
2156
+ // all) previously bounded a milestone that isn't actually in the roadmap,
2157
+ // un-suppressing a percent computed off the wrong (prior-milestone-plus-
2158
+ // whole-disk) phase set. tokenizeHeadings never produces a token for a
2159
+ // fenced line, so a fenced-only example can no longer satisfy this test.
2160
+ const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
2161
+ // #612 round-5 (Minor 1): skip ≤3-space-indented tokens — `h.offset` is
2162
+ // tokenizeHeadings' LINE-START offset, not the `#` character, so an
2163
+ // indented heading here would bound a milestone the line-start-anchored
2164
+ // raw predecessor never matched. Restores raw parity; see roadmap-parser's
2165
+ // matching selector-reconstruction comment for the full rationale.
2166
+ return (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapRaw).some((h) => h.level <= 3 && roadmapRaw[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text));
2167
+ }
1823
2168
  /**
1824
2169
  * Extract the set of retired/folded phase keys from a ROADMAP milestone scope
1825
2170
  * (#1514). A retired phase is struck through with GFM strikethrough,
@@ -1841,16 +2186,31 @@ function cmdStateSnapshot(cwd, raw) {
1841
2186
  * decimal, and project-code IDs are detected alike. Returns canonical keys
1842
2187
  * (see phaseKeyFromToken).
1843
2188
  */
1844
- function extractRetiredPhaseNumbers(scope) {
2189
+ function extractRetiredPhaseNumbers(scope, convention) {
1845
2190
  const retired = new Set();
1846
2191
  const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
1847
- for (const line of scope.split(/\r?\n/)) {
2192
+ // #612: the retirement filter has to widen with the counter it protects. The
2193
+ // canonical #1514 gesture strikes the checklist BULLET and leaves the detail
2194
+ // heading intact, so a bracket-form retirement went undetected and the phase
2195
+ // stayed in the denominator forever — a shipped bracket milestone could never
2196
+ // reach 100%. Same selection rule as the counter: a non-bracket repo compiles
2197
+ // the bare `Phase\s+` this line spelled before.
2198
+ const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
2199
+ const phaseRefRe = new RegExp(`^[\\s*_]*${introSrc}([\\w][\\w.-]*)`, 'i');
2200
+ // #612 round-5 (Major 1): fence-aware on the BRACKET path only — a fenced
2201
+ // AUTHORING EXAMPLE of the #1514 retirement gesture, spelled in bracket
2202
+ // form, must not retire a real phase. Reuses markdown-sectionizer's
2203
+ // single-owner stripFencedCode rather than a second fence parser. Legacy
2204
+ // stays the raw `scope`, byte-identical — its own fenced-example hazard is
2205
+ // pre-existing and out of scope.
2206
+ const scanScope = convention === 'bracket' ? (0, markdown_sectionizer_cjs_1.stripFencedCode)(scope).text : scope;
2207
+ for (const line of scanScope.split(/\r?\n/)) {
1848
2208
  if (!isChecklistOrHeading.test(line))
1849
2209
  continue;
1850
2210
  const strikeSpan = /~~([^~]*?)~~/g;
1851
2211
  let s;
1852
2212
  while ((s = strikeSpan.exec(line)) !== null) {
1853
- const phaseRef = /^[\s*_]*Phase\s+([\w][\w.-]*)/i.exec(s[1]);
2213
+ const phaseRef = phaseRefRe.exec(s[1]);
1854
2214
  // Require a digit so struck prose like ~~Phase Overview~~ is ignored.
1855
2215
  if (phaseRef && /\d/.test(phaseRef[1]))
1856
2216
  retired.add(phaseKeyFromToken(phaseRef[1]));
@@ -1858,12 +2218,110 @@ function extractRetiredPhaseNumbers(scope) {
1858
2218
  }
1859
2219
  return retired;
1860
2220
  }
2221
+ /**
2222
+ * #612 (round-4 fix): the single shared implementation for the phase-heading
2223
+ * counter `buildStateFrontmatter` (read path) and `cmdStateSync` (write
2224
+ * path) each built inline as an independent copy. The comment at each call
2225
+ * site already claimed "the two counters must see the same phases or
2226
+ * `state json` and `state sync` report different totals for one repo
2227
+ * (#3242 Bug B)" — this makes that invariant STRUCTURAL (one implementation,
2228
+ * two call sites) instead of two copies a future edit could silently
2229
+ * diverge.
2230
+ *
2231
+ * Two DELIBERATELY DIFFERENT counting strategies, selected by `convention`:
2232
+ *
2233
+ * - BRACKET: counts via `tokenizeHeadings(scope)` at levels 2-4 (mirroring
2234
+ * `getMilestonePhaseFilter`'s own level bound, `roadmap-parser.cts:1090`),
2235
+ * testing each heading's (hash-stripped, fence-STRIPPED-by-construction)
2236
+ * text against the phase-heading-intro grammar directly. Fence-aware by
2237
+ * construction — `tokenizeHeadings` never produces a token for a fenced
2238
+ * line — closing round-4's Major 1: a fenced EXAMPLE phase heading in the
2239
+ * preamble (`` ### [GSD.02] 05: Example phase `` inside a
2240
+ * ` ```markdown ` block) previously inflated this count via the raw regex
2241
+ * below, which ran over the whole scope STRING with no fence awareness at
2242
+ * all (F9, F10 — `total_phases` read 3 where the milestone has 2 real
2243
+ * phases). The producer (`extractCurrentMilestone`'s returned scope
2244
+ * string) is deliberately NOT changed — every other consumer of that
2245
+ * string needs its full content fidelity, and the legacy path's identity
2246
+ * forbids touching the string all consumers share; this fixes the
2247
+ * COUNTING, not the scope.
2248
+ *
2249
+ * - LEGACY (any non-bracket convention, including unresolved/null): retain
2250
+ * the existing raw `content.exec()` counting strategy. On the read path,
2251
+ * route sentinel exclusion through #3185's canonical predicate; the sync
2252
+ * path intentionally retains its pre-existing absence of that exclusion.
2253
+ *
2254
+ * `applyConventionTokenSentinelRules` makes the remaining convention-specific
2255
+ * asymmetry explicit. Both read and sync exclude bare bracket token 999; only
2256
+ * the read path excludes canonical legacy sentinels. Both bracket paths also
2257
+ * retain the bracket-id and bare-0 rules. Sharing the implementation therefore
2258
+ * cannot silently move either convention's total.
2259
+ */
2260
+ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules) {
2261
+ let count = 0;
2262
+ if (convention === 'bracket') {
2263
+ const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true);
2264
+ const phaseHeadingPattern = new RegExp(`^${introSrc}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
2265
+ for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(scope)) {
2266
+ if (h.level < 2 || h.level > 4)
2267
+ continue;
2268
+ const m = phaseHeadingPattern.exec(h.text);
2269
+ if (!m)
2270
+ continue;
2271
+ const bracketId = m[1];
2272
+ const token = m[2];
2273
+ // Only count tokens that contain at least one digit — excludes
2274
+ // pure-word section headings (Overview, Details) while keeping
2275
+ // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
2276
+ if (!/\d/.test(token))
2277
+ continue;
2278
+ // #612 READING-B: a bracket heading carries its sentinel in the
2279
+ // bracket, so `### [GSD.999] 01:` is an icebox item even though its
2280
+ // token is `01`.
2281
+ if (bracketId && isSentinelPhaseId(`${bracketId}-${token}`, 'bracket'))
2282
+ continue;
2283
+ // #612: under bracket the token rule composes with the bracket-id
2284
+ // check as the engine's {0, 999} sentinel set.
2285
+ if (bracketId && /^0\b/.test(token))
2286
+ continue;
2287
+ if (applyConventionTokenSentinelRules && /^999\b/.test(token))
2288
+ continue;
2289
+ // #1514: retired/folded phases are struck through in the ROADMAP;
2290
+ // exclude them from the denominator (they can never be completed).
2291
+ if (retiredPhaseNums.has(phaseKeyFromToken(token)))
2292
+ continue;
2293
+ count++;
2294
+ }
2295
+ return count;
2296
+ }
2297
+ // LEGACY stays on the pre-round-4 raw exec loop. #3185 owns the read-path
2298
+ // sentinel predicate; sync deliberately preserves its prior behavior.
2299
+ const phaseHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
2300
+ let m;
2301
+ while ((m = phaseHeadingPattern.exec(scope)) !== null) {
2302
+ const token = m[1];
2303
+ if (!/\d/.test(token))
2304
+ continue;
2305
+ if (applyConventionTokenSentinelRules && isSentinelPhaseId(token))
2306
+ continue;
2307
+ if (retiredPhaseNums.has(phaseKeyFromToken(token)))
2308
+ continue;
2309
+ count++;
2310
+ }
2311
+ return count;
2312
+ }
1861
2313
  /**
1862
2314
  * Extract machine-readable fields from STATE.md markdown body and build
1863
2315
  * a YAML frontmatter object. Allows hooks and scripts to read state
1864
2316
  * reliably via `state json` instead of fragile regex parsing.
1865
2317
  */
1866
- function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases) {
2318
+ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases,
2319
+ // #4094: the stored siblings of storedTotalPhases, threaded from each call
2320
+ // site exactly the same way — see readStoredProgressCounter below. Under the
2321
+ // #3354/#3573 withhold condition the disk scan returns null for all four
2322
+ // counters, and these stored values are what the progress block falls back
2323
+ // to (else the keys are omitted).
2324
+ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
1867
2325
  // #2956: scope `Phase` extraction to ## Current Position (mirrors the read
1868
2326
  // path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
1869
2327
  // below). Phase canonically lives in ## Current Position (templates/state.md);
@@ -1953,6 +2411,10 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1953
2411
  // from the pre-existing frontmatter fields parsed above, a path this phase
1954
2412
  // does not touch and which predates listMilestonePhaseDirs entirely.
1955
2413
  let diskScope = SCOPE.COMPLETE;
2414
+ // #612: resolved ONCE per call, federated workstream -> root, and shared by
2415
+ // the heading counter, the retirement filter and the retired-directory skip so
2416
+ // no two of them can split on different answers.
2417
+ const phaseConvention = cwd ? resolvePhaseIdConvention(cwd) : null;
1956
2418
  if (cwd) {
1957
2419
  try {
1958
2420
  const phasesDir = planningPaths(cwd).phases;
@@ -1973,7 +2435,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1973
2435
  roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1974
2436
  if (roadmapRaw !== null) {
1975
2437
  roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1976
- retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
2438
+ retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention);
1977
2439
  }
1978
2440
  }
1979
2441
  catch { /* fall through: no roadmap scope → no retired exclusion */ }
@@ -1984,7 +2446,11 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1984
2446
  // CURRENT (stored) milestone" — routed through the canonical owner
1985
2447
  // instead of a hand-rolled readdirSync + isDirInMilestone filter
1986
2448
  // (which also never excluded sentinels, unlike the owner).
1987
- const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: storedMilestone ?? null });
2449
+ const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, {
2450
+ cwd,
2451
+ versionOverride: storedMilestone ?? null,
2452
+ phaseIdConvention: phaseConvention,
2453
+ });
1988
2454
  // Bug #2445: when stale phase dirs from a prior milestone remain in
1989
2455
  // .planning/phases/ alongside new dirs with the same phase number,
1990
2456
  // de-duplicate by normalized phase number keeping exactly one dir
@@ -1996,7 +2462,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1996
2462
  // artifact; drop it from the disk phase set so it counts toward
1997
2463
  // neither the denominator nor the numerator (mirrors the heading
1998
2464
  // exclusion below). Project-code-aware via phaseKeyFromDir.
1999
- if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
2465
+ if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
2000
2466
  continue;
2001
2467
  // #3185: dedup grouping routed through the canonical phaseKeyFromDir
2002
2468
  // (src/phase-id.cts) instead of a local leading-digits regex that
@@ -2005,7 +2471,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2005
2471
  // so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
2006
2472
  // multi-segment milestone dirs. Same key surface used two lines
2007
2473
  // above for the retiredPhaseNums exclusion, so both filters agree.
2008
- const key = phaseKeyFromDir(dir);
2474
+ const key = phaseKeyFromDir(dir, phaseConvention);
2009
2475
  if (!seenPhaseNums.has(key)) {
2010
2476
  seenPhaseNums.set(key, dir);
2011
2477
  }
@@ -2049,31 +2515,15 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2049
2515
  if (isPhaseComplete(phaseDir).value.complete)
2050
2516
  diskCompletedPhases++;
2051
2517
  }
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
- }
2518
+ // Count phase headings from ROADMAP — single source of truth for
2519
+ // total_phases (#549). #612 round-4: shared with cmdStateSync's
2520
+ // identical-purpose counter via countRoadmapPhaseHeadings (above
2521
+ // extractRetiredPhaseNumbers). The shared helper composes its
2522
+ // fence-aware bracket strategy with #3185's canonical legacy
2523
+ // sentinel predicate for this read-path call.
2524
+ const roadmapPhaseCount = roadmapScope !== null
2525
+ ? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true)
2526
+ : 0;
2077
2527
  cached = (() => {
2078
2528
  // #1761 read-path: mirror the cmdStateSync guard (#1794). When the
2079
2529
  // asserted milestone version can't be bounded to a versioned ROADMAP
@@ -2098,7 +2548,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2098
2548
  // the prior inline regex had no boundary assertion after the
2099
2549
  // version token, so `v2.0` matched inside `v2.0.1` (#2562-class
2100
2550
  // defect, design row 17).
2101
- milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(assertedMilestoneVersion).trim());
2551
+ milestoneBounded = isMilestoneBounded(roadmapRaw, String(assertedMilestoneVersion).trim(), phaseConvention);
2102
2552
  }
2103
2553
  // #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
2104
2554
  // at all — only Phase headings) from a MILESTONED-but-unbounded one
@@ -2140,7 +2590,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2140
2590
  // the disk count is the only source and remains correct.
2141
2591
  const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
2142
2592
  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`);
2593
+ 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
2594
  }
2145
2595
  // #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
2146
2596
  // absent/unreadable the #549 heading counter never ran (roadmapScope
@@ -2157,21 +2607,31 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2157
2607
  typeof storedMilestone === 'string' &&
2158
2608
  storedMilestone.trim() !== '';
2159
2609
  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`);
2610
+ 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
2611
  }
2612
+ // #4094: the withhold condition covers ALL FOUR progress counters,
2613
+ // not just total_phases. completed_phases / total_plans /
2614
+ // completed_plans are accumulated from the exact same phaseDirs
2615
+ // walk as total_phases (same loop, same scope, same filters), so
2616
+ // whenever that walk's scope is known-untrustworthy — the exact
2617
+ // condition #3354 established — they are equally untrustworthy.
2618
+ // Pre-#4094 only totalPhases was nulled here, so every resyncing
2619
+ // write silently clobbered the three stored siblings with the
2620
+ // under-scoped disk numbers.
2621
+ const diskCountsWithheld = milestonedButUnbounded || roadmapAbsentWithAssertedMilestone;
2162
2622
  return {
2163
2623
  // The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
2164
2624
  // roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
2165
2625
  // safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
2166
2626
  // vacuously true (its gate requires roadmapRaw), so the safe-count
2167
2627
  // arm would otherwise swallow the withhold.
2168
- totalPhases: (milestonedButUnbounded || roadmapAbsentWithAssertedMilestone)
2628
+ totalPhases: diskCountsWithheld
2169
2629
  ? null
2170
2630
  : (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
2171
2631
  milestoneBounded,
2172
- completedPhases: diskCompletedPhases,
2173
- totalPlans: diskTotalPlans,
2174
- completedPlans: diskTotalSummaries,
2632
+ completedPhases: diskCountsWithheld ? null : diskCompletedPhases,
2633
+ totalPlans: diskCountsWithheld ? null : diskTotalPlans,
2634
+ completedPlans: diskCountsWithheld ? null : diskTotalSummaries,
2175
2635
  phaseDirScope,
2176
2636
  };
2177
2637
  })();
@@ -2189,9 +2649,31 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2189
2649
  else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
2190
2650
  totalPhases = storedTotalPhases;
2191
2651
  }
2192
- completedPhases = cached.completedPhases;
2193
- totalPlans = cached.totalPlans;
2194
- completedPlans = cached.completedPlans;
2652
+ // #4094: the same withhold-then-fall-back-to-stored pattern for the
2653
+ // three sibling counters. They are derived from the identical
2654
+ // phaseDirs walk, so cached.* === null here means the SAME withheld
2655
+ // condition — keep the stored frontmatter value when the caller can
2656
+ // supply it; else leave null (key omitted). Note completedPhases /
2657
+ // completedPlans have NO body-annotation fallback (only the totals
2658
+ // have body annotations), so an unstored-withheld counter is omitted.
2659
+ if (cached.completedPhases !== null) {
2660
+ completedPhases = cached.completedPhases;
2661
+ }
2662
+ else if (storedCompletedPhases !== null && storedCompletedPhases !== undefined) {
2663
+ completedPhases = storedCompletedPhases;
2664
+ }
2665
+ if (cached.totalPlans !== null) {
2666
+ totalPlans = cached.totalPlans;
2667
+ }
2668
+ else if (storedTotalPlans !== null && storedTotalPlans !== undefined) {
2669
+ totalPlans = storedTotalPlans;
2670
+ }
2671
+ if (cached.completedPlans !== null) {
2672
+ completedPlans = cached.completedPlans;
2673
+ }
2674
+ else if (storedCompletedPlans !== null && storedCompletedPlans !== undefined) {
2675
+ completedPlans = storedCompletedPlans;
2676
+ }
2195
2677
  milestoneUnbounded = cached.milestoneBounded === false;
2196
2678
  diskScope = cached.phaseDirScope;
2197
2679
  }
@@ -2483,12 +2965,21 @@ function readStateHeadFreshness(cwd, stateHead) {
2483
2965
  * instead of being clobbered by the on-disk phase-directory count.
2484
2966
  */
2485
2967
  function readStoredTotalPhases(existingFm) {
2968
+ return readStoredProgressCounter(existingFm, 'total_phases');
2969
+ }
2970
+ /**
2971
+ * #4094: the three sibling readers of readStoredTotalPhases, one per progress
2972
+ * counter the #3354/#3573 withhold now protects. All four counters come from
2973
+ * the same disk-scan walk and are withheld together; these readers feed the
2974
+ * stored-value fallback for the three that previously had none.
2975
+ */
2976
+ function readStoredProgressCounter(existingFm, key) {
2486
2977
  if (!existingFm || typeof existingFm !== 'object')
2487
2978
  return null;
2488
2979
  const progress = existingFm['progress'];
2489
2980
  if (!progress || typeof progress !== 'object')
2490
2981
  return null;
2491
- const raw = progress['total_phases'];
2982
+ const raw = progress[key];
2492
2983
  if (raw === null || raw === undefined)
2493
2984
  return null;
2494
2985
  if (typeof raw === 'string' && raw.trim() === '')
@@ -2496,6 +2987,15 @@ function readStoredTotalPhases(existingFm) {
2496
2987
  const n = Number(raw);
2497
2988
  return Number.isFinite(n) ? n : null;
2498
2989
  }
2990
+ function readStoredCompletedPhases(existingFm) {
2991
+ return readStoredProgressCounter(existingFm, 'completed_phases');
2992
+ }
2993
+ function readStoredTotalPlans(existingFm) {
2994
+ return readStoredProgressCounter(existingFm, 'total_plans');
2995
+ }
2996
+ function readStoredCompletedPlans(existingFm) {
2997
+ return readStoredProgressCounter(existingFm, 'completed_plans');
2998
+ }
2499
2999
  function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
2500
3000
  // Read existing frontmatter BEFORE stripping — it may contain values
2501
3001
  // that the body no longer has (e.g., Status field removed by an agent).
@@ -2533,7 +3033,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
2533
3033
  // milestoned-but-unbounded withhold can preserve it across the write
2534
3034
  // (the derived progress sub-block replaces the stored one wholesale below,
2535
3035
  // so an omitted key would otherwise DELETE the stored value).
2536
- const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
3036
+ const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
2537
3037
  // Preserve existing frontmatter status when body-derived status is 'unknown'.
2538
3038
  // This prevents a missing Status: field in the body from overwriting a
2539
3039
  // previously valid status (e.g., 'executing' → 'unknown').
@@ -3291,6 +3791,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3291
3791
  }
3292
3792
  }
3293
3793
  }
3794
+ let finalContent = syncedContent;
3294
3795
  if (preservation.mutated || authoritativeReasserted) {
3295
3796
  // #3742: preservation RESTORES frontmatter keys the body-derived rebuild
3296
3797
  // could not produce (e.g. `current_phase` on a layout with no body
@@ -3311,9 +3812,15 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3311
3812
  }
3312
3813
  const yamlStr = reconstructFrontmatter(preservation.postFm);
3313
3814
  const body = stripFrontmatter(syncedContent);
3314
- return `---\n${yamlStr}\n---\n\n${body}`;
3815
+ finalContent = `---\n${yamlStr}\n---\n\n${body}`;
3816
+ }
3817
+ const persistedPercent = (0, state_document_cjs_1.toFiniteNumber)(preservation.postFm['progress'] && preservation.postFm['progress']['percent']);
3818
+ if (persistedPercent !== null) {
3819
+ const reconciled = stateReplaceProgressPercent(finalContent, persistedPercent);
3820
+ if (reconciled !== null)
3821
+ finalContent = reconciled;
3315
3822
  }
3316
- return syncedContent;
3823
+ return finalContent;
3317
3824
  }
3318
3825
  /**
3319
3826
  * ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
@@ -3858,7 +4365,7 @@ function cmdStateJson(cwd, raw) {
3858
4365
  // reports the phase-directory count while the persisted file preserves the
3859
4366
  // stored total, exactly the write/read divergence #3354 closed for its shape.
3860
4367
  const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
3861
- const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
4368
+ const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
3862
4369
  // ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
3863
4370
  // current_phase_name / current_plan through the SAME `preserve-when-unchanged`
3864
4371
  // executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
@@ -4558,10 +5065,28 @@ function cmdStateValidate(cwd, raw, opts = {}) {
4558
5065
  emit({ valid: false, warnings, scope });
4559
5066
  return;
4560
5067
  }
5068
+ // #612: #3208 replaced this lookup's `startsWith` prefix test with the
5069
+ // canonical key comparison — which is the right surface, and is exactly why it
5070
+ // now needs the convention. `phaseKeyFromDir` refuses to read a bracket
5071
+ // directory without an explicit signal (a bracket dir is string-
5072
+ // indistinguishable from the legacy letter-prefixed-decimal family, ADR-2121),
5073
+ // so un-threaded it returns the WHOLE dir name as the key —
5074
+ // `GSD.02-05-delta` -> `GSD.02-5-DELTA` — while `selectedPhaseKey` is the bare
5075
+ // `05` that `parsePhaseFromProse` yields. The two sides of one comparison were
5076
+ // derived under different conventions, which is #2562's defect class and the
5077
+ // thing this file's other three `phaseKeyFromDir` call sites already thread
5078
+ // against. Un-threaded, a bracket repo whose phase directory plainly exists
5079
+ // reports `no phase directory matches phase 05` and `valid: false` — a
5080
+ // wrong-and-confident answer on precisely the repos this convention supports.
5081
+ // Resolved here rather than reusing a caller's value because cmdStateValidate
5082
+ // has no other convention-dependent read. Non-bracket conventions (null,
5083
+ // 'milestone-prefixed', unresolvable) are byte-identical to the un-threaded
5084
+ // call by construction: `extractPhaseToken` branches only on `=== 'bracket'`.
5085
+ const validateConvention = resolvePhaseIdConvention(cwd);
4561
5086
  let phaseDirPath;
4562
5087
  try {
4563
5088
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
4564
- const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
5089
+ const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention) === selectedPhaseKey);
4565
5090
  if (!phaseDir) {
4566
5091
  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
5092
  emit({ valid: false, warnings, scope });
@@ -4730,22 +5255,51 @@ function cmdStateSync(cwd, options, raw) {
4730
5255
  let syncRoadmapScope = null;
4731
5256
  let syncRoadmapRaw = null;
4732
5257
  let syncRetiredPhaseNums = new Set();
5258
+ const syncConvention = resolvePhaseIdConvention(cwd);
4733
5259
  try {
4734
5260
  const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
4735
5261
  if (roadmapRaw !== null) {
4736
5262
  syncRoadmapRaw = roadmapRaw;
4737
5263
  syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
4738
- syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
5264
+ syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention);
4739
5265
  }
4740
5266
  }
4741
5267
  catch { /* fall through: no roadmap scope → no retired exclusion */ }
5268
+ // #2761 Major 1 (round-2 adversarial review): this disk scan fed
5269
+ // totalDiskPlans/totalDiskSummaries/diskCompletedPhases/syncTotalPhases
5270
+ // below UNFILTERED — no milestone-window filter, unlike
5271
+ // buildStateFrontmatter's identical-purpose scan a few hundred lines above
5272
+ // (`:1698`). One command (`state sync`) therefore wrote TWO contradictory
5273
+ // numbers into the same STATE.md: frontmatter total_phases/completed_phases
5274
+ // milestone-scoped correctly (via the READ derivation), body Progress
5275
+ // percent computed from the whole disk. On the ADR-canonical version-less
5276
+ // bracket fixture (4 dirs, 3 complete; asserted milestone = 2 phases, both
5277
+ // complete): body wrote 75% where 100% is true (repro3).
5278
+ //
5279
+ // GATED on `syncConvention === 'bracket'` — an unconditional filter would
5280
+ // ALSO move LEGACY sync percents, since the milestone-scoping-vs-whole-disk
5281
+ // divergence this fixes is engine-wide, not bracket-specific; the gate
5282
+ // keeps legacy byte-identical, which is the binding constraint here. This
5283
+ // is a DEVIATION from an earlier "mirror :1698 unconditionally" phrasing —
5284
+ // deliberate, not an oversight: legacy repos are DOWNSTREAM of a Progress
5285
+ // percent that has read this way for a long time, and moving it as a side
5286
+ // effect of a bracket-only PR is out of this fix's scope.
5287
+ // Upstream #3185 made `listMilestonePhaseDirs` the sole phase-directory
5288
+ // enumeration owner; it delegates window membership to
5289
+ // getMilestonePhaseFilter. Cache that owner's bracket result as a set and
5290
+ // compose it with this scan, rather than restoring the retired direct
5291
+ // parser dependency. Legacy retains this scan's prior pass-all behavior.
5292
+ const syncMilestonePhaseDirs = syncConvention === 'bracket'
5293
+ ? new Set(listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: syncConvention }).value)
5294
+ : null;
4742
5295
  // Scan all phases
4743
5296
  let entries;
4744
5297
  try {
4745
5298
  entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
4746
5299
  .filter(e => e.isDirectory())
4747
5300
  .map(e => e.name)
4748
- .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name))))
5301
+ .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention))))
5302
+ .filter(name => syncMilestonePhaseDirs === null || syncMilestonePhaseDirs.has(name))
4749
5303
  .sort();
4750
5304
  }
4751
5305
  catch {
@@ -4793,28 +5347,18 @@ function cmdStateSync(cwd, options, raw) {
4793
5347
  }
4794
5348
  }
4795
5349
  // 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.
5350
+ // #612 round-4: shares countRoadmapPhaseHeadings with buildStateFrontmatter
5351
+ // (defined just above extractRetiredPhaseNumbers) so both report
5352
+ // consistent totals off the SAME implementation, not two independently
5353
+ // maintained copies (#3242 Bug B).
5354
+ // #612 round-5: bracket sync enables the same bare-token 999 exclusion as
5355
+ // the read path and getMilestonePhaseFilter, preventing frontmatter/body
5356
+ // disagreement. Non-bracket conventions still pass false, preserving the
5357
+ // pre-existing legacy sync behavior while #3185 remains the read-path owner.
4800
5358
  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
- }
5359
+ const roadmapPhaseCount = syncRoadmapScope !== null
5360
+ ? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket')
5361
+ : 0;
4818
5362
  if (roadmapPhaseCount > 0) {
4819
5363
  syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
4820
5364
  }
@@ -4834,8 +5378,9 @@ function cmdStateSync(cwd, options, raw) {
4834
5378
  if (versionStr !== null && syncRoadmapRaw !== null) {
4835
5379
  // #3184: routed through the single owner (roadmap-parser.cjs) instead of
4836
5380
  // a hand-rolled, unbounded-substring re-derivation — see the identical
4837
- // fix in buildStateFrontmatter above.
4838
- milestoneBounded = isMilestoneBoundedInRoadmap(syncRoadmapRaw, versionStr);
5381
+ // fix in buildStateFrontmatter above. #612 composes its gated bracket
5382
+ // extension on top inside isMilestoneBounded.
5383
+ milestoneBounded = isMilestoneBounded(syncRoadmapRaw, versionStr, syncConvention);
4839
5384
  }
4840
5385
  let percent = null;
4841
5386
  if (!milestoneBounded) {
@@ -4853,6 +5398,20 @@ function cmdStateSync(cwd, options, raw) {
4853
5398
  // it here (discarding `.value`, which duplicates `entries`'s own
4854
5399
  // retired-phase-filtered listing) gets the real scope without changing
4855
5400
  // the disk-scan totals computed above.
5401
+ //
5402
+ // #2761 (round-11 M2 follow-up): deliberately NOT threading
5403
+ // `phaseIdConvention` here, unlike the other call sites this same PR
5404
+ // converts. Only `.scope` is consumed (the `.value` directory list is
5405
+ // thrown away), and inside `getMilestonePhaseFilter` `scope` is computed
5406
+ // from `extractCurrentMilestoneScoped`/`classifyMilestoneWindow` BEFORE
5407
+ // `headingConvention` is resolved — `phaseIdConvention` only reaches the
5408
+ // heading/dir MEMBERSHIP scan (`scanMilestonePhaseIds`, `isDirInMilestone`)
5409
+ // that produces `.value`, never the scope discriminator itself. So the
5410
+ // `undefined` default here (lazy resolve-from-config) and an explicitly
5411
+ // threaded `syncConvention` would compute the identical `scope` either
5412
+ // way — there is no silent-inherit exposure to close at this site, only
5413
+ // at sites (milestone.cts, cmdStateUpdateProgress above) that also
5414
+ // consume `.value`.
4856
5415
  const syncScope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
4857
5416
  if (syncScope !== SCOPE.COMPLETE) {
4858
5417
  changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);