@opengsd/gsd-core 1.11.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 (498) 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-code-fixer.md +1 -1
  5. package/agents/gsd-debug-session-manager.md +1 -1
  6. package/agents/gsd-debugger.md +1 -1
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +78 -42
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +0 -1
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +3 -1
  15. package/agents/gsd-plan-checker.md +91 -112
  16. package/agents/gsd-planner.md +20 -4
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +82 -7
  21. package/agents/gsd-ui-researcher.md +70 -3
  22. package/agents/gsd-verifier.md +24 -2
  23. package/bin/install.js +847 -200
  24. package/commands/gsd/discuss-phase.md +1 -1
  25. package/commands/gsd/execute-phase.md +1 -1
  26. package/commands/gsd/import.md +1 -1
  27. package/commands/gsd/ns-workflow.md +2 -1
  28. package/commands/gsd/phase.md +1 -1
  29. package/commands/gsd/quick-batch.md +105 -0
  30. package/commands/gsd/quick.md +8 -4
  31. package/commands/gsd/surface.md +18 -8
  32. package/gsd-core/bin/gsd-tools.cjs +761 -100
  33. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  34. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  35. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  36. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  37. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  38. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  39. package/gsd-core/bin/lib/audit.cjs +163 -41
  40. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  41. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  42. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  43. package/gsd-core/bin/lib/capability-registry.cjs +785 -144
  44. package/gsd-core/bin/lib/capability-state.cjs +25 -4
  45. package/gsd-core/bin/lib/capability-validator.cjs +321 -18
  46. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  47. package/gsd-core/bin/lib/check-command-router.cjs +229 -6
  48. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  49. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  50. package/gsd-core/bin/lib/clusters.cjs +1 -0
  51. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  52. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  53. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  54. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  55. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  56. package/gsd-core/bin/lib/commands.cjs +877 -54
  57. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  58. package/gsd-core/bin/lib/config-loader.cjs +121 -29
  59. package/gsd-core/bin/lib/config.cjs +92 -2
  60. package/gsd-core/bin/lib/configuration.cjs +129 -37
  61. package/gsd-core/bin/lib/core-utils.cjs +118 -14
  62. package/gsd-core/bin/lib/decisions.cjs +213 -1
  63. package/gsd-core/bin/lib/edge-probe.cjs +23 -2
  64. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  65. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  66. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  67. package/gsd-core/bin/lib/frontmatter.cjs +975 -326
  68. package/gsd-core/bin/lib/gap-checker.cjs +41 -8
  69. package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  71. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  72. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
  73. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  74. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  75. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  76. package/gsd-core/bin/lib/host-integration.cjs +96 -11
  77. package/gsd-core/bin/lib/init-command-router.cjs +132 -21
  78. package/gsd-core/bin/lib/init.cjs +252 -56
  79. package/gsd-core/bin/lib/install-engine.cjs +252 -15
  80. package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
  81. package/gsd-core/bin/lib/install-profiles.cjs +100 -18
  82. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  83. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  84. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  85. package/gsd-core/bin/lib/intel.cjs +101 -26
  86. package/gsd-core/bin/lib/io.cjs +195 -15
  87. package/gsd-core/bin/lib/learnings.cjs +85 -14
  88. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  89. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  90. package/gsd-core/bin/lib/markdown-table.cjs +175 -4
  91. package/gsd-core/bin/lib/milestone.cjs +112 -7
  92. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  93. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  94. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  95. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  96. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  97. package/gsd-core/bin/lib/phase-id.cjs +321 -13
  98. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  99. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  100. package/gsd-core/bin/lib/phase.cjs +1175 -115
  101. package/gsd-core/bin/lib/plan-document.cjs +273 -0
  102. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  103. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  104. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  105. package/gsd-core/bin/lib/planning-snapshot.cjs +165 -34
  106. package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
  107. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  108. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  109. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  110. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  111. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  112. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  113. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  114. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  115. package/gsd-core/bin/lib/review-lane-descriptor.cjs +62 -14
  116. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  117. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  118. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  119. package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
  120. package/gsd-core/bin/lib/roadmap.cjs +248 -64
  121. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
  122. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  123. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
  124. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
  125. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  126. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  127. package/gsd-core/bin/lib/shell-command-projection.cjs +75 -8
  128. package/gsd-core/bin/lib/smart-entry.cjs +19 -31
  129. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  130. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  131. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  132. package/gsd-core/bin/lib/state-document.cjs +216 -5
  133. package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
  134. package/gsd-core/bin/lib/state-transition.cjs +850 -145
  135. package/gsd-core/bin/lib/state.cjs +1629 -287
  136. package/gsd-core/bin/lib/surface.cjs +33 -10
  137. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  138. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  139. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  140. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  141. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  142. package/gsd-core/bin/lib/uat.cjs +2542 -387
  143. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  144. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  145. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  146. package/gsd-core/bin/lib/update-context.cjs +6 -2
  147. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  148. package/gsd-core/bin/lib/validate.cjs +230 -12
  149. package/gsd-core/bin/lib/vendor/README.md +43 -5
  150. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  151. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  152. package/gsd-core/bin/lib/verification.cjs +287 -13
  153. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  154. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  155. package/gsd-core/bin/lib/verify.cjs +441 -56
  156. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  157. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  158. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  159. package/gsd-core/bin/lib/worktree-safety.cjs +185 -21
  160. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  161. package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
  162. package/gsd-core/bin/shared/exit-codes.json +8 -0
  163. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  164. package/gsd-core/bin/shared/model-catalog.json +8 -1
  165. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  166. package/gsd-core/references/agent-contracts.md +6 -5
  167. package/gsd-core/references/api-coverage.md +24 -2
  168. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  169. package/gsd-core/references/checkpoints.md +37 -19
  170. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  171. package/gsd-core/references/edge-probe.md +17 -5
  172. package/gsd-core/references/execute-mvp-tdd.md +18 -18
  173. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  174. package/gsd-core/references/execute-phase-response-language.md +6 -0
  175. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  176. package/gsd-core/references/executor-examples.md +42 -0
  177. package/gsd-core/references/failing-direction.md +78 -0
  178. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  179. package/gsd-core/references/gate-prompts.md +1 -1
  180. package/gsd-core/references/git-integration.md +5 -5
  181. package/gsd-core/references/git-planning-commit.md +3 -3
  182. package/gsd-core/references/gsd-run-resolver.md +1 -1
  183. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  184. package/gsd-core/references/model-profiles.md +1 -1
  185. package/gsd-core/references/mvp-concepts.md +2 -2
  186. package/gsd-core/references/nyquist-compliance.md +74 -0
  187. package/gsd-core/references/offer-next.md +3 -5
  188. package/gsd-core/references/phase-argument-parsing.md +3 -3
  189. package/gsd-core/references/plan-checker-examples.md +41 -0
  190. package/gsd-core/references/planner-antipatterns.md +25 -0
  191. package/gsd-core/references/planner-chunked.md +5 -1
  192. package/gsd-core/references/planner-coupling.md +42 -0
  193. package/gsd-core/references/planner-failing-direction.md +53 -0
  194. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  195. package/gsd-core/references/planner-quick-batch.md +71 -0
  196. package/gsd-core/references/planner-reviews.md +47 -0
  197. package/gsd-core/references/planner-revision.md +76 -3
  198. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  199. package/gsd-core/references/planning-config.md +39 -9
  200. package/gsd-core/references/response-language-directive.md +9 -0
  201. package/gsd-core/references/reviewer-instances.md +31 -0
  202. package/gsd-core/references/revision-loop.md +118 -11
  203. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  204. package/gsd-core/references/tdd.md +15 -12
  205. package/gsd-core/references/ui-brand.md +65 -21
  206. package/gsd-core/references/ui-consideration-probe.md +1 -1
  207. package/gsd-core/references/universal-anti-patterns.md +2 -2
  208. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  209. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  210. package/gsd-core/references/verify-mvp-mode.md +1 -1
  211. package/gsd-core/references/workstream-flag.md +11 -11
  212. package/gsd-core/templates/README.md +1 -1
  213. package/gsd-core/templates/SECURITY.md +3 -3
  214. package/gsd-core/templates/UI-SPEC.md +25 -3
  215. package/gsd-core/templates/VALIDATION.md +3 -3
  216. package/gsd-core/templates/phase-prompt.md +7 -0
  217. package/gsd-core/templates/state.md +7 -0
  218. package/gsd-core/templates/verification-report.md +5 -0
  219. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  220. package/gsd-core/workflows/add-backlog.md +3 -1
  221. package/gsd-core/workflows/add-phase.md +5 -3
  222. package/gsd-core/workflows/add-tests.md +4 -9
  223. package/gsd-core/workflows/add-todo.md +2 -2
  224. package/gsd-core/workflows/ai-integration-phase.md +5 -10
  225. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  226. package/gsd-core/workflows/audit-fix.md +14 -3
  227. package/gsd-core/workflows/audit-milestone.md +11 -9
  228. package/gsd-core/workflows/audit-uat.md +19 -2
  229. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  230. package/gsd-core/workflows/autonomous.md +12 -26
  231. package/gsd-core/workflows/check-todos.md +2 -2
  232. package/gsd-core/workflows/cleanup.md +3 -3
  233. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
  234. package/gsd-core/workflows/code-review-fix.md +3 -1
  235. package/gsd-core/workflows/code-review.md +192 -69
  236. package/gsd-core/workflows/complete-milestone.md +28 -14
  237. package/gsd-core/workflows/debug.md +6 -4
  238. package/gsd-core/workflows/diagnose-issues.md +17 -7
  239. package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
  240. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  241. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  242. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  243. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  244. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
  245. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  246. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  247. package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
  248. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  249. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  250. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  251. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
  252. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  253. package/gsd-core/workflows/discuss-phase.md +2 -2
  254. package/gsd-core/workflows/do.md +46 -19
  255. package/gsd-core/workflows/docs-update.md +6 -5
  256. package/gsd-core/workflows/edit-phase.md +3 -1
  257. package/gsd-core/workflows/eval-review.md +5 -10
  258. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
  259. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -11
  260. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  261. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  262. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  263. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +29 -5
  264. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  265. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  266. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +4 -2
  267. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  268. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  269. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  270. package/gsd-core/workflows/execute-phase.md +68 -66
  271. package/gsd-core/workflows/execute-plan.md +25 -20
  272. package/gsd-core/workflows/explore.md +3 -1
  273. package/gsd-core/workflows/extract-learnings.md +3 -1
  274. package/gsd-core/workflows/fast.md +8 -2
  275. package/gsd-core/workflows/forensics.md +3 -1
  276. package/gsd-core/workflows/graduation.md +6 -6
  277. package/gsd-core/workflows/health.md +4 -7
  278. package/gsd-core/workflows/help/modes/brief.md +2 -0
  279. package/gsd-core/workflows/help/modes/default.md +2 -0
  280. package/gsd-core/workflows/help/modes/full.md +12 -0
  281. package/gsd-core/workflows/help/modes/topic.md +2 -0
  282. package/gsd-core/workflows/help.md +2 -0
  283. package/gsd-core/workflows/import.md +17 -14
  284. package/gsd-core/workflows/inbox.md +5 -6
  285. package/gsd-core/workflows/ingest-docs.md +45 -12
  286. package/gsd-core/workflows/insert-phase.md +7 -5
  287. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  288. package/gsd-core/workflows/list-seeds.md +7 -3
  289. package/gsd-core/workflows/list-workspaces.md +3 -1
  290. package/gsd-core/workflows/manager.md +15 -26
  291. package/gsd-core/workflows/map-codebase.md +3 -1
  292. package/gsd-core/workflows/milestone-summary.md +3 -1
  293. package/gsd-core/workflows/mvp-phase.md +3 -3
  294. package/gsd-core/workflows/new-milestone.md +10 -22
  295. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  296. package/gsd-core/workflows/new-project.md +17 -29
  297. package/gsd-core/workflows/new-workspace.md +2 -2
  298. package/gsd-core/workflows/next.md +4 -2
  299. package/gsd-core/workflows/node-repair.md +2 -0
  300. package/gsd-core/workflows/note.md +2 -0
  301. package/gsd-core/workflows/onboard.md +1 -1
  302. package/gsd-core/workflows/pause-work.md +20 -5
  303. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  304. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  305. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
  306. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
  307. package/gsd-core/workflows/plan-phase.md +251 -54
  308. package/gsd-core/workflows/plan-review-convergence.md +148 -19
  309. package/gsd-core/workflows/plant-seed.md +3 -3
  310. package/gsd-core/workflows/pr-branch.md +195 -51
  311. package/gsd-core/workflows/profile-user.md +17 -15
  312. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  313. package/gsd-core/workflows/progress.md +52 -15
  314. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  315. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
  316. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  317. package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
  318. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  319. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  320. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  321. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  322. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  323. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  324. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  325. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  326. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  327. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  328. package/gsd-core/workflows/quick-batch.md +203 -0
  329. package/gsd-core/workflows/quick.md +33 -32
  330. package/gsd-core/workflows/reapply-patches.md +2 -0
  331. package/gsd-core/workflows/remove-phase.md +6 -4
  332. package/gsd-core/workflows/remove-workspace.md +3 -3
  333. package/gsd-core/workflows/resume-project.md +14 -14
  334. package/gsd-core/workflows/review.md +404 -21
  335. package/gsd-core/workflows/scan.md +3 -1
  336. package/gsd-core/workflows/section-manifest.json +12 -0
  337. package/gsd-core/workflows/secure-phase.md +3 -3
  338. package/gsd-core/workflows/session-report.md +2 -0
  339. package/gsd-core/workflows/settings-advanced.md +9 -9
  340. package/gsd-core/workflows/settings-integrations.md +66 -32
  341. package/gsd-core/workflows/settings.md +4 -6
  342. package/gsd-core/workflows/ship.md +22 -16
  343. package/gsd-core/workflows/sketch-wrap-up.md +13 -17
  344. package/gsd-core/workflows/sketch.md +13 -19
  345. package/gsd-core/workflows/smart-entry.md +4 -6
  346. package/gsd-core/workflows/spec-phase.md +31 -4
  347. package/gsd-core/workflows/spike-wrap-up.md +9 -11
  348. package/gsd-core/workflows/spike.md +21 -32
  349. package/gsd-core/workflows/stats.md +4 -2
  350. package/gsd-core/workflows/sync-skills.md +13 -5
  351. package/gsd-core/workflows/thread.md +13 -7
  352. package/gsd-core/workflows/transition.md +7 -5
  353. package/gsd-core/workflows/ui-phase.md +36 -21
  354. package/gsd-core/workflows/ui-review.md +7 -11
  355. package/gsd-core/workflows/ultraplan-phase.md +7 -13
  356. package/gsd-core/workflows/undo.md +9 -17
  357. package/gsd-core/workflows/update.md +47 -48
  358. package/gsd-core/workflows/validate-phase.md +3 -3
  359. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  360. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  361. package/gsd-core/workflows/verify-work.md +106 -21
  362. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  363. package/hooks/dist/gsd-check-update-worker.js +19 -2
  364. package/hooks/dist/gsd-config-reload.js +18 -12
  365. package/hooks/dist/gsd-context-monitor.js +302 -22
  366. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  367. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  368. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  369. package/hooks/dist/gsd-cursor-stop.js +2 -1
  370. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  371. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  372. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  373. package/hooks/dist/gsd-graphify-update.sh +22 -18
  374. package/hooks/dist/gsd-node-runner.sh +77 -0
  375. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  376. package/hooks/dist/gsd-prompt-guard.js +46 -12
  377. package/hooks/dist/gsd-read-guard.js +18 -7
  378. package/hooks/dist/gsd-read-injection-scanner.js +22 -13
  379. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  380. package/hooks/dist/gsd-session-state.sh +1 -0
  381. package/hooks/dist/gsd-statusline.js +222 -29
  382. package/hooks/dist/gsd-validate-commit.sh +523 -12
  383. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  384. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  385. package/hooks/dist/gsd-workflow-guard.js +36 -17
  386. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  387. package/hooks/dist/gsd-write-guard.js +35 -25
  388. package/hooks/dist/lib/cli-exit.js +560 -0
  389. package/hooks/dist/lib/exit-code-registry.js +98 -0
  390. package/hooks/dist/lib/git-cmd.js +210 -1
  391. package/hooks/dist/lib/git-probe.js +84 -0
  392. package/hooks/dist/lib/hook-exit.js +81 -0
  393. package/hooks/dist/lib/injection-patterns.js +36 -6
  394. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  395. package/hooks/gsd-agent-isolation-guard.js +77 -38
  396. package/hooks/gsd-check-update-worker.js +19 -2
  397. package/hooks/gsd-config-reload.js +18 -12
  398. package/hooks/gsd-context-monitor.js +302 -22
  399. package/hooks/gsd-cursor-post-tool.js +3 -1
  400. package/hooks/gsd-cursor-pre-tool.js +3 -1
  401. package/hooks/gsd-cursor-session-start.js +2 -1
  402. package/hooks/gsd-cursor-stop.js +2 -1
  403. package/hooks/gsd-cursor-subagent-start.js +28 -23
  404. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  405. package/hooks/gsd-ensure-canonical-path.js +2 -1
  406. package/hooks/gsd-graphify-update.sh +22 -18
  407. package/hooks/gsd-node-runner.sh +77 -0
  408. package/hooks/gsd-phase-boundary.sh +1 -0
  409. package/hooks/gsd-prompt-guard.js +46 -12
  410. package/hooks/gsd-read-guard.js +18 -7
  411. package/hooks/gsd-read-injection-scanner.js +22 -13
  412. package/hooks/gsd-secret-read-guard.js +1079 -0
  413. package/hooks/gsd-session-state.sh +1 -0
  414. package/hooks/gsd-statusline.js +222 -29
  415. package/hooks/gsd-validate-commit.sh +523 -12
  416. package/hooks/gsd-windsurf-pre-command.js +16 -11
  417. package/hooks/gsd-windsurf-pre-write.js +22 -13
  418. package/hooks/gsd-workflow-guard.js +36 -17
  419. package/hooks/gsd-worktree-path-guard.js +36 -21
  420. package/hooks/gsd-write-guard.js +35 -25
  421. package/hooks/hooks.json +6 -0
  422. package/hooks/lib/cli-exit.js +560 -0
  423. package/hooks/lib/exit-code-registry.js +98 -0
  424. package/hooks/lib/git-cmd.js +210 -1
  425. package/hooks/lib/git-probe.js +84 -0
  426. package/hooks/lib/hook-exit.js +81 -0
  427. package/hooks/lib/injection-patterns.js +36 -6
  428. package/hooks/managed-hooks-registry.cjs +4 -0
  429. package/package.json +14 -9
  430. package/scripts/base64-scan.sh +74 -12
  431. package/scripts/build-hooks.js +12 -0
  432. package/scripts/check-glossary-refs.cjs +77 -15
  433. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  434. package/scripts/ci-check-job-near-cap.cjs +49 -0
  435. package/scripts/ci-pr-mergeability.cjs +262 -0
  436. package/scripts/ci-test-scope.cjs +52 -12
  437. package/scripts/ci-timeout-report.cjs +230 -0
  438. package/scripts/docs-guard-registry.cjs +406 -0
  439. package/scripts/gen-capability-registry.cjs +8 -6
  440. package/scripts/gen-exit-code-docs.cjs +318 -0
  441. package/scripts/gen-exit-code-registry.cjs +891 -0
  442. package/scripts/gen-features.cjs +836 -0
  443. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  444. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  445. package/scripts/gen-loop-host-contract.cjs +189 -4
  446. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  447. package/scripts/gen-state-md-docs.cjs +727 -0
  448. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  449. package/scripts/lib/ci-job-timing.cjs +72 -0
  450. package/scripts/lib/cli-exit.cjs +546 -44
  451. package/scripts/lib/drift-scan.cjs +32 -2
  452. package/scripts/lib/exit-code-registry.cjs +98 -0
  453. package/scripts/lib/ndjson-reporter.cjs +119 -0
  454. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  455. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  456. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  457. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  458. package/scripts/lint-docs-guard-registration.cjs +495 -0
  459. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +198 -0
  460. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  461. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  462. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  463. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  464. package/scripts/lint-phase-enumeration-drift.cjs +45 -14
  465. package/scripts/lint-phase-id-drift.cjs +133 -8
  466. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  467. package/scripts/lint-portable-grep.cjs +176 -0
  468. package/scripts/lint-removed-but-needed.cjs +184 -16
  469. package/scripts/lint-response-language-coverage.cjs +524 -0
  470. package/scripts/lint-seam-enforcement.cjs +182 -0
  471. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  472. package/scripts/lint-source-test-name-collision.cjs +241 -0
  473. package/scripts/lint-state-write-path-drift.cjs +337 -432
  474. package/scripts/lint-test-file-count.allowlist.json +124 -4
  475. package/scripts/lint-test-file-count.cjs +25 -3
  476. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  477. package/scripts/lint-vendored-deps.cjs +208 -35
  478. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  479. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  480. package/scripts/mutation-matrix.cjs +599 -50
  481. package/scripts/npm-audit-baseline.cjs +376 -0
  482. package/scripts/prompt-injection-scan.sh +83 -14
  483. package/scripts/require-issue-link-policy.cjs +16 -1
  484. package/scripts/secret-scan.sh +75 -13
  485. package/scripts/select-docs-guards.cjs +56 -0
  486. package/scripts/sync-runtime-launcher.cjs +22 -3
  487. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  488. package/skills/gsd-execute-phase/SKILL.md +1 -1
  489. package/skills/gsd-import/SKILL.md +1 -1
  490. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  491. package/skills/gsd-phase/SKILL.md +1 -1
  492. package/skills/gsd-quick/SKILL.md +8 -4
  493. package/skills/gsd-quick-batch/SKILL.md +105 -0
  494. package/skills/gsd-surface/SKILL.md +18 -8
  495. package/vscode/package.json +1 -1
  496. package/bin/lib/ui-safety-gate.cjs +0 -109
  497. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  498. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -14,27 +14,51 @@ 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
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ const cliExitModule = require("./cli-exit.cjs");
20
+ const { ExitError } = cliExitModule;
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ const stateContract = require("./state-contract.cjs");
23
+ const { publishStateContract } = stateContract;
18
24
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
25
  const configLoaderMod = require("./config-loader.cjs");
20
26
  const { loadConfig } = configLoaderMod;
21
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
28
  const phaseIdMod = require("./phase-id.cjs");
23
- 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;
24
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
33
  const roadmapParserMod = require("./roadmap-parser.cjs");
26
- const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasMilestoneSectioning } = roadmapParserMod;
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.
35
+ const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasAnyMilestoneSection } = roadmapParserMod;
27
36
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
28
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
38
  const planningWorkspace = require("./planning-workspace.cjs");
30
- const { planningDir, planningPaths } = planningWorkspace;
39
+ const { planningDir, planningPaths, resolvePhaseIdConvention } = planningWorkspace;
31
40
  const clock_cjs_1 = require("./clock.cjs");
32
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
33
42
  const frontmatter = require("./frontmatter.cjs");
34
- const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel } = frontmatter;
43
+ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel, FRONTMATTER_UNPARSEABLE } = frontmatter;
44
+ /**
45
+ * ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
46
+ * `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a frontmatter-fenced region
47
+ * exists but failed to parse (malformed YAML, or a refused anchor/alias/merge key)? Mirrors
48
+ * `state-transition.cts`'s private helper of the same name/shape — kept local rather than
49
+ * exported+imported because the two modules' `existingFm` values come from independent
50
+ * `extractFrontmatter` calls and this predicate is a two-line symbol read, not shared state.
51
+ */
52
+ function isUnparseableFrontmatter(existingFm) {
53
+ return existingFm[FRONTMATTER_UNPARSEABLE] === true;
54
+ }
35
55
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
56
  const scanPhasePlans = require("./plan-scan.cjs");
37
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
38
62
  const verificationMod = require("./verification.cjs");
39
63
  const { isPhaseComplete } = verificationMod;
40
64
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -45,6 +69,10 @@ const phaseLocatorMod = require("./phase-locator.cjs");
45
69
  const { listMilestonePhaseDirs } = phaseLocatorMod;
46
70
  // eslint-disable-next-line @typescript-eslint/no-require-imports
47
71
  const stateTransitionMod = require("./state-transition.cjs");
72
+ // #3873 (ADR-3473 §8.8): FRONTMATTER_KEY_TO_BODY_LABEL below is now a
73
+ // projection of this leaf schema rather than a hand-maintained literal.
74
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
75
+ const stateMdSchemaMod = require("./state-md-schema.cjs");
48
76
  // #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only
49
77
  // node builtins, so it introduces no cycle on this path.
50
78
  const project_root_cjs_1 = require("./project-root.cjs");
@@ -53,7 +81,13 @@ const project_root_cjs_1 = require("./project-root.cjs");
53
81
  // it introduces no cycle on this path.
54
82
  // eslint-disable-next-line @typescript-eslint/no-require-imports
55
83
  const milestoneLockMod = require("./milestone-lock.cjs");
56
- const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
84
+ const { transitionCore, applyStatePreservation, sliceCurrentPositionSection, stateReplaceProgressPercent, formatProgressMachineSegment } = stateTransitionMod;
85
+ // #3699: the frontmatter-key <-> body-field routing behind `state update`'s
86
+ // failure explanation, and the classification table it falls back to.
87
+ const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
88
+ // ADR-3473 §8.7 (#3872): the declared dotted-leaf enumeration `reconcileReportedFields`
89
+ // diffs against — see `declaredLeavesOf` below.
90
+ const { FIELD_CLASSIFICATION } = stateTransitionMod;
57
91
  const state_document_cjs_1 = require("./state-document.cjs");
58
92
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
59
93
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
@@ -233,7 +267,7 @@ function cmdStateLoad(cwd, raw) {
233
267
  `state_exists=${stateExists}`,
234
268
  ];
235
269
  process.stdout.write(lines.join('\n'));
236
- process.exit(0);
270
+ throw new ExitError(0);
237
271
  }
238
272
  output(result, false, undefined);
239
273
  }
@@ -312,14 +346,15 @@ function cmdStatePatch(cwd, patches, raw) {
312
346
  // delta (table-driven) that this phase adds. Field-name validation (security)
313
347
  // and the resync-progress decision stay in this adapter.
314
348
  let precomputed = { updated: [], failed: [] };
315
- let preSyncContent = '';
316
349
  const divergedFields = [];
350
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
351
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
352
+ const preWriteState = {};
317
353
  readModifyWriteStateMd(statePath, (content) => {
318
354
  const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock });
319
355
  precomputed = result.data ?? precomputed;
320
- preSyncContent = result.content;
321
356
  return result.content;
322
- }, cwd, { resync: shouldResync, divergedFields });
357
+ }, cwd, { resync: shouldResync, divergedFields, explicitProgressField: shouldResync, preWriteState });
323
358
  // ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
324
359
  // patchCore's bookkeeping says whether the stateReplaceField text-replace
325
360
  // MATCHED — but its plain-line pattern (`m` flag over the full document)
@@ -334,7 +369,7 @@ function cmdStatePatch(cwd, patches, raw) {
334
369
  // `applyStatePreservation` restored that this patch never named at all
335
370
  // (#3345's direction), a case the pre-#3471 version of this command never
336
371
  // covered.
337
- const updated = reconcileReportedFields(statePath, preSyncContent, precomputed.updated, divergedFields);
372
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields);
338
373
  const updatedSet = new Set(updated);
339
374
  const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
340
375
  const results = { updated, failed };
@@ -344,6 +379,53 @@ function cmdStatePatch(cwd, patches, raw) {
344
379
  error('STATE.md not found');
345
380
  }
346
381
  }
382
+ /**
383
+ * Why did `state update <field>` not write anything?
384
+ *
385
+ * #3699: this used to be one sentence — `Field "X" not found in STATE.md` — for
386
+ * every falsy outcome, so a PRESENT-but-derived frontmatter key and a genuinely
387
+ * absent field produced byte-identical output apart from the name. The classifier
388
+ * already knew the difference; the message threw it away, and worse, pointed away
389
+ * from the route that works.
390
+ *
391
+ * Four distinct answers, because there are four distinct situations:
392
+ * 1. a body-derived frontmatter key whose body source EXISTS → name that source
393
+ * 2. a frontmatter key with no body source at all (disk/external/clock-derived)
394
+ * → say what derives it, and do not invent a body field to blame
395
+ * 3. a body field that feeds a frontmatter key still carrying a value
396
+ * → name the key, so case D is diagnosable rather than a bare absence
397
+ * 4. genuinely unknown → unchanged
398
+ */
399
+ function explainUpdateFailure(field) {
400
+ const bodySource = getFrontmatterBodySource(field);
401
+ if (bodySource) {
402
+ // (1) The fallback in `updateCore` did not fire, so a body source line
403
+ // exists — that is the writable route.
404
+ const [primary] = bodySource;
405
+ return `Field "${field}" is a body-derived frontmatter key and is not directly writable. `
406
+ + `Update its body source instead: state update "${primary}" <value>.`;
407
+ }
408
+ const classification = getFieldClassification(field);
409
+ if (classification) {
410
+ // (2) Known key, no body source: disk/external/free-derived.
411
+ const derivedFrom = {
412
+ disk: 'derived from a scan of .planning/phases/ and is not directly writable',
413
+ external: 'derived from ROADMAP.md and is not directly writable',
414
+ free: 'recomputed on every write and is not directly writable',
415
+ curated: 'maintained by the write path and is not directly writable through this command',
416
+ body: 'body-derived and is not directly writable',
417
+ };
418
+ return `Field "${field}" is a frontmatter key that is ${derivedFrom[classification.source]}.`;
419
+ }
420
+ const owningKey = frontmatterKeyForBodyField(field);
421
+ if (owningKey) {
422
+ // (3) Case D from the body-field side.
423
+ return `Field "${field}" not found in STATE.md. It is the body source for frontmatter key `
424
+ + `"${owningKey}" — add the "${field}:" line to the body, or update "${owningKey}" directly `
425
+ + `to repair a document whose body source is missing.`;
426
+ }
427
+ return `Field "${field}" not found in STATE.md`; // (4) genuinely unknown
428
+ }
347
429
  function cmdStateUpdate(cwd, field, value) {
348
430
  if (!field || value === undefined) {
349
431
  error('field and value required for state update');
@@ -358,7 +440,30 @@ function cmdStateUpdate(cwd, field, value) {
358
440
  const statePath = planningPaths(cwd).state;
359
441
  try {
360
442
  let updated = false;
361
- let preSyncContent = '';
443
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
444
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
445
+ const preWriteState = {};
446
+ let transitionData;
447
+ // #3699 case D: when `updateCore` falls back to writing the frontmatter key
448
+ // directly, the value must survive the post-sync pass — otherwise the write
449
+ // is silently undone. `buildStateFrontmatter` re-derives `stopped_at` from
450
+ // the body, finds no source, and emits nothing; `applyPreserveWhenUnchanged`
451
+ // then sees an unchanged (absent) body source and restores the PRE-write
452
+ // snapshot over the value just written. Verified: without this the command
453
+ // reported `updated:false` with `preserved:["Stopped At"]` and the old value
454
+ // stood.
455
+ //
456
+ // `authoritativeFm` is the seam built for exactly this (#2736 — "intent-first
457
+ // frontmatter values … so the lossy body-prose re-derivation can never
458
+ // destroy information the transition just resolved"), and it is re-applied
459
+ // AFTER preservation (`applyPostSyncPreservation`), so it wins the restore.
460
+ //
461
+ // Populated by the transform below rather than up front, because only the
462
+ // transition knows whether the fallback fired. Safe: `readModifyWriteStateMd`
463
+ // dereferences `options.authoritativeFm` after running the transform. Left
464
+ // empty when the fallback does not fire — an empty object iterates zero
465
+ // entries and is a no-op at both application sites.
466
+ const authoritativeFm = {};
362
467
  const divergedFields = [];
363
468
  const shouldResync = shouldResyncStateProgress([field]);
364
469
  // ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
@@ -370,9 +475,12 @@ function cmdStateUpdate(cwd, field, value) {
370
475
  readModifyWriteStateMd(statePath, (content) => {
371
476
  const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock });
372
477
  updated = result.data?.updated === true;
373
- preSyncContent = result.content;
478
+ transitionData = result.data;
479
+ if (transitionData?.wroteFrontmatter === true) {
480
+ authoritativeFm[field] = value;
481
+ }
374
482
  return result.content;
375
- }, cwd, { resync: shouldResync, divergedFields });
483
+ }, cwd, { resync: shouldResync, divergedFields, authoritativeFm, explicitProgressField: shouldResync, preWriteState });
376
484
  // ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
377
485
  // `updateCore`'s own match does not know whether sync/preservation later
378
486
  // discarded the value it wrote (#3351's direction, generalized from
@@ -380,14 +488,37 @@ function cmdStateUpdate(cwd, field, value) {
380
488
  // restored during this write that this command never touched at all
381
489
  // (#3345's direction) — reported separately from `updated` because this
382
490
  // command's contract is a single-field boolean, not a per-field array.
383
- const reconciled = reconcileReportedFields(statePath, preSyncContent, updated ? [field] : [], divergedFields);
491
+ const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
384
492
  updated = reconciled.includes(field);
385
493
  const preserved = reconciled.filter((f) => f !== field);
386
494
  if (updated) {
387
- output({ updated: true, preserved }, false, undefined);
495
+ // #3699 case D: surfaced so a caller can tell "wrote the body source" from
496
+ // "wrote the frontmatter key because no body source existed" — the second
497
+ // is a repair, and silently reporting it as an ordinary update is the same
498
+ // class of unfalsifiable success this issue is about.
499
+ const wroteFrontmatter = transitionData?.wroteFrontmatter === true;
500
+ if (!wroteFrontmatter) {
501
+ output({ updated: true, preserved }, false, undefined);
502
+ }
503
+ else {
504
+ // `preserved` reports the BODY LABEL of each field preservation restored
505
+ // (`bodyLabelFor`, the #3345 direction). In the case-D fallback that
506
+ // reading is stale by one step: preservation DID restore this field's
507
+ // snapshot, and `authoritativeFm` then overrode it, so the value on disk
508
+ // is the one just written. Reporting it as preserved would claim a
509
+ // restore that did not survive — the same unfalsifiable-success shape
510
+ // #3699 is about, one field over. Drop this field's own labels; other
511
+ // fields' preservation is untouched and still reported.
512
+ const ownLabels = new Set((getFrontmatterBodySource(field) ?? []).map((l) => l.toLowerCase()));
513
+ output({
514
+ updated: true,
515
+ wrote: 'frontmatter',
516
+ preserved: preserved.filter((p) => !ownLabels.has(p.toLowerCase())),
517
+ }, false, undefined);
518
+ }
388
519
  }
389
520
  else {
390
- output({ updated: false, reason: `Field "${field}" not found in STATE.md`, preserved }, false, undefined);
521
+ output({ updated: false, reason: explainUpdateFailure(field), preserved }, false, undefined);
391
522
  }
392
523
  }
393
524
  catch {
@@ -395,6 +526,42 @@ function cmdStateUpdate(cwd, field, value) {
395
526
  }
396
527
  }
397
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
+ }
398
565
  /**
399
566
  * Replace a STATE.md field with fallback field name support.
400
567
  * Tries `primary` first, then `fallback` (if provided), returns content unchanged
@@ -416,6 +583,86 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
416
583
  `This may indicate STATE.md was externally modified or uses an unexpected format.\n`);
417
584
  return content;
418
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
+ }
419
666
  function cmdStateAdvancePlan(cwd, raw) {
420
667
  const statePath = planningPaths(cwd).state;
421
668
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -434,13 +681,25 @@ function cmdStateAdvancePlan(cwd, raw) {
434
681
  };
435
682
  let resultData;
436
683
  let precomputedUpdated = [];
437
- let preSyncContent = '';
438
684
  const divergedFields = [];
685
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
686
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
687
+ const preWriteState = {};
439
688
  // #3311: the milestone (phase + session) claim is consulted INSIDE the
440
689
  // STATE.md lock, so the position read and the claim read cannot interleave
441
690
  // with another session's Current Position write.
442
691
  let milestoneConflict = null;
443
- readModifyWriteStateMd(statePath, (content) => {
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 };
702
+ const wrote = readModifyWriteStateMd(statePath, (content) => {
444
703
  // advance-plan has no phase argument of its own — the phase it advances is
445
704
  // whatever ## Current Position names. Compare that against the milestone
446
705
  // claim: a mismatch means another session moved the single-slot position
@@ -449,6 +708,17 @@ function cmdStateAdvancePlan(cwd, raw) {
449
708
  const body = stripFrontmatter(content);
450
709
  const positionScope = matchCurrentPositionSection(body) ?? body;
451
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;
452
722
  if (positionPhase !== null) {
453
723
  milestoneConflict = milestoneLockMod.checkMilestonePosition(cwd, positionPhase);
454
724
  if (milestoneConflict) {
@@ -456,13 +726,128 @@ function cmdStateAdvancePlan(cwd, raw) {
456
726
  }
457
727
  }
458
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
+ }
459
752
  resultData = result.data;
460
753
  precomputedUpdated = result.updated;
461
- preSyncContent = result.content;
462
754
  return result.content;
463
- }, cwd, { divergedFields });
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.
464
776
  if (!resultData || resultData['error']) {
465
- output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
777
+ // #3807: a multi-`Phase:` Current Position section carries its own cause
778
+ // and its own remedy (name the candidates; the caller resolves them).
779
+ if (resultData && resultData['reason'] === 'ambiguous_position_phase') {
780
+ output({
781
+ error: 'Current Position section contains more than one Phase: entry — refusing to silently advance the first. Resolve the section to a single current entry and re-run.',
782
+ reason: resultData['reason'],
783
+ phase_candidates: resultData['phase_candidates'],
784
+ }, raw, undefined);
785
+ return;
786
+ }
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);
466
851
  return;
467
852
  }
468
853
  // ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
@@ -471,13 +856,25 @@ function cmdStateAdvancePlan(cwd, raw) {
471
856
  // Generalizes fix(#3351) (closes #3351's direction) and folds in any field
472
857
  // preservation restored that this transform never touched (#3345's
473
858
  // direction).
474
- const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
859
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
475
860
  if (resultData['advanced'] === false) {
476
861
  output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'false');
477
862
  }
478
863
  else {
479
864
  output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
480
865
  }
866
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): a refreshed
867
+ // state.json `updated_at` must always mean something on disk actually
868
+ // moved, in EITHER branch above — so gate on `wrote`
869
+ // (readModifyWriteStateMd's own return value) rather than assuming both
870
+ // branches are unconditional mutations. They are not: re-running
871
+ // advance-plan on a phase already parked in its post-advance state (e.g.
872
+ // two same-day calls once a phase is "ready for verification") reproduces
873
+ // byte-identical content, the #948 no-op guard skips the write, and
874
+ // `resultData`/`updated` still populate normally from the transform's OWN
875
+ // (unwritten) output — so those are not safe publish signals here either.
876
+ if (wrote)
877
+ publishStateContract(cwd);
481
878
  }
482
879
  function cmdStateRecordMetric(cwd, options, raw) {
483
880
  const statePath = planningPaths(cwd).state;
@@ -655,7 +1052,7 @@ function computeUpdateProgressPreview(statePath, cwd) {
655
1052
  const existingFm = extractFrontmatter(preContent, statePath);
656
1053
  const preBody = stripFrontmatter(preContent);
657
1054
  const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
658
- const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
1055
+ const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
659
1056
  const progress = builtFm['progress'];
660
1057
  const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
661
1058
  const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
@@ -696,7 +1093,39 @@ function cmdStateUpdateProgress(cwd, raw) {
696
1093
  // excluded sentinels, unlike the owner). The owner already handles an
697
1094
  // absent phasesDir as a real empty, so the fs.existsSync guard folds
698
1095
  // into it.
699
- 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
+ });
700
1129
  phaseScope = scope;
701
1130
  for (const dir of phaseDirs) {
702
1131
  const { planCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
@@ -713,10 +1142,10 @@ function cmdStateUpdateProgress(cwd, raw) {
713
1142
  // never read, and STATE.md's Progress field goes stale with no
714
1143
  // user-visible signal beyond it. Mirrors the established
715
1144
  // `[gsd-tools] WARNING:` stderr convention this file already uses
716
- // (stateReplaceFieldWithFallback above) for a comparable silent no-op.
717
- process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — phase scope is ${phaseScope}, not complete. ` +
718
- `STATE.md's Progress field was left unchanged.\n`);
719
- 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.`);
720
1149
  return;
721
1150
  }
722
1151
  // #3233: zero plans in the current-milestone phases means there is nothing to
@@ -728,9 +1157,7 @@ function cmdStateUpdateProgress(cwd, raw) {
728
1157
  // ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist,
729
1158
  // none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0.
730
1159
  if (totalPlans === 0) {
731
- process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — no plans found in current-milestone phases (0 plans). ` +
732
- `STATE.md's Progress field was left unchanged (milestone archived?).\n`);
733
- 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?).`);
734
1161
  return;
735
1162
  }
736
1163
  // #3583: percent AND the completed/total counts reported alongside it both
@@ -741,48 +1168,30 @@ function cmdStateUpdateProgress(cwd, raw) {
741
1168
  // disagrees with its own completed/total.
742
1169
  const preview = computeUpdateProgressPreview(statePath, cwd);
743
1170
  if (preview.withheld) {
744
- process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — ${preview.reason}\n`);
745
- output({ updated: false, reason: preview.reason }, raw, 'false');
1171
+ declineNoOp(raw, 'updated', preview.reason, `state update-progress skipped — ${preview.reason}`);
746
1172
  return;
747
1173
  }
748
1174
  const { percent, completedPlans: fmCompletedPlans, totalPlans: fmTotalPlans } = preview;
749
- const barWidth = 10;
750
- const filled = Math.round(percent / 100 * barWidth);
751
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
752
- const progressStr = `[${bar}] ${percent}%`;
1175
+ const progressStr = formatProgressMachineSegment(percent);
753
1176
  let updated = false;
754
1177
  readModifyWriteStateMd(statePath, (content) => {
755
- // #2177: match against the BODY only. With /i the patterns below would
756
- // otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would
757
- // eat its newline, mangling the nested block), while the body Progress: line
758
- // — which frontmatter `percent` is re-derived from on every write — stays
759
- // stale and silently reverts the update.
760
- const body = stripFrontmatter(content);
761
- const fmPrefix = content.slice(0, content.length - body.length);
762
- // Swap only the machine segment ("[bar] NN%" or bare "NN%"), preserving any
763
- // descriptive suffix an agent authored, e.g. "(2/4 plans done; blocked on…)".
764
- const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
765
- const replaceValue = (value) => machineSegment.test(value)
766
- ? value.replace(machineSegment, progressStr)
767
- : progressStr;
768
- // Try **Progress:** bold format first, then plain Progress: format.
769
- const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
770
- const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
771
- const pattern = boldProgressPattern.test(body)
772
- ? boldProgressPattern
773
- : plainProgressPattern.test(body)
774
- ? plainProgressPattern
775
- : null;
776
- if (!pattern)
1178
+ const result = stateReplaceProgressPercent(content, percent);
1179
+ if (result === null)
777
1180
  return content;
778
1181
  updated = true;
779
- return fmPrefix + body.replace(pattern, (_match, prefix, value) => `${prefix}${replaceValue(value)}`);
1182
+ return result;
780
1183
  }, cwd);
781
1184
  if (updated) {
782
1185
  output({ updated: true, percent, completed: fmCompletedPlans, total: fmTotalPlans, bar: progressStr }, raw, progressStr);
783
1186
  }
784
1187
  else {
785
- 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 });
786
1195
  }
787
1196
  }
788
1197
  function cmdStateAddDecision(cwd, options, raw) {
@@ -1088,7 +1497,15 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1088
1497
  output({ error: 'text required' }, raw, undefined);
1089
1498
  return;
1090
1499
  }
1091
- 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;
1092
1509
  readModifyWriteStateMd(statePath, (content) => {
1093
1510
  // ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3.
1094
1511
  // Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
@@ -1096,6 +1513,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1096
1513
  const i = hs.findIndex(h => (h.level === 2 || h.level === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(h.text));
1097
1514
  if (i === -1)
1098
1515
  return content;
1516
+ sectionFound = true;
1099
1517
  const h = hs[i];
1100
1518
  const ls = content.split('\n');
1101
1519
  const hl = ls[h.line - 1];
@@ -1112,21 +1530,33 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1112
1530
  const filtered = lines.filter(line => {
1113
1531
  if (!line.startsWith('- '))
1114
1532
  return true;
1115
- 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;
1116
1539
  });
1540
+ if (!matched)
1541
+ return content;
1117
1542
  let newBody = filtered.join('\n');
1118
1543
  // If section is now empty, add placeholder
1119
1544
  if (!newBody.trim() || !newBody.includes('- ')) {
1120
1545
  newBody = 'None\n';
1121
1546
  }
1122
- resolved = true;
1123
1547
  return content.slice(0, bs) + newBody + content.slice(se);
1124
1548
  }, cwd);
1125
- if (resolved) {
1549
+ if (matched) {
1126
1550
  output({ resolved: true, blocker: text }, raw, 'true');
1127
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
+ }
1128
1555
  else {
1129
- 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.`);
1130
1560
  }
1131
1561
  }
1132
1562
  function cmdStateRecordSession(cwd, options, raw) {
@@ -1138,8 +1568,10 @@ function cmdStateRecordSession(cwd, options, raw) {
1138
1568
  const now = clock_cjs_1.realClock.nowIso();
1139
1569
  const updated = [];
1140
1570
  let sessionCreated = false;
1141
- let preSyncContent = '';
1142
1571
  const divergedFields = [];
1572
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
1573
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
1574
+ const preWriteState = {};
1143
1575
  readModifyWriteStateMd(statePath, (content) => {
1144
1576
  // Update Last session / Last Date
1145
1577
  let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
@@ -1353,22 +1785,35 @@ function cmdStateRecordSession(cwd, options, raw) {
1353
1785
  updated.push('Resume File');
1354
1786
  }
1355
1787
  }
1356
- preSyncContent = content;
1357
1788
  return content;
1358
- }, cwd, { divergedFields });
1789
+ }, cwd, { divergedFields, preWriteState });
1359
1790
  // ADR-3408 §8.4 (D4): reconcile this command's own success list against the
1360
1791
  // bytes actually persisted (fix(#3351) generalized) and fold in any field
1361
1792
  // preservation restored that this transform never touched (#3345's
1362
1793
  // direction).
1363
- const reconciledUpdated = reconcileReportedFields(statePath, preSyncContent, updated, divergedFields);
1794
+ const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields);
1364
1795
  if (reconciledUpdated.length > 0) {
1365
1796
  const result = { recorded: true, updated: reconciledUpdated };
1366
1797
  if (sessionCreated)
1367
1798
  result['created'] = true;
1368
1799
  output(result, raw, 'true');
1369
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
+ }
1370
1807
  else {
1371
- 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.');
1372
1817
  }
1373
1818
  }
1374
1819
  /**
@@ -1670,7 +2115,56 @@ function cmdStateSnapshot(cwd, raw) {
1670
2115
  // ROADMAP phase token against an on-disk phase directory — moved to the
1671
2116
  // phase-id owner module in #2562 so every consumer derives BOTH sides of a
1672
2117
  // phase comparison from the same function (see phase-id.cts). Imported at the
1673
- // 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
+ }
1674
2168
  /**
1675
2169
  * Extract the set of retired/folded phase keys from a ROADMAP milestone scope
1676
2170
  * (#1514). A retired phase is struck through with GFM strikethrough,
@@ -1692,16 +2186,31 @@ function cmdStateSnapshot(cwd, raw) {
1692
2186
  * decimal, and project-code IDs are detected alike. Returns canonical keys
1693
2187
  * (see phaseKeyFromToken).
1694
2188
  */
1695
- function extractRetiredPhaseNumbers(scope) {
2189
+ function extractRetiredPhaseNumbers(scope, convention) {
1696
2190
  const retired = new Set();
1697
2191
  const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
1698
- 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/)) {
1699
2208
  if (!isChecklistOrHeading.test(line))
1700
2209
  continue;
1701
2210
  const strikeSpan = /~~([^~]*?)~~/g;
1702
2211
  let s;
1703
2212
  while ((s = strikeSpan.exec(line)) !== null) {
1704
- const phaseRef = /^[\s*_]*Phase\s+([\w][\w.-]*)/i.exec(s[1]);
2213
+ const phaseRef = phaseRefRe.exec(s[1]);
1705
2214
  // Require a digit so struck prose like ~~Phase Overview~~ is ignored.
1706
2215
  if (phaseRef && /\d/.test(phaseRef[1]))
1707
2216
  retired.add(phaseKeyFromToken(phaseRef[1]));
@@ -1709,12 +2218,110 @@ function extractRetiredPhaseNumbers(scope) {
1709
2218
  }
1710
2219
  return retired;
1711
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
+ }
1712
2313
  /**
1713
2314
  * Extract machine-readable fields from STATE.md markdown body and build
1714
2315
  * a YAML frontmatter object. Allows hooks and scripts to read state
1715
2316
  * reliably via `state json` instead of fragile regex parsing.
1716
2317
  */
1717
- 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) {
1718
2325
  // #2956: scope `Phase` extraction to ## Current Position (mirrors the read
1719
2326
  // path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
1720
2327
  // below). Phase canonically lives in ## Current Position (templates/state.md);
@@ -1804,6 +2411,10 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1804
2411
  // from the pre-existing frontmatter fields parsed above, a path this phase
1805
2412
  // does not touch and which predates listMilestonePhaseDirs entirely.
1806
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;
1807
2418
  if (cwd) {
1808
2419
  try {
1809
2420
  const phasesDir = planningPaths(cwd).phases;
@@ -1824,7 +2435,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1824
2435
  roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1825
2436
  if (roadmapRaw !== null) {
1826
2437
  roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1827
- retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
2438
+ retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention);
1828
2439
  }
1829
2440
  }
1830
2441
  catch { /* fall through: no roadmap scope → no retired exclusion */ }
@@ -1835,7 +2446,11 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1835
2446
  // CURRENT (stored) milestone" — routed through the canonical owner
1836
2447
  // instead of a hand-rolled readdirSync + isDirInMilestone filter
1837
2448
  // (which also never excluded sentinels, unlike the owner).
1838
- 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
+ });
1839
2454
  // Bug #2445: when stale phase dirs from a prior milestone remain in
1840
2455
  // .planning/phases/ alongside new dirs with the same phase number,
1841
2456
  // de-duplicate by normalized phase number keeping exactly one dir
@@ -1847,7 +2462,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1847
2462
  // artifact; drop it from the disk phase set so it counts toward
1848
2463
  // neither the denominator nor the numerator (mirrors the heading
1849
2464
  // exclusion below). Project-code-aware via phaseKeyFromDir.
1850
- if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
2465
+ if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
1851
2466
  continue;
1852
2467
  // #3185: dedup grouping routed through the canonical phaseKeyFromDir
1853
2468
  // (src/phase-id.cts) instead of a local leading-digits regex that
@@ -1856,7 +2471,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1856
2471
  // so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
1857
2472
  // multi-segment milestone dirs. Same key surface used two lines
1858
2473
  // above for the retiredPhaseNums exclusion, so both filters agree.
1859
- const key = phaseKeyFromDir(dir);
2474
+ const key = phaseKeyFromDir(dir, phaseConvention);
1860
2475
  if (!seenPhaseNums.has(key)) {
1861
2476
  seenPhaseNums.set(key, dir);
1862
2477
  }
@@ -1900,31 +2515,15 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1900
2515
  if (isPhaseComplete(phaseDir).value.complete)
1901
2516
  diskCompletedPhases++;
1902
2517
  }
1903
- // Count phase headings from ROADMAP using a digit-containing pattern
1904
- // that matches both numeric phases (01, 05.1) and project-code phases
1905
- // (PROJ-42, CK-05) but excludes pure-word section headers like
1906
- // `## Phase Overview:` or `## Phase Details:` — single source of
1907
- // truth for total_phases (#549).
1908
- let roadmapPhaseCount = 0;
1909
- if (roadmapScope !== null) {
1910
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1911
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
1912
- let m;
1913
- while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
1914
- // Only count tokens that contain at least one digit — excludes
1915
- // pure-word section headings (Overview, Details) while keeping
1916
- // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
1917
- // Also exclude sentinel phases (0 and 999.x backlog).
1918
- // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1919
- if (!/\d/.test(m[1]) || isSentinelPhaseId(m[1]))
1920
- continue;
1921
- // #1514: retired/folded phases are struck through in the ROADMAP;
1922
- // exclude them from the denominator (they can never be completed).
1923
- if (retiredPhaseNums.has(phaseKeyFromToken(m[1])))
1924
- continue;
1925
- roadmapPhaseCount++;
1926
- }
1927
- }
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;
1928
2527
  cached = (() => {
1929
2528
  // #1761 read-path: mirror the cmdStateSync guard (#1794). When the
1930
2529
  // asserted milestone version can't be bounded to a versioned ROADMAP
@@ -1949,7 +2548,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1949
2548
  // the prior inline regex had no boundary assertion after the
1950
2549
  // version token, so `v2.0` matched inside `v2.0.1` (#2562-class
1951
2550
  // defect, design row 17).
1952
- milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(assertedMilestoneVersion).trim());
2551
+ milestoneBounded = isMilestoneBounded(roadmapRaw, String(assertedMilestoneVersion).trim(), phaseConvention);
1953
2552
  }
1954
2553
  // #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
1955
2554
  // at all — only Phase headings) from a MILESTONED-but-unbounded one
@@ -1961,10 +2560,19 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1961
2560
  // deliberately weaker than isMilestoneBoundedInRoadmap above (no
1962
2561
  // version-token requirement); see hasMilestoneSectioning's own
1963
2562
  // doc comment for why that distinction is load-bearing.
1964
- const roadmapHasMilestoneSectioning = roadmapRaw !== null
1965
- && hasMilestoneSectioning(roadmapRaw);
2563
+ // #3642: the flat test uses the >=1 sibling (hasAnyMilestoneSection),
2564
+ // not the >=2 predicate. >=2 under-answers the question this branch
2565
+ // asks: with EXACTLY ONE milestone section and an asserted milestone
2566
+ // absent from the ROADMAP, >=2 read "flat" and the whole-document
2567
+ // count — which IS that single section's phases — was written as the
2568
+ // asserted milestone's total, silently clobbering the stored value.
2569
+ // The >=2 threshold governs SIBLING conflation; asserted-vs-section
2570
+ // needs only one section to go wrong. Zero sections (genuinely flat)
2571
+ // keeps the whole-document count, per #2828.
2572
+ const roadmapHasAnyMilestoneSection = roadmapRaw !== null
2573
+ && hasAnyMilestoneSection(roadmapRaw);
1966
2574
  const safeToUseRoadmapCount = milestoneBounded
1967
- || (roadmapPhaseCount > 0 && !roadmapHasMilestoneSectioning);
2575
+ || (roadmapPhaseCount > 0 && !roadmapHasAnyMilestoneSection);
1968
2576
  // #3354: the milestoned-but-unbounded sibling of the #2828/#3204
1969
2577
  // shapes. The whole-document roadmapPhaseCount is rightly rejected
1970
2578
  // above (it would conflate sibling milestones, #1761), but the
@@ -1980,9 +2588,9 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1980
2588
  // The degenerate un-sectioned zero-heading case keeps the
1981
2589
  // phaseDirs.length fallback — with nothing declared anywhere else,
1982
2590
  // the disk count is the only source and remains correct.
1983
- const milestonedButUnbounded = !milestoneBounded && roadmapHasMilestoneSectioning;
2591
+ const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
1984
2592
  if (milestonedButUnbounded) {
1985
- process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries multiple milestone sections; the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3354)\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`);
1986
2594
  }
1987
2595
  // #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
1988
2596
  // absent/unreadable the #549 heading counter never ran (roadmapScope
@@ -1999,21 +2607,31 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1999
2607
  typeof storedMilestone === 'string' &&
2000
2608
  storedMilestone.trim() !== '';
2001
2609
  if (roadmapAbsentWithAssertedMilestone) {
2002
- 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`);
2003
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;
2004
2622
  return {
2005
2623
  // The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
2006
2624
  // roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
2007
2625
  // safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
2008
2626
  // vacuously true (its gate requires roadmapRaw), so the safe-count
2009
2627
  // arm would otherwise swallow the withhold.
2010
- totalPhases: (milestonedButUnbounded || roadmapAbsentWithAssertedMilestone)
2628
+ totalPhases: diskCountsWithheld
2011
2629
  ? null
2012
2630
  : (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
2013
2631
  milestoneBounded,
2014
- completedPhases: diskCompletedPhases,
2015
- totalPlans: diskTotalPlans,
2016
- completedPlans: diskTotalSummaries,
2632
+ completedPhases: diskCountsWithheld ? null : diskCompletedPhases,
2633
+ totalPlans: diskCountsWithheld ? null : diskTotalPlans,
2634
+ completedPlans: diskCountsWithheld ? null : diskTotalSummaries,
2017
2635
  phaseDirScope,
2018
2636
  };
2019
2637
  })();
@@ -2031,9 +2649,31 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
2031
2649
  else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
2032
2650
  totalPhases = storedTotalPhases;
2033
2651
  }
2034
- completedPhases = cached.completedPhases;
2035
- totalPlans = cached.totalPlans;
2036
- 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
+ }
2037
2677
  milestoneUnbounded = cached.milestoneBounded === false;
2038
2678
  diskScope = cached.phaseDirScope;
2039
2679
  }
@@ -2325,12 +2965,21 @@ function readStateHeadFreshness(cwd, stateHead) {
2325
2965
  * instead of being clobbered by the on-disk phase-directory count.
2326
2966
  */
2327
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) {
2328
2977
  if (!existingFm || typeof existingFm !== 'object')
2329
2978
  return null;
2330
2979
  const progress = existingFm['progress'];
2331
2980
  if (!progress || typeof progress !== 'object')
2332
2981
  return null;
2333
- const raw = progress['total_phases'];
2982
+ const raw = progress[key];
2334
2983
  if (raw === null || raw === undefined)
2335
2984
  return null;
2336
2985
  if (typeof raw === 'string' && raw.trim() === '')
@@ -2338,12 +2987,43 @@ function readStoredTotalPhases(existingFm) {
2338
2987
  const n = Number(raw);
2339
2988
  return Number.isFinite(n) ? n : null;
2340
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
+ }
2341
2999
  function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
2342
3000
  // Read existing frontmatter BEFORE stripping — it may contain values
2343
3001
  // that the body no longer has (e.g., Status field removed by an agent).
2344
3002
  // `cwd` already identifies the workspace this content came from, so the STATE.md path is
2345
3003
  // derivable here without widening the signature (#1882).
2346
3004
  const existingFm = extractFrontmatter(content, cwd ? planningPaths(cwd).state : undefined);
3005
+ // #3881 review, second round: an UNPARSEABLE frontmatter block (malformed YAML, a git
3006
+ // merge-conflict marker, a refused anchor) must never be silently REPLACED by a freshly
3007
+ // re-derived one — that destroys the only copy of what the block actually contained, with
3008
+ // no signal to the human that their document was in conflict. `beginFrontmatterReassembly`
3009
+ // (state-transition.cts) already preserves the raw fmPrefix through the pure transform
3010
+ // layer for every `transitionCore` kind; this was the gap — this function re-parses the
3011
+ // ALREADY-preserved `content` and, finding {} + the marker, rebuilt a fresh block anyway,
3012
+ // discarding the raw prefix the transform layer had just protected. Confirmed by execution
3013
+ // against `state complete-phase`/`update`/`patch`/`begin-phase`: each returned success with
3014
+ // the conflict markers gone and a freshly-derived, well-formed frontmatter block in their
3015
+ // place (re-derivation, not deletion — the document never lost its frontmatter FENCE).
3016
+ //
3017
+ // `sanctionedPermanentEmptyFallback` is threaded ONLY from `writeStateMd`, itself consumed
3018
+ // ONLY by `cmdStateSync` (#905) and `/gsd-health --repair`'s `REGENERATE_STATE` — ADR-3408
3019
+ // §8.3's CLOSED list of commands whose documented contract is "body wins, re-derive
3020
+ // unconditionally" (a factory reset / explicit resync). Those two are untouched here: this
3021
+ // guard fires only on the OTHER call path (`syncAndPreserveStateMd`, i.e. every
3022
+ // `readModifyWriteStateMd`-based command), where re-deriving over unparseable content was
3023
+ // never the intended contract in the first place — it was an unhandled gap, not a decision.
3024
+ if (!sanctionedPermanentEmptyFallback && isUnparseableFrontmatter(existingFm)) {
3025
+ return content;
3026
+ }
2347
3027
  const body = stripFrontmatter(content);
2348
3028
  // #3017: pass the stored milestone from the existing frontmatter so
2349
3029
  // buildStateFrontmatter scopes its disk scan to the correct milestone
@@ -2353,7 +3033,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
2353
3033
  // milestoned-but-unbounded withhold can preserve it across the write
2354
3034
  // (the derived progress sub-block replaces the stored one wholesale below,
2355
3035
  // so an omitted key would otherwise DELETE the stored value).
2356
- const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
3036
+ const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
2357
3037
  // Preserve existing frontmatter status when body-derived status is 'unknown'.
2358
3038
  // This prevents a missing Status: field in the body from overwriting a
2359
3039
  // previously valid status (e.g., 'executing' → 'unknown').
@@ -2531,7 +3211,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
2531
3211
  // #3257: propagate full-line frontmatter comments from the extracted source onto the
2532
3212
  // rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both
2533
3213
  // skip the Symbol-keyed channel, so without this the comments would be lost here even
2534
- // though parseYamlRegion/reconstructFrontmatter preserve them in isolation).
3214
+ // though parseGuardedYamlRegion/reconstructFrontmatter preserve them in isolation).
2535
3215
  propagateCommentChannel(existingFm, derivedFm);
2536
3216
  const yamlStr = reconstructFrontmatter(derivedFm);
2537
3217
  return `---\n${yamlStr}\n---\n\n${body}`;
@@ -2783,7 +3463,23 @@ function withStateLock(statePath, fn) {
2783
3463
  * @param clock
2784
3464
  * Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
2785
3465
  */
2786
- function writeStateMd(statePath, content, cwd, clock) {
3466
+ /**
3467
+ * ADR-3473 §8.6: `writeStateMd` is ADR-3408 §8.3's sanctioned-exception write
3468
+ * path — only a `rebuildStateTransaction` may travel it. Enforced here rather
3469
+ * than left to caller discipline: the transaction TYPE is what makes the two
3470
+ * sanctioned exceptions (`cmdStateSync`, `REGENERATE_STATE`) greppable and
3471
+ * closed, and an `open()` transaction reaching this function would mean a
3472
+ * preservation-governed write silently skipped preservation.
3473
+ */
3474
+ function writeStateMd(statePath, content, transaction, cwd, clock) {
3475
+ if (transaction.kind !== 'rebuild') {
3476
+ const err = new Error(`writeStateMd: expected a 'rebuild' transaction, got '${transaction.kind}'. writeStateMd is ` +
3477
+ 'ADR-3408 §8.3\'s sanctioned-exception write path (cmdStateSync / REGENERATE_STATE only) — ' +
3478
+ 'only rebuildStateTransaction() may travel it (ADR-3473 §8.6). An open() transaction here ' +
3479
+ 'would silently skip preservation for a write that was supposed to run it.');
3480
+ err.code = 'STATE_TRANSACTION_KIND_INVALID';
3481
+ throw err;
3482
+ }
2787
3483
  const lockPath = acquireStateLock(statePath, clock);
2788
3484
  // Test seam (audit M8): fire AFTER the lock is taken so a test can simulate a
2789
3485
  // concurrent writer landing in the (now-closed) scan→lock window.
@@ -2805,10 +3501,14 @@ function writeStateMd(statePath, content, cwd, clock) {
2805
3501
  _diskScanCache.delete(cwd);
2806
3502
  // ADR-3408 §8.3: `writeStateMd` is the sole write path for the two
2807
3503
  // sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`) —
2808
- // pass `sanctionedPermanentEmptyFallback: true` so their long-standing
2809
- // empty-field fallback behavior stays byte-identical (see
2810
- // `syncStateFrontmatter`'s docstring above the guard block).
2811
- const synced = syncStateFrontmatter(content, cwd, undefined, true);
3504
+ // the sanctioned-permanent empty-field fallback is now DERIVED FROM THE
3505
+ // TRANSACTION KIND (ADR-3473 §8.6) rather than asserted by a literal
3506
+ // `true` at this call site: only a `rebuild` transaction can reach this
3507
+ // function (enforced above), so `transaction.kind === 'rebuild'` is
3508
+ // always `true` here today, but the derivation is what keeps the
3509
+ // fallback's scope tied to the transaction type rather than a
3510
+ // hard-coded constant that could silently drift from it.
3511
+ const synced = syncStateFrontmatter(content, cwd, undefined, transaction.kind === 'rebuild');
2812
3512
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
2813
3513
  }
2814
3514
  finally {
@@ -2863,10 +3563,7 @@ function assertStatePreservationOptions(options, caller) {
2863
3563
  }
2864
3564
  function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options) {
2865
3565
  assertStatePreservationOptions(options, 'applyPostSyncPreservation');
2866
- const { resync, authoritativeFm, deriveProgressKeys, divergedFields } = options;
2867
- // Snapshot the existing progress block BEFORE the transform so we can
2868
- // restore it when resync is false.
2869
- const preFm = resync ? null : extractFrontmatter(originalContent, statePath);
3566
+ const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField, preWriteState } = options;
2870
3567
  // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
2871
3568
  // we can detect whether THIS write changed them. syncStateFrontmatter
2872
3569
  // re-derives frontmatter status/stopped_at from the body on every write;
@@ -2877,8 +3574,24 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
2877
3574
  // above (null when resync:true) — these are independent snapshots.
2878
3575
  // Strip frontmatter before calling stateExtractField so the YAML `status:`
2879
3576
  // key in the frontmatter block cannot shadow the body field we are tracking.
2880
- const preBody = stripFrontmatter(originalContent);
2881
3577
  const preFmSnapshot = extractFrontmatter(originalContent, statePath);
3578
+ // #3881 review, second round: `syncStateFrontmatter` above already declines to re-derive
3579
+ // over an UNPARSEABLE original frontmatter block (its own matching guard), so `syncedContent`
3580
+ // here is `transformedContent` verbatim. But this function's own downstream preservation
3581
+ // machinery (`applyStatePreservation` + the `authoritativeFm` reassertion below) reads
3582
+ // `postFm = extractFrontmatter(syncedContent, ...)` — {} + the marker, since the block still
3583
+ // doesn't parse — restores curated fields from `transaction.snapshot`, and reconstructs a
3584
+ // FRESH frontmatter block from the result, destroying the raw block a second time even
3585
+ // though `syncStateFrontmatter` just finished protecting it. `applyPostSyncPreservation` is
3586
+ // reached ONLY via the non-sanctioned path (`syncAndPreserveStateMd`; `writeStateMd`'s two
3587
+ // ADR-3408 §8.3 closed-list callers — `cmdStateSync` #905 and `/gsd-health --repair`'s
3588
+ // `REGENERATE_STATE` — never call it at all), so this guard needs no extra parameter to stay
3589
+ // scoped off that list. Confirmed by execution: `state begin-phase` on a conflict-marked
3590
+ // STATE.md reached exactly this second clobber even after the `syncStateFrontmatter` fix.
3591
+ if (isUnparseableFrontmatter(preFmSnapshot)) {
3592
+ return transformedContent;
3593
+ }
3594
+ const preBody = stripFrontmatter(originalContent);
2882
3595
  const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
2883
3596
  // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
2884
3597
  // mirroring buildStateFrontmatter's sessionBodyScope logic.
@@ -2958,6 +3671,16 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
2958
3671
  status: { pre: preBodyStatus, post: postBodyStatus },
2959
3672
  stopped_at: { pre: preBodyStoppedAt, post: postBodyStoppedAt },
2960
3673
  current_phase_name: { pre: preBodyPhaseSource, post: postBodyPhaseSource },
3674
+ // ADR-3473 §8.7 (#3872): `last_activity` is the one `FRONTMATTER_BODY_SOURCE`
3675
+ // key that is NOT `preserve-when-unchanged` (it is `derive` — always
3676
+ // re-stamped from the body) and so was never part of this map before.
3677
+ // Added ONLY for `reconcileReportedFields`'s consumption below (via
3678
+ // `preWriteState.bodyDeltas`) — harmless here, since
3679
+ // `applyPreserveWhenUnchanged` is dispatched by
3680
+ // `getPreserveWhenUnchangedFields()`, never by iterating this object's
3681
+ // keys, so an extra non-preserve-when-unchanged entry changes no
3682
+ // preservation behavior.
3683
+ last_activity: { pre: preBodyLastActivityRaw, post: postBodyLastActivityRaw },
2961
3684
  };
2962
3685
  // ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
2963
3686
  // preservation block is now the pure, table-driven `applyStatePreservation`
@@ -2977,11 +3700,34 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
2977
3700
  // callers that omit it (readModifyWriteStateMd, cmdPhaseComplete) pay
2978
3701
  // nothing extra and see no change to `synced`/the returned content.
2979
3702
  const preservationInputSnapshot = divergedFields ? { ...postFm } : null;
2980
- const preservation = applyStatePreservation({
2981
- preFm, postFm, preFmSnapshot, resync,
3703
+ // ADR-3473 §8.6: the pre-write snapshot + policy flags now travel as ONE
3704
+ // transaction rather than as a nullable `preFm` alongside the always-present
3705
+ // `preFmSnapshot` (same source, same extractFrontmatter call — `preFm` was
3706
+ // `preFmSnapshot` with the `resync` policy baked in by nulling it, which is
3707
+ // what made `applyPreserveAlways` inert on the default resyncing write
3708
+ // path — #3756).
3709
+ const transaction = stateTransitionMod.openStateTransaction({
3710
+ snapshot: preFmSnapshot,
3711
+ resync,
2982
3712
  deriveProgressKeys: deriveProgressKeys === true,
2983
3713
  bodyDeltas,
3714
+ explicitProgressField: explicitProgressField === true,
2984
3715
  });
3716
+ // ADR-3473 §8.7 (#3872): fill the caller's out-param with the TRANSACTION'S
3717
+ // OWN snapshot object (not a second `extractFrontmatter(originalContent)`
3718
+ // derivation — `transaction.snapshot === preFmSnapshot`, reusing it is the
3719
+ // whole point) plus the pre-write body, so `reconcileReportedFields` can
3720
+ // diff persisted-vs-pre-write instead of re-deriving either side itself.
3721
+ if (preWriteState) {
3722
+ preWriteState.fm = transaction.snapshot;
3723
+ preWriteState.body = preBody;
3724
+ // ADR-3473 §8.7 (#3872): the pre/post body-source delta for every
3725
+ // FRONTMATTER_BODY_SOURCE key — see `StatePreWriteSnapshot`'s docstring
3726
+ // for why `reconcileReportedFields` needs this instead of a raw
3727
+ // frontmatter diff for these specific keys.
3728
+ preWriteState.bodyDeltas = bodyDeltas;
3729
+ }
3730
+ const preservation = applyStatePreservation({ transaction, postFm });
2985
3731
  if (divergedFields && preservationInputSnapshot) {
2986
3732
  // §8.5's "liberal but visible": every field whose value actually
2987
3733
  // differs before vs after `applyStatePreservation` is a field where the
@@ -2993,10 +3739,13 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
2993
3739
  for (const key of Object.keys(preservation.postFm)) {
2994
3740
  const before = preservationInputSnapshot[key];
2995
3741
  const after = preservation.postFm[key];
2996
- const changed = (typeof before === 'object' || typeof after === 'object')
2997
- ? JSON.stringify(before) !== JSON.stringify(after)
2998
- : before !== after;
2999
- if (changed)
3742
+ // ADR-3473 §8.7 (#3872 standards-axis finding): route through the ONE
3743
+ // owner of this comparison rule (`stateFieldValuesDiffer`, defined
3744
+ // below) instead of carrying a second inline `JSON.stringify`-vs-`!==`
3745
+ // copy — this is exactly the duplicated-rule shape this epic exists to
3746
+ // remove. `stateFieldValuesDiffer` is a function declaration (hoisted),
3747
+ // so calling it here, above its textual definition, is safe.
3748
+ if (stateFieldValuesDiffer(before, after))
3000
3749
  divergedFields.push(key);
3001
3750
  }
3002
3751
  // ADR-3408 §8.5 Row 2 (D1's actual bug, the reason the guards had to be
@@ -3042,12 +3791,36 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3042
3791
  }
3043
3792
  }
3044
3793
  }
3794
+ let finalContent = syncedContent;
3045
3795
  if (preservation.mutated || authoritativeReasserted) {
3796
+ // #3742: preservation RESTORES frontmatter keys the body-derived rebuild
3797
+ // could not produce (e.g. `current_phase` on a layout with no body
3798
+ // `**Current Phase:**` line) — but the comment channel was filtered
3799
+ // against the pre-restore key set during sync, so a full-line comment
3800
+ // attached to a restored key died with nothing to re-attach it. Propagate
3801
+ // the channel from the PRE-WRITE snapshot here, after the restores, so a
3802
+ // comment's survival depends on its key surviving the whole write — not
3803
+ // on which body line happened to feed the rebuild. Merge semantics
3804
+ // (propagateCommentChannel) keep any channel the synced content already
3805
+ // carried. No resync gate: this is the RMW path, where `resync` is the
3806
+ // DEFAULT (readModifyWriteStateMd derives it as `options.resync !==
3807
+ // false`) and preservation itself runs regardless — the factory-reset
3808
+ // semantic the #3742 review worried about lives in writeStateMd's
3809
+ // `rebuild` transactions, which never reach this branch.
3810
+ if (preFmSnapshot && !isUnparseableFrontmatter(preFmSnapshot)) {
3811
+ propagateCommentChannel(preFmSnapshot, preservation.postFm);
3812
+ }
3046
3813
  const yamlStr = reconstructFrontmatter(preservation.postFm);
3047
3814
  const body = stripFrontmatter(syncedContent);
3048
- 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;
3049
3822
  }
3050
- return syncedContent;
3823
+ return finalContent;
3051
3824
  }
3052
3825
  /**
3053
3826
  * ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
@@ -3127,6 +3900,12 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
3127
3900
  authoritativeFm: options?.authoritativeFm,
3128
3901
  deriveProgressKeys: options?.deriveProgressKeys === true,
3129
3902
  divergedFields: options?.divergedFields,
3903
+ explicitProgressField: options?.explicitProgressField === true,
3904
+ // ADR-3473 §8.7 (#3872): forwarded so `applyPostSyncPreservation` can
3905
+ // fill it — an unenumerated option here is silently dropped
3906
+ // (Phase 1's commit message; #3871), which is exactly how a prior cut
3907
+ // of this option would have gone missing.
3908
+ preWriteState: options?.preWriteState,
3130
3909
  });
3131
3910
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
3132
3911
  return true;
@@ -3158,15 +3937,36 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
3158
3937
  * exactly that closed, tested set (`tests/state.test.cjs` A2f pins
3159
3938
  * `divergedFields` reporting bare `'progress'`).
3160
3939
  */
3161
- const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze({
3162
- current_phase: 'Current Phase',
3163
- current_phase_name: 'Current Phase Name',
3164
- current_plan: 'Current Plan',
3165
- stopped_at: 'Stopped At',
3166
- paused_at: 'Paused At',
3167
- status: 'Status',
3168
- last_activity_desc: 'Last Activity Description',
3169
- });
3940
+ /**
3941
+ * #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
3942
+ * (`src/state-md-schema.cts`)'s `bodyLabel` field, in this EXPLICIT key
3943
+ * order — the pre-#3873 literal's own order, which puts `status` AFTER
3944
+ * `stopped_at`/`paused_at` (the opposite of `FRONTMATTER_BODY_SOURCE`'s order
3945
+ * in `state-transition.cts`; the two pre-existing tables disagreed with each
3946
+ * other's order too, so each projection reproduces its OWN table's order
3947
+ * rather than a shared derivation). Byte-identical to the pre-#3873 literal:
3948
+ * same 7 keys, same order, same frozen (NOT null-prototype — this table was
3949
+ * a plain `Object.freeze({...})` literal before #3873 and stays one) shape.
3950
+ * `last_activity` is deliberately excluded — see `STATE_FIELD_SCHEMA`'s
3951
+ * `last_activity` row docstring for the resolved disagreement. Pinned by
3952
+ * `tests/state.test.cjs`'s `bodyLabelProjectionMatchesTodaysTable` and
3953
+ * `lastActivityLabelResolutionMatchesShippedBehavior`.
3954
+ */
3955
+ const FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER = Object.freeze([
3956
+ 'current_phase',
3957
+ 'current_phase_name',
3958
+ 'current_plan',
3959
+ 'stopped_at',
3960
+ 'paused_at',
3961
+ 'status',
3962
+ 'last_activity_desc',
3963
+ ]);
3964
+ const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze(FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER.reduce((acc, key) => {
3965
+ const row = stateMdSchemaMod.STATE_FIELD_SCHEMA[key];
3966
+ if (row.bodyLabel !== undefined)
3967
+ acc[key] = row.bodyLabel;
3968
+ return acc;
3969
+ }, {}));
3170
3970
  /**
3171
3971
  * ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
3172
3972
  * entry. Throws for a `preserve-when-unchanged` field with no
@@ -3181,9 +3981,19 @@ const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze({
3181
3981
  * (e.g. `progress`), not a silent degrade.
3182
3982
  */
3183
3983
  function bodyLabelFor(field) {
3184
- const label = FRONTMATTER_KEY_TO_BODY_LABEL[field];
3185
- if (label !== undefined)
3186
- return label;
3984
+ // ADR-3473 §8.7 (#3872 review): an OWN-PROPERTY check, never a bare
3985
+ // bracket read — `FRONTMATTER_KEY_TO_BODY_LABEL` is a plain object literal
3986
+ // (real `Object.prototype` in its chain), so `[field]` for a hostile field
3987
+ // named `__proto__`/`constructor`/`toString` returns the INHERITED
3988
+ // prototype-chain member (`Object.prototype` itself, the `Object`
3989
+ // constructor function, `Object.prototype.toString`) instead of
3990
+ // `undefined` — which would then be returned as the "label" and leak a
3991
+ // non-string value into the caller's `updated` array. Proven by
3992
+ // `dottedResolutionDoesNotPollutePrototypes` (test matrix row 25) before
3993
+ // this fix. Mirrors `resolveFrontmatterPath`'s own-property discipline.
3994
+ if (Object.prototype.hasOwnProperty.call(FRONTMATTER_KEY_TO_BODY_LABEL, field)) {
3995
+ return FRONTMATTER_KEY_TO_BODY_LABEL[field];
3996
+ }
3187
3997
  const cls = stateTransitionMod.getFieldClassification(field);
3188
3998
  if (cls && cls.preservation === 'preserve-when-unchanged') {
3189
3999
  const err = new Error(`reconcileReportedFields: preserve-when-unchanged field ${JSON.stringify(field)} has no ` +
@@ -3196,98 +4006,343 @@ function bodyLabelFor(field) {
3196
4006
  return field;
3197
4007
  }
3198
4008
  /**
3199
- * ADR-3408 §8.4 (D4): shared persisted-bytes reconciliation, generalized
3200
- * from fix(#3351)'s `cmdStatePatch`-only version so every RMW-based command
3201
- * that reports a per-field `updated` array shares ONE comparison instead of
3202
- * re-deriving it per call site — duplicated policy is exactly what this
3203
- * epic exists to remove.
4009
+ * ADR-3473 §8.7 (issue #3872): the provenance exclusion — the ONLY
4010
+ * frontmatter key measured to change on EVERY write, regardless of content.
4011
+ * Verified at the CLI (`40-design.md` "Two corrections from reproducing it"):
4012
+ * two content-identical writes to a git-backed fixture differ in exactly
4013
+ * this one key. `state_head` was deliberately measured OUT of this set —
4014
+ * it restamps every write but its PERSISTED VALUE changes only when git HEAD
4015
+ * actually moved, so it tracks a real fact and does not flood.
4016
+ *
4017
+ * A CLOSED, ENUMERATED set — not a predicate or a callback (Greenspun's
4018
+ * Tenth Rule, ADR-3473 §8.7's Laws section: "the moment it takes a callback
4019
+ * it has become the classification table again under a new name"). It
4020
+ * exists to protect `src/state.cts:607` — `state.patch`'s ENTIRE
4021
+ * success/failure signal is `results.updated.length > 0` — admitting an
4022
+ * always-changing key here would make that boolean permanently `true`, so a
4023
+ * fully-failed patch would report success.
4024
+ */
4025
+ const STATE_UPDATED_PROVENANCE_EXCLUSION = Object.freeze(['last_updated']);
4026
+ /** Sentinel: "this dotted path did not resolve to any value" — distinct from every real value including `undefined`/`null`, so absence and an explicit null are never confused. */
4027
+ const STATE_FIELD_ABSENT = Symbol('state-field-absent');
4028
+ /**
4029
+ * ADR-3473 §8.7 (#3872): resolve `path` against a parsed frontmatter object.
4030
+ * Pure, never throws.
4031
+ *
4032
+ * Order is pinned (test matrix row 26, `literalDottedKeyResolvesBeforePathTraversal`):
4033
+ * a LITERAL flat key wins first — a field name that happens to contain a `.`
4034
+ * but is stored as one flat key must not be shadowed by path traversal —
4035
+ * and only when no literal key exists does `path` get split and walked as a
4036
+ * dotted path.
4037
+ *
4038
+ * Hostile-input rows (23-25 of the test matrix) all resolve to
4039
+ * `STATE_FIELD_ABSENT` rather than throwing: a missing parent, a scalar
4040
+ * parent (`typeof cursor !== 'object'`), and — the prototype-pollution
4041
+ * case — a `__proto__`/`constructor`/`toString` segment. The own-property
4042
+ * check (`Object.prototype.hasOwnProperty.call`, never a bare `in` or
4043
+ * bracket read) is what makes the last one safe: an inherited
4044
+ * `Object.prototype` member is never mistaken for an own data key, and
4045
+ * because this function only ever READS a segment (never assigns one),
4046
+ * no prototype can be polluted by walking it.
4047
+ */
4048
+ function resolveFrontmatterPath(fm, path) {
4049
+ if (Object.prototype.hasOwnProperty.call(fm, path))
4050
+ return fm[path];
4051
+ if (!path.includes('.'))
4052
+ return STATE_FIELD_ABSENT;
4053
+ let cursor = fm;
4054
+ for (const segment of path.split('.')) {
4055
+ if (typeof cursor !== 'object' || cursor === null || Array.isArray(cursor))
4056
+ return STATE_FIELD_ABSENT;
4057
+ if (!Object.prototype.hasOwnProperty.call(cursor, segment))
4058
+ return STATE_FIELD_ABSENT;
4059
+ cursor = cursor[segment];
4060
+ }
4061
+ return cursor;
4062
+ }
4063
+ /**
4064
+ * ADR-3473 §8.7 (#3872): representation-insensitive equality for a
4065
+ * persisted-vs-snapshot leaf value (test matrix rows 21/22). Frontmatter
4066
+ * scalars round-trip as STRINGS (`extractFrontmatter`, §8.1's open type
4067
+ * question) while an in-memory derivation can hold a real number or boolean
4068
+ * — a naive `!==` would report every numeric/boolean field changed on every
4069
+ * write. Mirrors the existing `divergedFields` diff's typeof-object branch
4070
+ * in `applyPostSyncPreservation` (JSON.stringify for objects, else a
4071
+ * normalized scalar compare) rather than inventing a second comparison.
4072
+ * Presence-vs-absence (`STATE_FIELD_ABSENT` on exactly one side) is always a
4073
+ * change — a deleted or newly-added key (test matrix rows 16/17) — never
4074
+ * folded into the scalar branch below it.
4075
+ */
4076
+ /**
4077
+ * ADR-3473 §8.7 (#3872): `String(v)` on an `unknown` is unsafe (a hostile
4078
+ * object could carry a custom, throwing, or `[object Object]`-degrading
4079
+ * `toString`) — narrowed per-branch here so each `String()` call below only
4080
+ * ever runs on a primitive TypeScript itself knows is safe to stringify.
4081
+ */
4082
+ function stateScalarString(v) {
4083
+ if (v === null || v === undefined)
4084
+ return '';
4085
+ if (typeof v === 'string')
4086
+ return v;
4087
+ if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint')
4088
+ return String(v);
4089
+ return JSON.stringify(v) ?? '';
4090
+ }
4091
+ function stateFieldValuesDiffer(before, after) {
4092
+ if (before === STATE_FIELD_ABSENT && after === STATE_FIELD_ABSENT)
4093
+ return false;
4094
+ if (before === STATE_FIELD_ABSENT || after === STATE_FIELD_ABSENT)
4095
+ return true;
4096
+ if (typeof before === 'object' || typeof after === 'object') {
4097
+ return JSON.stringify(before) !== JSON.stringify(after);
4098
+ }
4099
+ return stateScalarString(before).trim() !== stateScalarString(after).trim();
4100
+ }
4101
+ /**
4102
+ * ADR-3473 §8.7 (#3872): the declared dotted-leaf children of a frontmatter
4103
+ * key, read off `FIELD_CLASSIFICATION` (`progress` -> its five
4104
+ * `progress.*` rows) rather than walked from arbitrary nesting depth of a
4105
+ * user-authored document. A BOUNDED, DECLARED enumeration — the design
4106
+ * doc's Rejected #5 and the "Emit dotted leaves, not the parent" rule both
4107
+ * depend on this staying a closed set the schema names, not unbounded
4108
+ * traversal of whatever object shape happens to be on disk.
4109
+ */
4110
+ function declaredLeavesOf(key) {
4111
+ const prefix = `${key}.`;
4112
+ return Object.keys(FIELD_CLASSIFICATION).filter((k) => k.startsWith(prefix));
4113
+ }
4114
+ /**
4115
+ * ADR-3473 §8.7 (#3872): every frontmatter key — resolved at DOTTED-LEAF
4116
+ * granularity for a key with declared leaves (`progress` -> only the
4117
+ * `progress.*` leaves that actually moved, never bare `progress` itself;
4118
+ * design doc rule 4/Rejected #5) — whose PERSISTED value differs from the
4119
+ * transaction's pre-write SNAPSHOT. Pure: no I/O, no `FIELD_CLASSIFICATION`
4120
+ * preservation-policy consultation (that filter is exactly what this rule
4121
+ * deletes — ADR-3473 §8.7 "no field is excluded by classification").
4122
+ * `last_updated` is the one-element provenance exclusion; every other key,
4123
+ * including `state_head`, is a candidate.
4124
+ *
4125
+ * **A `FRONTMATTER_BODY_SOURCE` key is diffed via `bodyDeltas`, never via a
4126
+ * raw frontmatter compare.** Found while driving the #1264 regression check
4127
+ * through this rewrite at the CLI: `syncStateFrontmatter` re-derives EVERY
4128
+ * body-sourced key into frontmatter on EVERY write, independent of whether
4129
+ * this write's own transform touched it. A hand-authored (or day-1
4130
+ * bootstrap) STATE.md whose frontmatter has not yet caught up to an
4131
+ * already-stable body value — e.g. `current_phase_name` present in the body
4132
+ * but absent from a pre-write frontmatter block that only ever recorded
4133
+ * `status`/`progress` — makes that key look newly ADDED under a raw diff
4134
+ * (rows 15/17) even though nothing changed. The real "did THIS write change
4135
+ * it" signal for these keys is whether their BODY SOURCE moved, which is
4136
+ * exactly what `bodyDeltas` (built once, in `applyPostSyncPreservation`,
4137
+ * from `originalContent` vs `transformedContent`) already answers — reused
4138
+ * here rather than re-derived, and it is what correctly REPORTS #3818's
4139
+ * `current_phase` (the body source did move) while staying SILENT on a
4140
+ * merely-backfilled, body-unchanged key (the #1264 false positive this
4141
+ * function's first cut produced).
4142
+ *
4143
+ * **A declared dotted-leaf (`declaredLeavesOf`, e.g. every `progress.*` row)
4144
+ * absent from the snapshot and present in persisted is materialization, not
4145
+ * a change.** Found the same way as the paragraph above, one layer down:
4146
+ * `progress` is `source: 'disk'` (state-transition.cts), re-derived by
4147
+ * `buildStateFrontmatter`'s phase-directory scan on every write regardless
4148
+ * of whether the caller's own action touched it — and the phases directory
4149
+ * cannot move during a STATE.md write, so a fresh `progress` block appearing
4150
+ * where the snapshot had none is the scanner catching a never-synced
4151
+ * document up, not the caller changing anything. This is the SAME
4152
+ * provenance principle `STATE_UPDATED_PROVENANCE_EXCLUSION` applies to
4153
+ * `last_updated` (a field stamped by the write's occurrence, not its
4154
+ * action) — generalized to the declared-leaf case, deliberately NOT a
4155
+ * second classification-based exclusion: `progress`'s `preserve-always`
4156
+ * policy plays no part in the check below, and a leaf already PRESENT in
4157
+ * the snapshot is diffed exactly as every other field is, including
4158
+ * reporting its outright disappearance (row 16) — only the absent-in-
4159
+ * snapshot-but-materialized-in-persisted transition is suppressed.
4160
+ */
4161
+ function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
4162
+ const changed = [];
4163
+ const topKeys = new Set([...Object.keys(snapshotFm), ...Object.keys(persistedFm)]);
4164
+ for (const key of topKeys) {
4165
+ if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(key))
4166
+ continue;
4167
+ if (stateTransitionMod.getFrontmatterBodySource(key) !== null) {
4168
+ const delta = bodyDeltas ? bodyDeltas[key] : undefined;
4169
+ if (delta && stateFieldValuesDiffer(delta.pre ?? STATE_FIELD_ABSENT, delta.post ?? STATE_FIELD_ABSENT)) {
4170
+ changed.push(key);
4171
+ }
4172
+ continue;
4173
+ }
4174
+ const leaves = declaredLeavesOf(key);
4175
+ if (leaves.length > 0) {
4176
+ for (const leaf of leaves) {
4177
+ const before = resolveFrontmatterPath(snapshotFm, leaf);
4178
+ const after = resolveFrontmatterPath(persistedFm, leaf);
4179
+ // Generalizes the SAME provenance principle STATE_UPDATED_PROVENANCE_EXCLUSION
4180
+ // applies to `last_updated` one level up — this is NOT a classification-based
4181
+ // exclusion (progress's `preserve-always` policy plays no part here; that filter
4182
+ // stays deleted per §8.7). It is a fact about the DECLARED LEAF SET: every key
4183
+ // enumerated by `declaredLeavesOf` is `source: 'disk'` (state-transition.cts),
4184
+ // re-derived from a scan that cannot move during a STATE.md write (the write only
4185
+ // touches STATE.md, never the phases directory). So a leaf ABSENT from the
4186
+ // pre-write snapshot and PRESENT in persisted is the scanner catching a document
4187
+ // up to a derivation it had never synced before — the write's own OCCURRENCE
4188
+ // produced the bytes, not the caller's ACTION, exactly the `last_updated` shape.
4189
+ // A leaf already PRESENT in the snapshot behaves normally: any difference
4190
+ // (including disappearing entirely, row 16) is reported, because there the
4191
+ // snapshot proves the derivation had already run once, so a new persisted value
4192
+ // can only come from something genuinely moving (#3743/#3818).
4193
+ if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
4194
+ continue;
4195
+ if (stateFieldValuesDiffer(before, after))
4196
+ changed.push(leaf);
4197
+ }
4198
+ continue;
4199
+ }
4200
+ const before = resolveFrontmatterPath(snapshotFm, key);
4201
+ const after = resolveFrontmatterPath(persistedFm, key);
4202
+ if (stateFieldValuesDiffer(before, after))
4203
+ changed.push(key);
4204
+ }
4205
+ return changed;
4206
+ }
4207
+ /**
4208
+ * ADR-3473 §8.7 (issue #3872): the transaction diff. `updated` is derived
4209
+ * by comparing PERSISTED frontmatter against the transaction's pre-write
4210
+ * SNAPSHOT — replacing the prior comparison of the transform's own OUTPUT
4211
+ * against persisted bytes, which answered a different question ("did the
4212
+ * transform's write survive to disk", #3351) from the one §8.7 asks ("what
4213
+ * did this write actually change" — both #3351's direction and #3345/#3818's
4214
+ * fall out of ONE comparison against the pre-write state; see the design
4215
+ * doc's "ambiguity in §8.7" section for why the transform-output comparison
4216
+ * was rejected).
3204
4217
  *
3205
- * Closes BOTH directions:
3206
- * - **#3351's** (reported-but-discarded): a field the transform's own
3207
- * return value held a value for, that sync/preservation then discarded
3208
- * or overwrote before the file was saved, must NOT be reported.
3209
- * - **#3345's** (persisted-but-unreported): a field `applyStatePreservation`
3210
- * restored that the transform never touched at all must still be
3211
- * reported — preservation can mutate a field the pre-sync intent never
3212
- * knew about.
4218
+ * No field is excluded by classification — `getFieldClassification` /
4219
+ * `preservation !== 'preserve-when-unchanged'` is gone, not relocated. The
4220
+ * ONLY exclusion is `STATE_UPDATED_PROVENANCE_EXCLUSION` (provenance, not
4221
+ * classification): an unchanged `progress` no longer needs a special filter
4222
+ * to stay unreported (#1264) because the diff itself says "unchanged" —
4223
+ * and a GENUINELY changed `progress.*` leaf (#3743, #3818) is no longer
4224
+ * suppressed by the same filter.
3213
4225
  *
3214
- * @param preSyncContent The transformFn's OWN return value — the content
3215
- * BEFORE `syncAndPreserveStateMd` ran this write — captured by the caller
3216
- * inside its own `readModifyWriteStateMd` callback. Comparing that against
3217
- * the actual bytes on disk after the full write pipeline settled is what
3218
- * makes the report reflect what POST-sync bytes hold (ADR-3408 §8.4),
3219
- * not what the pre-sync intent merely hoped for.
3220
- * @param reported The candidate field names — the transform's OWN
3221
- * success list (e.g. `beginPhaseCore`'s `updated`), never a raw intent
3222
- * list the transform might not have actually matched. Body Title-Case
3223
- * labels (`Status`, `Current Plan`) and frontmatter keys are both valid;
3224
- * each is looked up as a frontmatter key first, else as a body field —
3225
- * the same fallback chain `cmdStatePatch` used before this generalization.
3226
- * @param divergedFields Frontmatter field names `applyStatePreservation`
3227
- * actually restored during this write (ADR-3408 §8.5's out-param).
4226
+ * @param preWriteState The transaction's pre-write snapshot + body — the
4227
+ * `preWriteState` out-param `applyPostSyncPreservation` filled during
4228
+ * THIS write (see `ReadModifyWriteOptions.preWriteState`'s docstring).
4229
+ * `.fm`/`.body` are `undefined` only when `readModifyWriteStateMd`'s own
4230
+ * #948 no-op guard fired (transform output was byte-identical to input),
4231
+ * in which case nothing was ever written and `[]` is the correct,
4232
+ * short-circuited answer — never a diff against a synthesized empty `{}`
4233
+ * snapshot, which would read every already-persisted key as newly ADDED.
4234
+ * @param reported The candidate field names — the transform's OWN success
4235
+ * list. Body Title-Case labels (`Status`, `Current Plan`, `Current
4236
+ * Position`) and frontmatter keys (including dotted leaves like
4237
+ * `progress.total_plans`) are both valid; each is resolved via the same
4238
+ * `valueOf` fallback chain used for the inclusion test below.
4239
+ * @param divergedFields Kept for signature/out-param stability (ADR-3408
4240
+ * §8.5) — populated exactly as before by `applyPostSyncPreservation` and
4241
+ * still read directly by other code and `tests/state.test.cjs`'s A2f case
4242
+ * — but no longer consulted here as a candidate SOURCE (design doc row
4243
+ * 18): the frontmatter diff subsumes what it used to contribute, and it
4244
+ * sees only what *preservation* changed, never what *sync* changed
4245
+ * (#3818's own direction), which is why keeping it as the candidate
4246
+ * source was rejected (design doc, Rejected #1).
3228
4247
  */
3229
- function reconcileReportedFields(statePath, preSyncContent, reported, divergedFields) {
4248
+ function reconcileReportedFields(statePath, preWriteState, reported, divergedFields) {
4249
+ void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
4250
+ if (preWriteState.fm === undefined || preWriteState.body === undefined)
4251
+ return [];
3230
4252
  const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
3231
4253
  const persistedFm = extractFrontmatter(persisted, statePath);
3232
4254
  const persistedBody = stripFrontmatter(persisted);
3233
- const preFm = extractFrontmatter(preSyncContent, statePath);
3234
- const preBody = stripFrontmatter(preSyncContent);
3235
- // #3471 review: body-FIRST, frontmatter-fallback — mirrors the actual write
3236
- // precedence `patchCore`/`updateCore` apply (their own docstrings: "a key
3237
- // that resolves against the STRIPPED body ... wins deterministically even
3238
- // when the same key also happens to exist as a parsed frontmatter key").
3239
- // The prior frontmatter-first order was silently correct for every
3240
- // Title-Case body label (`Status`, `Current Plan`, ...) only because those
3241
- // never case-exact-match a frontmatter key (frontmatter keys are always
3242
- // lowercase snake_case) — so `hasOwnProperty` always missed and it fell
3243
- // through to the body anyway. It broke the one case where `field` IS
3244
- // lowercase and DOES exact-match a frontmatter key: a table-format
3245
- // STATE.md with a lowercase field name (e.g. `state update status ...`
3246
- // against `| status | ... |`). There, `preFm` (extracted from the
3247
- // transform's own pre-sync output) never has a `status` key yet — but
3248
- // `persistedFm` (extracted after `syncStateFrontmatter` ran) always does,
3249
- // since `status` is a schema-owned frontmatter key re-derived on every
3250
- // write. Reading frontmatter first made `intended` (body text) and
3251
- // `persistedValue` (frontmatter-derived enum) compare two different
3252
- // representations of the same field, and the write was never reconciled
3253
- // (regression: #1162's "state update is case-insensitive for table field
3254
- // names").
4255
+ const snapshotFm = preWriteState.fm;
4256
+ const snapshotBody = preWriteState.body;
4257
+ // #3471 review (unchanged by this rewrite): body-FIRST, frontmatter-key-
4258
+ // FLAT-fallback, dotted-PATH-fallback last. Body-first mirrors the actual
4259
+ // write precedence `patchCore`/`updateCore` apply (#1162's fix — a
4260
+ // lowercase body label that happens to case-exact-match a frontmatter key
4261
+ // must still resolve against the body). `field` a literal flat key (even
4262
+ // one containing a `.`) is tried before it is split and walked as a
4263
+ // dotted path (test matrix row 26) — `resolveFrontmatterPath` pins that
4264
+ // same order for the frontmatter side alone.
4265
+ //
4266
+ // `Current Position` is special-cased: it names the WHOLE `## Current
4267
+ // Position` section, not a single `Label: value` line, so
4268
+ // `stateExtractField` can never resolve it (this is the root cause of the
4269
+ // "Current Position undercount" — a transform can correctly push
4270
+ // `'Current Position'` into its own `updated` list, and this function
4271
+ // still silently dropped it, because `valueOf` returned `null` for BOTH
4272
+ // sides and `null === null` failed the old `intended !== null` guard).
4273
+ // `sliceCurrentPositionSection` is the existing fence-aware section
4274
+ // locator (state-transition.cts) — reused rather than re-derived.
3255
4275
  const valueOf = (fm, body, field) => {
4276
+ if (field === 'Current Position') {
4277
+ const section = stateTransitionMod.sliceCurrentPositionSection(body);
4278
+ return section !== null ? section.trim() : null;
4279
+ }
3256
4280
  const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
3257
4281
  if (bodyValue !== null)
3258
4282
  return bodyValue;
3259
- return Object.prototype.hasOwnProperty.call(fm, field) ? String(fm[field]) : null;
4283
+ if (Object.prototype.hasOwnProperty.call(fm, field))
4284
+ return String(fm[field]);
4285
+ if (field.includes('.')) {
4286
+ const resolved = resolveFrontmatterPath(fm, field);
4287
+ if (resolved !== STATE_FIELD_ABSENT) {
4288
+ return stateScalarString(resolved);
4289
+ }
4290
+ }
4291
+ return null;
4292
+ };
4293
+ // A field in `reported` can itself be a declared derived leaf (e.g.
4294
+ // `plannedPhaseCore` pushing `'progress.total_plans'` — state-
4295
+ // transition.cts:1752). `valueOf`'s null-vs-string convention cannot tell
4296
+ // "absent from the frontmatter" apart from "resolved to the literal string
4297
+ // 'null'/''", so it cannot carry the same materialization rule
4298
+ // `computeChangedFrontmatterFields` applies below. Route these fields
4299
+ // through the SAME primitives (`resolveFrontmatterPath` + the
4300
+ // `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
4301
+ // second, parallel absence convention — one rule, reused, not duplicated.
4302
+ const isDeclaredDerivedLeaf = (candidate) => candidate.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, candidate);
4303
+ const changed = (field) => {
4304
+ if (isDeclaredDerivedLeaf(field)) {
4305
+ const before = resolveFrontmatterPath(snapshotFm, field);
4306
+ const after = resolveFrontmatterPath(persistedFm, field);
4307
+ // Same generalized provenance rule as computeChangedFrontmatterFields:
4308
+ // absent-in-snapshot-materializing-in-persisted is the disk scan
4309
+ // catching a never-synced document up, not this write's own action.
4310
+ if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
4311
+ return false;
4312
+ return stateFieldValuesDiffer(before, after);
4313
+ }
4314
+ const before = valueOf(snapshotFm, snapshotBody, field);
4315
+ const after = valueOf(persistedFm, persistedBody, field);
4316
+ if (before === null && after === null)
4317
+ return false;
4318
+ if (before === null || after === null)
4319
+ return true;
4320
+ return before.trim() !== after.trim();
3260
4321
  };
4322
+ // Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
4323
+ // granularity) whose persisted value differs from the snapshot, minus the
4324
+ // provenance exclusion. A frontmatter-diff-discovered field is mapped
4325
+ // through `bodyLabelFor` so it lands in the SAME output vocabulary a
4326
+ // transform would have used (`status` -> `'Status'`; `progress.total_plans`
4327
+ // has no body-line label and falls through to its raw dotted key, same as
4328
+ // today's `progress`/`milestone*` fall-through).
4329
+ const changedFrontmatterFields = computeChangedFrontmatterFields(snapshotFm, persistedFm, preWriteState.bodyDeltas);
4330
+ const mappedFrontmatterFields = changedFrontmatterFields.map((field) => bodyLabelFor(field));
4331
+ const seen = new Set();
3261
4332
  const reconciled = [];
3262
4333
  for (const field of reported) {
3263
- const intended = valueOf(preFm, preBody, field);
3264
- const persistedValue = valueOf(persistedFm, persistedBody, field);
3265
- if (intended !== null && intended.trim() === (persistedValue ?? '').trim()) {
4334
+ if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
4335
+ continue;
4336
+ if (changed(field)) {
4337
+ seen.add(field);
3266
4338
  reconciled.push(field);
3267
4339
  }
3268
4340
  }
3269
- // #3471 review: only fold a `divergedFields` entry into the reported array
3270
- // when it is a `preserve-when-unchanged` row (has a genuine body-line
3271
- // label — Status, Current Plan, Current Phase, ...). #3345's direction
3272
- // ("preservation restored a field the intent never named") is about a
3273
- // caller-visible BODY field the transform could plausibly have named —
3274
- // never about `progress` (`preserve-always`) or `milestone`/`milestone_name`
3275
- // (`preserve-if-placeholder`), which are structured/paired fields no
3276
- // caller ever names via a per-field body label and whose restoration is
3277
- // the long-standing, silent #3242/#948 protection, not a caller-visible
3278
- // "update". Folding them in unconditionally reported `progress` as
3279
- // `updated` on every `state.patch`/`state.update` write that happened to
3280
- // preserve it — even when the call never touched Current Phase's
3281
- // curated-progress-preserving field at all (regression: #1264's
3282
- // `state.patch` of `Current Phase` reporting `updated: ['Current Phase',
3283
- // 'progress']` instead of `['Current Phase']`).
3284
- for (const field of divergedFields) {
3285
- const cls = stateTransitionMod.getFieldClassification(field);
3286
- if (!cls || cls.preservation !== 'preserve-when-unchanged')
4341
+ for (const field of mappedFrontmatterFields) {
4342
+ if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
3287
4343
  continue;
3288
- const label = bodyLabelFor(field);
3289
- if (!reconciled.includes(label))
3290
- reconciled.push(label);
4344
+ seen.add(field);
4345
+ reconciled.push(field);
3291
4346
  }
3292
4347
  return reconciled;
3293
4348
  }
@@ -3310,7 +4365,7 @@ function cmdStateJson(cwd, raw) {
3310
4365
  // reports the phase-directory count while the persisted file preserves the
3311
4366
  // stored total, exactly the write/read divergence #3354 closed for its shape.
3312
4367
  const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
3313
- const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
4368
+ const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
3314
4369
  // ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
3315
4370
  // current_phase_name / current_plan through the SAME `preserve-when-unchanged`
3316
4371
  // executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
@@ -3342,11 +4397,21 @@ function cmdStateJson(cwd, raw) {
3342
4397
  ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
3343
4398
  const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
3344
4399
  const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
4400
+ // #3836: mirrors applyPostSyncPreservation's own derivation (state.cts
4401
+ // bodyDeltas, `last_activity_desc`) — the `Last Activity Description`
4402
+ // label, falling back to the prose `Last Activity:` line's parsed
4403
+ // description. Read-side twin of #3258's write-side wiring; this field is
4404
+ // `preserve-when-unchanged` per FIELD_CLASSIFICATION and was previously
4405
+ // absent from this read path entirely (never derived here, never in the
4406
+ // loop below), so a stale body annotation always beat a fresher curated
4407
+ // frontmatter value on every `state json` read.
4408
+ const bodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last activity');
4409
+ const bodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity Description')
4410
+ ?? parseProseLastActivityField(bodyLastActivityRaw).description;
3345
4411
  const unchanged = (v) => ({ pre: v, post: v });
3346
4412
  const ctx = {
3347
- preFm: null,
3348
4413
  postFm: built,
3349
- preFmSnapshot: existingFm,
4414
+ snapshot: existingFm,
3350
4415
  resync: true,
3351
4416
  deriveProgressKeys: false,
3352
4417
  bodyDeltas: {
@@ -3356,10 +4421,15 @@ function cmdStateJson(cwd, raw) {
3356
4421
  current_phase: unchanged(bodyCurrentPhase),
3357
4422
  current_plan: unchanged(bodyCurrentPlan),
3358
4423
  current_phase_name: unchanged(bodyPhaseSource),
4424
+ last_activity_desc: unchanged(bodyLastActivityDesc),
3359
4425
  },
3360
4426
  mutated: false,
3361
4427
  };
3362
- for (const field of ['status', 'stopped_at', 'paused_at', 'current_phase', 'current_plan', 'current_phase_name']) {
4428
+ // #3836: derive the field set from FIELD_CLASSIFICATION's
4429
+ // `preserve-when-unchanged` rows (single source of truth) instead of a
4430
+ // hand-typed literal that can drift from the table — this IS the fix,
4431
+ // not merely an addition of one more name to the literal.
4432
+ for (const field of stateTransitionMod.getPreserveWhenUnchangedFields()) {
3363
4433
  const cls = stateTransitionMod.getFieldClassification(field);
3364
4434
  if (cls)
3365
4435
  stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
@@ -3412,26 +4482,28 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
3412
4482
  // still runs after the sync; the override is re-asserted after it inside
3413
4483
  // readModifyWriteStateMd for layouts with no body `Phase:` line.
3414
4484
  const divergedFields = [];
4485
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
4486
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
4487
+ const preWriteState = {};
3415
4488
  const rmwOptions = {
3416
4489
  authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
3417
4490
  divergedFields,
4491
+ preWriteState,
3418
4492
  };
3419
4493
  let precomputedUpdated = [];
3420
- let preSyncContent = '';
3421
4494
  // #3311: begin-phase is the claim point — it is the one Current Position
3422
4495
  // transition that explicitly names its phase, so it both records this
3423
4496
  // session's claim and detects a conflicting live claim for a different
3424
4497
  // phase. The check runs INSIDE the STATE.md lock so concurrent begin-phase
3425
4498
  // calls cannot both read "no claim" and both write.
3426
4499
  let milestoneConflict = null;
3427
- readModifyWriteStateMd(statePath, (content) => {
4500
+ const wrote = readModifyWriteStateMd(statePath, (content) => {
3428
4501
  milestoneConflict = milestoneLockMod.claimMilestonePhase(cwd, String(phaseNumber));
3429
4502
  if (milestoneConflict) {
3430
4503
  milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
3431
4504
  }
3432
4505
  const result = transitionCore(content, intent, deps);
3433
4506
  precomputedUpdated = result.updated;
3434
- preSyncContent = result.content;
3435
4507
  // #3127 resume: the core preserved the mid-flight Current Phase Name, so
3436
4508
  // the intent-first override must not fire — it would drift frontmatter
3437
4509
  // away from the preserved body value. Dropping it here is safe because
@@ -3445,8 +4517,29 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
3445
4517
  // the bytes actually persisted (fix(#3351) generalized) and fold in any
3446
4518
  // field preservation restored that this transform never touched (#3345's
3447
4519
  // direction).
3448
- const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
4520
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
3449
4521
  output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, milestone_conflict: milestoneConflict }, raw, updated.length > 0 ? 'true' : 'false');
4522
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on `wrote`
4523
+ // (readModifyWriteStateMd's own return value — its #948 no-op guard skips
4524
+ // the write outright when the transform produced no diff), not on
4525
+ // `updated.length > 0`. Confirmed reproducer: an unrecognized-format
4526
+ // STATE.md makes `beginPhaseCore` match zero body fields AND leave
4527
+ // `existingFm` untouched, so the raw transform output is byte-identical to
4528
+ // the input, the RMW guard fires, and `wrote` is false — matching
4529
+ // `updated: []` here. Unlike `cmdStatePlannedPhase` (which must NOT use
4530
+ // this same `wrote` signal — see its comment for why `plannedPhaseCore`
4531
+ // mutates frontmatter in place even on this exact no-op shape),
4532
+ // `beginPhaseCore` never mutates `existingFm`, so `wrote` and
4533
+ // `updated.length > 0` agree on every case audited for this phase; `wrote`
4534
+ // is kept as the gate here (and on `cmdStateAdvancePlan`/
4535
+ // `cmdStateCompletePhase` below, where it is REQUIRED — `updated`/
4536
+ // `reconciled` can be non-empty there even when nothing was written,
4537
+ // confirmed by direct re-invocation) for one consistent rule across every
4538
+ // RMW-backed command in this file: publish iff `readModifyWriteStateMd`
4539
+ // itself reports a write. Best-effort — cannot throw, cannot change this
4540
+ // command's exit code or output.
4541
+ if (wrote)
4542
+ publishStateContract(cwd);
3450
4543
  }
3451
4544
  /**
3452
4545
  * Write a WAITING.json signal file when GSD hits a decision point.
@@ -3698,19 +4791,38 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
3698
4791
  // line, and the prose re-derivation of current_phase_name truncates names
3699
4792
  // that themselves contain a parenthetical — the authoritative override keeps
3700
4793
  // the exact value, exactly as cmdStateBeginPhase does for its EXECUTING line.
4794
+ //
4795
+ // #3834: without a name, the body-source delta rule that would normally
4796
+ // preserve the curated `current_phase_name` (FIELD_CLASSIFICATION:
4797
+ // preserve-when-unchanged) cannot fire — THIS write rewrites the `Phase:`
4798
+ // source line to `N — READY TO EXECUTE` itself, so pre/post disagree by
4799
+ // construction and the post-sync re-derivation harvests "READY TO EXECUTE"
4800
+ // as if it were the name. The fix mirrors the named-arg path: reassert an
4801
+ // authoritative override, falling back to the pre-write curated value (read
4802
+ // inside the RMW callback, before this write's own body mutation) rather
4803
+ // than leaving the field to a delta heuristic this exact transition defeats.
3701
4804
  const divergedFields = [];
4805
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
4806
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
4807
+ const preWriteState = {};
3702
4808
  const rmwOptions = {
3703
4809
  resync: false,
3704
4810
  deriveProgressKeys: true,
3705
4811
  authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
3706
4812
  divergedFields,
4813
+ preWriteState,
3707
4814
  };
3708
4815
  let precomputedUpdated = [];
3709
- let preSyncContent = '';
3710
4816
  readModifyWriteStateMd(statePath, (content) => {
4817
+ if (!intent.phaseName) {
4818
+ const preFm = extractFrontmatter(content, statePath);
4819
+ const curatedName = preFm['current_phase_name'];
4820
+ if (typeof curatedName === 'string' && curatedName.trim().length > 0) {
4821
+ rmwOptions.authoritativeFm = { current_phase_name: curatedName };
4822
+ }
4823
+ }
3711
4824
  const result = transitionCore(content, intent, deps);
3712
4825
  precomputedUpdated = result.updated;
3713
- preSyncContent = result.content;
3714
4826
  return result.content;
3715
4827
  }, cwd, rmwOptions);
3716
4828
  // ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
@@ -3719,11 +4831,35 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
3719
4831
  // (#3345's direction) — traced for this phase (design doc: "not traced in
3720
4832
  // the analysis pass") and found to need exactly the same treatment as
3721
4833
  // `cmdStateBeginPhase`.
3722
- const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
4834
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
3723
4835
  const result = updated.length === 0
3724
4836
  ? { updated, phase: phaseNumber, plan_count: planCount, warning: 'STATE.md Current Position has no recognized labels — transition was a no-op. Verify STATE.md uses the canonical labeled format (Status:, Total Plans in Phase:, etc.).' }
3725
4837
  : { updated, phase: phaseNumber, plan_count: planCount };
3726
4838
  output(result, raw, updated.length > 0 ? 'true' : 'false');
4839
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on
4840
+ // `updated.length > 0`, NOT on `readModifyWriteStateMd`'s own write-happened
4841
+ // return value. The two are NOT equivalent here: readModifyWriteStateMd's
4842
+ // #948 no-op guard compares the transform's RAW returned string against the
4843
+ // RAW original file content, but `syncStateFrontmatter`'s progress-block
4844
+ // sync and this command's `authoritativeFm: {current_phase_name}` override
4845
+ // both run INSIDE the transform (via `frontmatterMod.reconstructFrontmatter`
4846
+ // over `existingFm`), so an unrecognized-format STATE.md — zero fields the
4847
+ // transition could actually apply, `updated: []`, the "transition was a
4848
+ // no-op" warning above — can still make the raw returned string differ
4849
+ // from the input (frontmatter gets synthesized: `gsd_state_version`,
4850
+ // `last_updated`, a zeroed `progress` block, `current_phase_name`), so the
4851
+ // RMW guard does NOT fire and a real write happens. That write is not a
4852
+ // meaningful state transition by this command's OWN reporting contract
4853
+ // (`updated: []`) — publishing on it would refresh state.json's
4854
+ // `updated_at` for a call this command itself reports did nothing.
4855
+ // `updated.length > 0` is the field-classification-table-backed signal
4856
+ // that actually answers "did plannedPhaseCore itself change anything this
4857
+ // caller asked it to change" — empirically verified: an unrecognized-format
4858
+ // STATE.md reproduces `updated: []` with a genuine (frontmatter-only) disk
4859
+ // write underneath it, and gating on `updated.length > 0` is what makes
4860
+ // this reproducer NOT publish.
4861
+ if (updated.length > 0)
4862
+ publishStateContract(cwd);
3727
4863
  }
3728
4864
  /**
3729
4865
  * Bug #2630: reset STATE.md for a new milestone cycle.
@@ -3746,16 +4882,24 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
3746
4882
  // steady-state syncStateFrontmatter post-sync.
3747
4883
  const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
3748
4884
  const deps = { clock: clock_cjs_1.realClock, sourcePath: statePath };
4885
+ let switched = false;
3749
4886
  const lockPath = acquireStateLock(statePath);
3750
4887
  try {
3751
4888
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
3752
4889
  const result = transitionCore(content, intent, deps);
3753
4890
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, result.content);
3754
4891
  output({ switched: true, version, name: resolvedName, status: 'planning' }, raw, 'true');
4892
+ switched = true;
3755
4893
  }
3756
4894
  finally {
3757
4895
  releaseStateLock(lockPath);
3758
4896
  }
4897
+ // #3227: publish AFTER releaseStateLock — publishStateContract derives `next`
4898
+ // from classifyProject, which shells out to git (bounded, but up to 3 x 10s).
4899
+ // Holding the STATE.md lock across that would turn a millisecond hold into a
4900
+ // git-bound one for every concurrent GSD process.
4901
+ if (switched)
4902
+ publishStateContract(cwd);
3759
4903
  }
3760
4904
  /**
3761
4905
  * Gate 1: Validate STATE.md against filesystem.
@@ -3851,10 +4995,31 @@ function readStateFrontmatterScoped(content, statePath) {
3851
4995
  function stateDiagnostic(code, severity, message, advice) {
3852
4996
  return { code, severity, message, remedy: adviseRemedy(advice) };
3853
4997
  }
3854
- function cmdStateValidate(cwd, raw) {
4998
+ function cmdStateValidate(cwd, raw, opts = {}) {
3855
4999
  const statePath = planningPaths(cwd).state;
5000
+ // #3696: `valid: false` used to exit 0, so a CI step or git hook could not gate
5001
+ // on state correctness without parsing JSON — every consumer had to
5002
+ // re-implement the "is this actually valid" decision, which is the
5003
+ // duplication #3473 is about.
5004
+ //
5005
+ // The DEFAULT is deliberately unchanged. `state validate`'s exit status is
5006
+ // Tier-2 observable output reaching "downstream projects that cannot be
5007
+ // enumerated" (ADR-3180 Decision 3, Hyrum's Law), so flipping 0 -> 1 for
5008
+ // everyone would break every script that runs it unconditionally. `--strict`
5009
+ // is the opt-in the issue itself offers as the alternative.
5010
+ //
5011
+ // Routed through one emit helper rather than a trailing assignment because
5012
+ // three of the exit paths below (`STATE.md not found`, S001, and the four
5013
+ // `return` branches in the phase-drift scan) emit and return early — a fix
5014
+ // that only set the exit code at the end of the function would silently miss
5015
+ // them, which is exactly the shape of the bug being fixed.
5016
+ const emit = (payload) => {
5017
+ if (opts.strict && payload.valid !== true)
5018
+ process.exitCode = 1;
5019
+ output(payload, raw, undefined);
5020
+ };
3856
5021
  if (!node_fs_1.default.existsSync(statePath)) {
3857
- output({ error: 'STATE.md not found' }, raw, undefined);
5022
+ emit({ error: 'STATE.md not found' });
3858
5023
  return;
3859
5024
  }
3860
5025
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
@@ -3867,10 +5032,10 @@ function cmdStateValidate(cwd, raw) {
3867
5032
  // unconditionally and returned immediately, matching every other
3868
5033
  // error-class code, not a mere warning). Message reused verbatim from
3869
5034
  // `textEncodingError`, not paraphrased.
3870
- output({
5035
+ emit({
3871
5036
  valid: false,
3872
5037
  warnings: [stateDiagnostic('S001', SEVERITY.ERROR, encErr, 'Re-save STATE.md as UTF-8 text with the embedded NUL byte(s) removed')],
3873
- }, raw, undefined);
5038
+ });
3874
5039
  return;
3875
5040
  }
3876
5041
  const warnings = [];
@@ -3888,7 +5053,7 @@ function cmdStateValidate(cwd, raw) {
3888
5053
  const phasesDir = planningPaths(cwd).phases;
3889
5054
  if (currentPhase === null) {
3890
5055
  warnings.push(stateDiagnostic('S002', SEVERITY.WARNING, 'Cannot validate phase drift: STATE.md has no usable current_phase, Current Phase, or Current Position Phase value', 'Set current_phase (frontmatter) or Current Phase / Current Position Phase (body) in STATE.md'));
3891
- output({ valid: false, warnings, scope }, raw, undefined);
5056
+ emit({ valid: false, warnings, scope });
3892
5057
  return;
3893
5058
  }
3894
5059
  const selectedPhaseKey = phaseKeyFromToken(currentPhase);
@@ -3897,23 +5062,41 @@ function cmdStateValidate(cwd, raw) {
3897
5062
  }
3898
5063
  if (!node_fs_1.default.existsSync(phasesDir)) {
3899
5064
  warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phases directory is missing for phase ${currentPhase}`, 'Create the phases directory or correct current_phase to a phase that exists on disk'));
3900
- output({ valid: false, warnings, scope }, raw, undefined);
5065
+ emit({ valid: false, warnings, scope });
3901
5066
  return;
3902
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);
3903
5086
  let phaseDirPath;
3904
5087
  try {
3905
5088
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
3906
- const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
5089
+ const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention) === selectedPhaseKey);
3907
5090
  if (!phaseDir) {
3908
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'));
3909
- output({ valid: false, warnings, scope }, raw, undefined);
5092
+ emit({ valid: false, warnings, scope });
3910
5093
  return;
3911
5094
  }
3912
5095
  phaseDirPath = node_path_1.default.join(phasesDir, phaseDir.name);
3913
5096
  }
3914
5097
  catch {
3915
5098
  warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phases directory is unreadable for phase ${currentPhase}`, 'Check phases directory permissions and re-run validate'));
3916
- output({ valid: false, warnings, scope }, raw, undefined);
5099
+ emit({ valid: false, warnings, scope });
3917
5100
  return;
3918
5101
  }
3919
5102
  try {
@@ -3981,8 +5164,56 @@ function cmdStateValidate(cwd, raw) {
3981
5164
  catch {
3982
5165
  warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phase directory is unreadable for phase ${currentPhase}`, 'Check phase directory permissions and re-run validate'));
3983
5166
  }
5167
+ // #3696 — the `last_activity` invariant. Three readers consumed this field
5168
+ // and none of them checked it, so a value no reader can parse validated as
5169
+ // `{valid:true, warnings:[], scope:'complete'}`: the scan ran to completion
5170
+ // and simply never looked. Read through the same owner every other field here
5171
+ // uses (ADR-3180 §7.7) — never a private `stateExtractField` call, which is
5172
+ // what `scripts/lint-state-field-drift.cjs` counts.
5173
+ const lastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity', 'Last activity').value;
5174
+ // NOT FILLED IN IS NOT DRIFT, and that covers three shapes, not one: absent,
5175
+ // blank, and the shipped template's `[YYYY-MM-DD] — [What happened]`
5176
+ // placeholder. Only a value a writer actually supplied can be wrong.
5177
+ if (!(0, state_document_cjs_1.isUnfilledFieldValue)(lastActivity)) {
5178
+ // Calendar validity, not merely `\d{4}-\d{2}-\d{2}` shape: smart-entry's
5179
+ // reader rejects 2026-02-30 via isRealCalendarDate (ADR-227 — validate shape
5180
+ // AND value). Accepting it here would leave the two surfaces disagreeing
5181
+ // about whether the file is usable, which is the complaint #3696 opens with.
5182
+ //
5183
+ // Review round 2: this asserts the LEADING date token, not
5184
+ // `parseProseLastActivityField`'s fully-anchored `date — description`
5185
+ // grammar. That grammar is stricter than any real reader, and routing the
5186
+ // check through it made S008 fire on values smart-entry parses fine (e.g.
5187
+ // `2026-08-24 Shipped feature X`, no dash separator) — the same
5188
+ // two-surfaces-disagree defect, pointing the other way. See
5189
+ // `leadingCalendarDate`.
5190
+ if ((0, state_document_cjs_1.leadingCalendarDate)(lastActivity) === null) {
5191
+ warnings.push(stateDiagnostic('S008', SEVERITY.WARNING, `Unreadable last activity: "${lastActivity}" does not begin with a real calendar date, so no reader can date this project's activity`, 'Rewrite the Last activity line to begin with a date that exists, as "YYYY-MM-DD — what happened"'));
5192
+ }
5193
+ // The attached half of #3696: `templates/state.md` prescribes a single-line
5194
+ // field, but writers emit descriptions long enough to wrap, and
5195
+ // `stateExtractField`'s newline-excluding `(.+)` drops the remainder with no
5196
+ // diagnostic. The DOCUMENT is what violates the template here, so this
5197
+ // reports the violation rather than teaching the reader a multi-line grammar
5198
+ // the template does not sanction (ADR-3180 §7.7 Rejected #1 forbids widening
5199
+ // stateExtractField, which has 20 callers and a CRITICAL blast radius).
5200
+ //
5201
+ // Scan the body ONLY when the body is what was actually read. The ladder
5202
+ // prefers the frontmatter scalar, so a document carrying a clean
5203
+ // `last_activity:` in frontmatter AND a stale, wrapped `Last activity:` line
5204
+ // in the body would otherwise report S009 — and exit 1 under `--strict` —
5205
+ // over a remainder that no reader consumes and whose field is entirely
5206
+ // valid. Asking the owner with an EMPTY body isolates the frontmatter rung
5207
+ // without re-deriving the ladder here (which is what
5208
+ // `scripts/lint-state-field-drift.cjs` counts).
5209
+ const fromFrontmatter = (0, state_document_cjs_1.stateFieldValue)(fm, '', 'last_activity', 'Last activity').value;
5210
+ const dropped = fromFrontmatter !== null ? null : (0, state_document_cjs_1.stateFieldContinuation)(body, 'Last activity');
5211
+ if (dropped !== null) {
5212
+ warnings.push(stateDiagnostic('S009', SEVERITY.WARNING, `Truncated last activity description: "${dropped}" follows the Last activity line and is silently dropped by every reader`, 'Fold the Last activity description onto one line — the template prescribes a single-line field'));
5213
+ }
5214
+ }
3984
5215
  const valid = warnings.length === 0;
3985
- output({ valid, warnings, scope }, raw, undefined);
5216
+ emit({ valid, warnings, scope });
3986
5217
  }
3987
5218
  /**
3988
5219
  * Gate 2: Sync STATE.md from filesystem ground truth.
@@ -3997,6 +5228,19 @@ function cmdStateSync(cwd, options, raw) {
3997
5228
  }
3998
5229
  const verify = options && options.verify;
3999
5230
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
5231
+ // ADR-3473 §8.5 (#3881): `state sync` is on ADR-3408 §8.3's closed
5232
+ // sanctioned-regenerate list — "the body wins" — and `syncStateFrontmatter`
5233
+ // (below, via `writeStateMd`'s `sanctionedPermanentEmptyFallback`) is
5234
+ // therefore CORRECT to overwrite even an unparseable existing frontmatter
5235
+ // block (git merge-conflict markers, malformed YAML). What was missing was
5236
+ // disclosure: a derived conclusion (`synced: true`) must not be reported as
5237
+ // authoritative when the derivation dropped input it could not resolve
5238
+ // (§8.5) — silently destroying the only copy of an unreadable block with no
5239
+ // signal is "failure is a value" (§8.4) violated. Computed once, up front,
5240
+ // from the pre-write snapshot so both the `--verify` (dry-run) and the real
5241
+ // write branch can surface it identically.
5242
+ const existingSyncFm = extractFrontmatter(content, statePath);
5243
+ const syncFrontmatterWasUnparseable = isUnparseableFrontmatter(existingSyncFm);
4000
5244
  const changes = [];
4001
5245
  let modified = content;
4002
5246
  const phasesDir = planningPaths(cwd).phases;
@@ -4011,22 +5255,51 @@ function cmdStateSync(cwd, options, raw) {
4011
5255
  let syncRoadmapScope = null;
4012
5256
  let syncRoadmapRaw = null;
4013
5257
  let syncRetiredPhaseNums = new Set();
5258
+ const syncConvention = resolvePhaseIdConvention(cwd);
4014
5259
  try {
4015
5260
  const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
4016
5261
  if (roadmapRaw !== null) {
4017
5262
  syncRoadmapRaw = roadmapRaw;
4018
5263
  syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
4019
- syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
5264
+ syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention);
4020
5265
  }
4021
5266
  }
4022
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;
4023
5295
  // Scan all phases
4024
5296
  let entries;
4025
5297
  try {
4026
5298
  entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
4027
5299
  .filter(e => e.isDirectory())
4028
5300
  .map(e => e.name)
4029
- .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))
4030
5303
  .sort();
4031
5304
  }
4032
5305
  catch {
@@ -4074,28 +5347,18 @@ function cmdStateSync(cwd, options, raw) {
4074
5347
  }
4075
5348
  }
4076
5349
  // Determine total phases from ROADMAP (may be larger than realized disk dirs).
4077
- // Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B).
4078
- // DEAD catch removed (#2245 audit): every operation in this block is a regex
4079
- // exec/test over an already-read string plus pure Set/Math ops — none of
4080
- // 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.
4081
5358
  let syncTotalPhases = null;
4082
- let roadmapPhaseCount = 0;
4083
- if (syncRoadmapScope !== null) {
4084
- // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
4085
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
4086
- let m;
4087
- while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
4088
- // Only count tokens that contain at least one digit — excludes
4089
- // pure-word section headings (Overview, Details) while keeping
4090
- // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
4091
- if (!/\d/.test(m[1]))
4092
- continue;
4093
- // #1514: retired/folded phases are struck through; exclude from total.
4094
- if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1])))
4095
- continue;
4096
- roadmapPhaseCount++;
4097
- }
4098
- }
5359
+ const roadmapPhaseCount = syncRoadmapScope !== null
5360
+ ? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket')
5361
+ : 0;
4099
5362
  if (roadmapPhaseCount > 0) {
4100
5363
  syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
4101
5364
  }
@@ -4115,8 +5378,9 @@ function cmdStateSync(cwd, options, raw) {
4115
5378
  if (versionStr !== null && syncRoadmapRaw !== null) {
4116
5379
  // #3184: routed through the single owner (roadmap-parser.cjs) instead of
4117
5380
  // a hand-rolled, unbounded-substring re-derivation — see the identical
4118
- // fix in buildStateFrontmatter above.
4119
- 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);
4120
5384
  }
4121
5385
  let percent = null;
4122
5386
  if (!milestoneBounded) {
@@ -4134,6 +5398,20 @@ function cmdStateSync(cwd, options, raw) {
4134
5398
  // it here (discarding `.value`, which duplicates `entries`'s own
4135
5399
  // retired-phase-filtered listing) gets the real scope without changing
4136
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`.
4137
5415
  const syncScope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
4138
5416
  if (syncScope !== SCOPE.COMPLETE) {
4139
5417
  changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);
@@ -4147,12 +5425,30 @@ function cmdStateSync(cwd, options, raw) {
4147
5425
  modified = syncResult.content;
4148
5426
  const coreChanges = syncResult.data?.changes ?? [];
4149
5427
  changes.push(...coreChanges);
5428
+ // #3881 (ADR-3473 §8.5): only warn when a write will actually regenerate the
5429
+ // frontmatter — if nothing changed this run, the unparseable block (if any)
5430
+ // was never touched, so there is nothing to disclose. Mirrors the exact
5431
+ // condition the write branch below uses to decide whether to write at all.
5432
+ const syncWillWrite = changes.length > 0 || modified !== content;
5433
+ if (syncWillWrite && syncFrontmatterWasUnparseable) {
5434
+ const unparseableWarning = `gsd: warning — STATE.md's existing frontmatter could not be parsed (malformed YAML, or ` +
5435
+ `unresolved content such as git merge-conflict markers) and was regenerated from the body; ` +
5436
+ `any content in the old frontmatter block — including merge-conflict markers — has been ` +
5437
+ `replaced. (#3881)`;
5438
+ process.stderr.write(`${unparseableWarning}\n`);
5439
+ changes.push(unparseableWarning);
5440
+ }
4150
5441
  if (verify) {
4151
5442
  output({ synced: false, changes, dry_run: true }, raw, undefined);
4152
5443
  return;
4153
5444
  }
4154
- if (changes.length > 0 || modified !== content) {
4155
- writeStateMd(statePath, modified, cwd);
5445
+ if (syncWillWrite) {
5446
+ // ADR-3473 §8.6: `rebuild()` is the typed expression of #905's contract —
5447
+ // `state sync` exists to let the body win, so preservation must NOT run,
5448
+ // and the snapshot is carried anyway because §8.7's reporting needs it.
5449
+ writeStateMd(statePath, modified, stateTransitionMod.rebuildStateTransaction({
5450
+ snapshot: extractFrontmatter(content, statePath),
5451
+ }), cwd);
4156
5452
  }
4157
5453
  output({ synced: true, changes, dry_run: false }, raw, undefined);
4158
5454
  }
@@ -4494,16 +5790,38 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
4494
5790
  // flat `string[]` (the command's OUTPUT CONTRACT — unchanged) happens once
4495
5791
  // below, right before `output()`.
4496
5792
  const updated = [];
4497
- let preSyncContent = '';
4498
5793
  const divergedFields = [];
4499
- readModifyWriteStateMd(statePath, (content) => {
5794
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
5795
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
5796
+ const preWriteState = {};
5797
+ // #3835: complete-phase unconditionally rewrites the body `Phase:` line to
5798
+ // `N — COMPLETE` below. That defeats current_phase_name's
5799
+ // preserve-when-unchanged delta rule the same way #3834's no-`--name`
5800
+ // planned-phase write does — pre/post body-source disagree BY CONSTRUCTION
5801
+ // (this write is what changed the source line), so the post-sync
5802
+ // re-derivation harvests nothing from "COMPLETE" and the curated key is
5803
+ // dropped entirely rather than preserved. The write site already documents
5804
+ // "an absent name does NOT clear an existing curated value" for the body
5805
+ // (`Current Phase Name` section below) — this reasserts the same rule for
5806
+ // the frontmatter key, mirroring cmdStatePlannedPhase's fix.
5807
+ const rmwOptions = { divergedFields, preWriteState };
5808
+ const wrote = readModifyWriteStateMd(statePath, (content) => {
4500
5809
  const currentPhase = resolvedPhase;
4501
5810
  // Bug #1255: operate on body only so the YAML frontmatter `status:` key
4502
5811
  // cannot shadow the body Status field (pipe-table or inline).
4503
- const existingFm = extractFrontmatter(content, statePath);
4504
- const hasFrontmatter = Object.keys(existingFm).length > 0;
4505
- let body = stripFrontmatter(content);
4506
- const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
5812
+ //
5813
+ // ADR-3473 §8.1 (#3881 review, finding 5): previously this block hand-reimplemented
5814
+ // the isUnparseableFrontmatter/rawFrontmatterPrefix shape inline instead of using the
5815
+ // canonical helper — the sixth copy of a block already duplicated 5x in
5816
+ // state-transition.cts. Routed through the shared `beginFrontmatterReassembly` so this
5817
+ // module can never drift from the frontmatter-preservation contract state-transition.cts
5818
+ // enforces everywhere else.
5819
+ const { existingFm, body: initialBody, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
5820
+ let body = initialBody;
5821
+ const curatedPhaseName = existingFm['current_phase_name'];
5822
+ if (typeof curatedPhaseName === 'string' && curatedPhaseName.trim().length > 0) {
5823
+ rmwOptions.authoritativeFm = { current_phase_name: curatedPhaseName };
5824
+ }
4507
5825
  // Update Status field (body only — #1255)
4508
5826
  const statusValue = `Phase ${currentPhase} complete`;
4509
5827
  let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
@@ -4584,10 +5902,8 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
4584
5902
  updated.push({ kind: 'section', name: 'Current Position' });
4585
5903
  }
4586
5904
  }
4587
- const out = reassemble(body);
4588
- preSyncContent = out;
4589
- return out;
4590
- }, cwd, { divergedFields });
5905
+ return reassemble(body);
5906
+ }, cwd, rmwOptions);
4591
5907
  // ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
4592
5908
  // the analysis pass"). Unlike the transitionCore-based commands, this
4593
5909
  // adapter's `updated` mixes FIELD entries (Status, Last Activity, Last
@@ -4603,8 +5919,17 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
4603
5919
  // string-matching a name against a Set.
4604
5920
  const sectionEntries = updated.filter((e) => e.kind === 'section').map((e) => e.name);
4605
5921
  const fieldEntries = updated.filter((e) => e.kind === 'field').map((e) => e.name);
4606
- const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preSyncContent, fieldEntries, divergedFields)];
5922
+ const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preWriteState, fieldEntries, divergedFields)];
4607
5923
  output({ updated: reconciled, phase: resolvedPhase }, raw, reconciled.length > 0 ? 'true' : 'false');
5924
+ // #3227: gate on `wrote` (readModifyWriteStateMd's own return value), not
5925
+ // `reconciled.length > 0` — a re-run of complete-phase against a phase
5926
+ // that is ALREADY marked complete (same status/date/Current Position
5927
+ // values already on disk) still has `stateReplaceField` report a match for
5928
+ // every field it looks up, so `reconciled` is non-empty even though the
5929
+ // #948 no-op guard skipped the write. Same reasoning as
5930
+ // cmdStateBeginPhase/cmdStatePlannedPhase/cmdStateAdvancePlan above.
5931
+ if (wrote)
5932
+ publishStateContract(cwd);
4608
5933
  }
4609
5934
  module.exports = {
4610
5935
  stateExtractField: state_document_cjs_1.stateExtractField,
@@ -4659,6 +5984,23 @@ module.exports = {
4659
5984
  // FIELD_CLASSIFICATION, exposed so a parity test can pin that every
4660
5985
  // `preserve-when-unchanged` row has a label here.
4661
5986
  _FRONTMATTER_KEY_TO_BODY_LABEL: FRONTMATTER_KEY_TO_BODY_LABEL,
5987
+ // Test seam (ADR-3473 §8.7, #3872): the transaction diff and its pure
5988
+ // building blocks, exposed so the ~15 boundary/hostile/property rows in
5989
+ // the test matrix (dotted-path resolution, prototype-pollution safety,
5990
+ // string/number representation insensitivity, the provenance exclusion)
5991
+ // can be driven directly with fabricated snapshot/persisted objects
5992
+ // instead of round-tripping every case through a full RMW write.
5993
+ _reconcileReportedFields: reconcileReportedFields,
5994
+ _computeChangedFrontmatterFields: computeChangedFrontmatterFields,
5995
+ _resolveFrontmatterPath: resolveFrontmatterPath,
5996
+ _stateFieldValuesDiffer: stateFieldValuesDiffer,
5997
+ _STATE_UPDATED_PROVENANCE_EXCLUSION: STATE_UPDATED_PROVENANCE_EXCLUSION,
5998
+ // Test seam (#3873 phase-3 test matrix row 9): `bodyLabelFor` itself is not
5999
+ // otherwise reachable from outside this module. Exposed so a test can drive
6000
+ // the real STATE_BODY_LABEL_UNWIRED_ROW throw directly, rather than only
6001
+ // pinning the table it reads (`_FRONTMATTER_KEY_TO_BODY_LABEL`) against
6002
+ // itself.
6003
+ _bodyLabelFor: bodyLabelFor,
4662
6004
  // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
4663
6005
  // steal decision is exercised without real pids. Mirrors capability-lock.cts.
4664
6006
  _setLockProbes(probes) {