@opengsd/gsd-core 1.10.0 → 1.12.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 (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  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 +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  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 +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -11,18 +11,26 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  };
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
+ const pattern_cjs_1 = require("./pattern.cjs");
14
15
  // eslint-disable-next-line @typescript-eslint/no-require-imports
15
16
  const ioMod = require("./io.cjs");
16
17
  const { output, error } = ioMod;
17
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;
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
25
  const configLoaderMod = require("./config-loader.cjs");
19
26
  const { loadConfig } = configLoaderMod;
20
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
28
  const phaseIdMod = require("./phase-id.cjs");
22
- const { escapeRegex, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir } = phaseIdMod;
29
+ const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId, scopeToPhase, } = phaseIdMod;
23
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
31
  const roadmapParserMod = require("./roadmap-parser.cjs");
25
- const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
32
+ // #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.
33
+ const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasAnyMilestoneSection } = roadmapParserMod;
26
34
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
27
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
36
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -30,16 +38,57 @@ const { planningDir, planningPaths } = planningWorkspace;
30
38
  const clock_cjs_1 = require("./clock.cjs");
31
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
40
  const frontmatter = require("./frontmatter.cjs");
33
- const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter;
41
+ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel, FRONTMATTER_UNPARSEABLE } = frontmatter;
42
+ /**
43
+ * ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
44
+ * `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a frontmatter-fenced region
45
+ * exists but failed to parse (malformed YAML, or a refused anchor/alias/merge key)? Mirrors
46
+ * `state-transition.cts`'s private helper of the same name/shape — kept local rather than
47
+ * exported+imported because the two modules' `existingFm` values come from independent
48
+ * `extractFrontmatter` calls and this predicate is a two-line symbol read, not shared state.
49
+ */
50
+ function isUnparseableFrontmatter(existingFm) {
51
+ return existingFm[FRONTMATTER_UNPARSEABLE] === true;
52
+ }
34
53
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
54
  const scanPhasePlans = require("./plan-scan.cjs");
36
55
  // eslint-disable-next-line @typescript-eslint/no-require-imports
56
+ const verificationMod = require("./verification.cjs");
57
+ const { isPhaseComplete } = verificationMod;
58
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
59
+ const planningScopeMod = require("./planning-scope.cjs");
60
+ const { SCOPE } = planningScopeMod;
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
62
+ const phaseLocatorMod = require("./phase-locator.cjs");
63
+ const { listMilestonePhaseDirs } = phaseLocatorMod;
64
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
65
  const stateTransitionMod = require("./state-transition.cjs");
66
+ // #3873 (ADR-3473 §8.8): FRONTMATTER_KEY_TO_BODY_LABEL below is now a
67
+ // projection of this leaf schema rather than a hand-maintained literal.
68
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
69
+ const stateMdSchemaMod = require("./state-md-schema.cjs");
70
+ // #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only
71
+ // node builtins, so it introduces no cycle on this path.
72
+ const project_root_cjs_1 = require("./project-root.cjs");
73
+ // #3311: advisory (phase, session) claim over the single Current Position slot.
74
+ // Imports only node builtins + planning-workspace + active-workstream-store, so
75
+ // it introduces no cycle on this path.
76
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
77
+ const milestoneLockMod = require("./milestone-lock.cjs");
38
78
  const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
79
+ // #3699: the frontmatter-key <-> body-field routing behind `state update`'s
80
+ // failure explanation, and the classification table it falls back to.
81
+ const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
82
+ // ADR-3473 §8.7 (#3872): the declared dotted-leaf enumeration `reconcileReportedFields`
83
+ // diffs against — see `declaredLeavesOf` below.
84
+ const { FIELD_CLASSIFICATION } = stateTransitionMod;
39
85
  const state_document_cjs_1 = require("./state-document.cjs");
40
86
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
41
87
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
42
88
  const validate_cjs_1 = require("./validate.cjs");
89
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
90
+ const healthDiagnosticTypesMod = require("./health-diagnostic-types.cjs");
91
+ const { SEVERITY, adviseRemedy } = healthDiagnosticTypesMod;
43
92
  const STATE_PROGRESS_RESYNC_FIELDS = new Set([
44
93
  'Progress',
45
94
  'Total Plans in Phase',
@@ -212,7 +261,7 @@ function cmdStateLoad(cwd, raw) {
212
261
  `state_exists=${stateExists}`,
213
262
  ];
214
263
  process.stdout.write(lines.join('\n'));
215
- process.exit(0);
264
+ throw new ExitError(0);
216
265
  }
217
266
  output(result, false, undefined);
218
267
  }
@@ -229,7 +278,7 @@ function cmdStateGet(cwd, section, raw) {
229
278
  return;
230
279
  }
231
280
  // Try to find markdown section or field
232
- const fieldEscaped = escapeRegex(section);
281
+ const fieldEscaped = (0, pattern_cjs_1.escapeRegex)(section);
233
282
  // Check for **field:** value (bold format)
234
283
  const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
235
284
  const boldMatch = content.match(boldPattern);
@@ -290,18 +339,87 @@ function cmdStatePatch(cwd, patches, raw) {
290
339
  // #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name
291
340
  // delta (table-driven) that this phase adds. Field-name validation (security)
292
341
  // and the resync-progress decision stay in this adapter.
293
- let results = { updated: [], failed: [] };
342
+ let precomputed = { updated: [], failed: [] };
343
+ const divergedFields = [];
344
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
345
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
346
+ const preWriteState = {};
294
347
  readModifyWriteStateMd(statePath, (content) => {
295
348
  const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock });
296
- results = result.data ?? results;
349
+ precomputed = result.data ?? precomputed;
297
350
  return result.content;
298
- }, cwd, { resync: shouldResync });
351
+ }, cwd, { resync: shouldResync, divergedFields, explicitProgressField: shouldResync, preWriteState });
352
+ // ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
353
+ // patchCore's bookkeeping says whether the stateReplaceField text-replace
354
+ // MATCHED — but its plain-line pattern (`m` flag over the full document)
355
+ // can match the YAML frontmatter line for a lower-cased key, and the write
356
+ // pipeline (syncStateFrontmatter re-derivation + the FIELD_CLASSIFICATION
357
+ // preservation rows) then discards or restores that text before the file is
358
+ // saved. A field is only reported `updated` when its post-write on-disk
359
+ // value equals what THIS transform actually wrote (the frontmatter key
360
+ // when present, else the body field — the legitimate working case for
361
+ // state.patch is display-cased BODY fields — Status, Current Plan, Phase —
362
+ // which are never frontmatter keys). Also folds in any field
363
+ // `applyStatePreservation` restored that this patch never named at all
364
+ // (#3345's direction), a case the pre-#3471 version of this command never
365
+ // covered.
366
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields);
367
+ const updatedSet = new Set(updated);
368
+ const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
369
+ const results = { updated, failed };
299
370
  output(results, raw, results.updated.length > 0 ? 'true' : 'false');
300
371
  }
301
372
  catch {
302
373
  error('STATE.md not found');
303
374
  }
304
375
  }
376
+ /**
377
+ * Why did `state update <field>` not write anything?
378
+ *
379
+ * #3699: this used to be one sentence — `Field "X" not found in STATE.md` — for
380
+ * every falsy outcome, so a PRESENT-but-derived frontmatter key and a genuinely
381
+ * absent field produced byte-identical output apart from the name. The classifier
382
+ * already knew the difference; the message threw it away, and worse, pointed away
383
+ * from the route that works.
384
+ *
385
+ * Four distinct answers, because there are four distinct situations:
386
+ * 1. a body-derived frontmatter key whose body source EXISTS → name that source
387
+ * 2. a frontmatter key with no body source at all (disk/external/clock-derived)
388
+ * → say what derives it, and do not invent a body field to blame
389
+ * 3. a body field that feeds a frontmatter key still carrying a value
390
+ * → name the key, so case D is diagnosable rather than a bare absence
391
+ * 4. genuinely unknown → unchanged
392
+ */
393
+ function explainUpdateFailure(field) {
394
+ const bodySource = getFrontmatterBodySource(field);
395
+ if (bodySource) {
396
+ // (1) The fallback in `updateCore` did not fire, so a body source line
397
+ // exists — that is the writable route.
398
+ const [primary] = bodySource;
399
+ return `Field "${field}" is a body-derived frontmatter key and is not directly writable. `
400
+ + `Update its body source instead: state update "${primary}" <value>.`;
401
+ }
402
+ const classification = getFieldClassification(field);
403
+ if (classification) {
404
+ // (2) Known key, no body source: disk/external/free-derived.
405
+ const derivedFrom = {
406
+ disk: 'derived from a scan of .planning/phases/ and is not directly writable',
407
+ external: 'derived from ROADMAP.md and is not directly writable',
408
+ free: 'recomputed on every write and is not directly writable',
409
+ curated: 'maintained by the write path and is not directly writable through this command',
410
+ body: 'body-derived and is not directly writable',
411
+ };
412
+ return `Field "${field}" is a frontmatter key that is ${derivedFrom[classification.source]}.`;
413
+ }
414
+ const owningKey = frontmatterKeyForBodyField(field);
415
+ if (owningKey) {
416
+ // (3) Case D from the body-field side.
417
+ return `Field "${field}" not found in STATE.md. It is the body source for frontmatter key `
418
+ + `"${owningKey}" — add the "${field}:" line to the body, or update "${owningKey}" directly `
419
+ + `to repair a document whose body source is missing.`;
420
+ }
421
+ return `Field "${field}" not found in STATE.md`; // (4) genuinely unknown
422
+ }
305
423
  function cmdStateUpdate(cwd, field, value) {
306
424
  if (!field || value === undefined) {
307
425
  error('field and value required for state update');
@@ -316,6 +434,31 @@ function cmdStateUpdate(cwd, field, value) {
316
434
  const statePath = planningPaths(cwd).state;
317
435
  try {
318
436
  let updated = false;
437
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
438
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
439
+ const preWriteState = {};
440
+ let transitionData;
441
+ // #3699 case D: when `updateCore` falls back to writing the frontmatter key
442
+ // directly, the value must survive the post-sync pass — otherwise the write
443
+ // is silently undone. `buildStateFrontmatter` re-derives `stopped_at` from
444
+ // the body, finds no source, and emits nothing; `applyPreserveWhenUnchanged`
445
+ // then sees an unchanged (absent) body source and restores the PRE-write
446
+ // snapshot over the value just written. Verified: without this the command
447
+ // reported `updated:false` with `preserved:["Stopped At"]` and the old value
448
+ // stood.
449
+ //
450
+ // `authoritativeFm` is the seam built for exactly this (#2736 — "intent-first
451
+ // frontmatter values … so the lossy body-prose re-derivation can never
452
+ // destroy information the transition just resolved"), and it is re-applied
453
+ // AFTER preservation (`applyPostSyncPreservation`), so it wins the restore.
454
+ //
455
+ // Populated by the transform below rather than up front, because only the
456
+ // transition knows whether the fallback fired. Safe: `readModifyWriteStateMd`
457
+ // dereferences `options.authoritativeFm` after running the transform. Left
458
+ // empty when the fallback does not fire — an empty object iterates zero
459
+ // entries and is a no-op at both application sites.
460
+ const authoritativeFm = {};
461
+ const divergedFields = [];
319
462
  const shouldResync = shouldResyncStateProgress([field]);
320
463
  // ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
321
464
  // body-strip/reassemble single-field update is the pure `updateCore` in
@@ -326,13 +469,50 @@ function cmdStateUpdate(cwd, field, value) {
326
469
  readModifyWriteStateMd(statePath, (content) => {
327
470
  const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock });
328
471
  updated = result.data?.updated === true;
472
+ transitionData = result.data;
473
+ if (transitionData?.wroteFrontmatter === true) {
474
+ authoritativeFm[field] = value;
475
+ }
329
476
  return result.content;
330
- }, cwd, { resync: shouldResync });
477
+ }, cwd, { resync: shouldResync, divergedFields, authoritativeFm, explicitProgressField: shouldResync, preWriteState });
478
+ // ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
479
+ // `updateCore`'s own match does not know whether sync/preservation later
480
+ // discarded the value it wrote (#3351's direction, generalized from
481
+ // `cmdStatePatch`). `preserved` folds in any OTHER field preservation
482
+ // restored during this write that this command never touched at all
483
+ // (#3345's direction) — reported separately from `updated` because this
484
+ // command's contract is a single-field boolean, not a per-field array.
485
+ const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
486
+ updated = reconciled.includes(field);
487
+ const preserved = reconciled.filter((f) => f !== field);
331
488
  if (updated) {
332
- output({ updated: true }, false, undefined);
489
+ // #3699 case D: surfaced so a caller can tell "wrote the body source" from
490
+ // "wrote the frontmatter key because no body source existed" — the second
491
+ // is a repair, and silently reporting it as an ordinary update is the same
492
+ // class of unfalsifiable success this issue is about.
493
+ const wroteFrontmatter = transitionData?.wroteFrontmatter === true;
494
+ if (!wroteFrontmatter) {
495
+ output({ updated: true, preserved }, false, undefined);
496
+ }
497
+ else {
498
+ // `preserved` reports the BODY LABEL of each field preservation restored
499
+ // (`bodyLabelFor`, the #3345 direction). In the case-D fallback that
500
+ // reading is stale by one step: preservation DID restore this field's
501
+ // snapshot, and `authoritativeFm` then overrode it, so the value on disk
502
+ // is the one just written. Reporting it as preserved would claim a
503
+ // restore that did not survive — the same unfalsifiable-success shape
504
+ // #3699 is about, one field over. Drop this field's own labels; other
505
+ // fields' preservation is untouched and still reported.
506
+ const ownLabels = new Set((getFrontmatterBodySource(field) ?? []).map((l) => l.toLowerCase()));
507
+ output({
508
+ updated: true,
509
+ wrote: 'frontmatter',
510
+ preserved: preserved.filter((p) => !ownLabels.has(p.toLowerCase())),
511
+ }, false, undefined);
512
+ }
333
513
  }
334
514
  else {
335
- output({ updated: false, reason: `Field "${field}" not found in STATE.md` }, false, undefined);
515
+ output({ updated: false, reason: explainUpdateFailure(field), preserved }, false, undefined);
336
516
  }
337
517
  }
338
518
  catch {
@@ -378,21 +558,74 @@ function cmdStateAdvancePlan(cwd, raw) {
378
558
  sourcePath: statePath,
379
559
  };
380
560
  let resultData;
381
- readModifyWriteStateMd(statePath, (content) => {
561
+ let precomputedUpdated = [];
562
+ const divergedFields = [];
563
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
564
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
565
+ const preWriteState = {};
566
+ // #3311: the milestone (phase + session) claim is consulted INSIDE the
567
+ // STATE.md lock, so the position read and the claim read cannot interleave
568
+ // with another session's Current Position write.
569
+ let milestoneConflict = null;
570
+ const wrote = readModifyWriteStateMd(statePath, (content) => {
571
+ // advance-plan has no phase argument of its own — the phase it advances is
572
+ // whatever ## Current Position names. Compare that against the milestone
573
+ // claim: a mismatch means another session moved the single-slot position
574
+ // away from the claimed phase (the #3311 flip) and must be surfaced, not
575
+ // silently absorbed.
576
+ const body = stripFrontmatter(content);
577
+ const positionScope = matchCurrentPositionSection(body) ?? body;
578
+ const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
579
+ if (positionPhase !== null) {
580
+ milestoneConflict = milestoneLockMod.checkMilestonePosition(cwd, positionPhase);
581
+ if (milestoneConflict) {
582
+ milestoneLockMod.warnMilestoneConflict(milestoneConflict, 'state.advance-plan');
583
+ }
584
+ }
382
585
  const result = transitionCore(content, intent, deps);
383
586
  resultData = result.data;
587
+ precomputedUpdated = result.updated;
384
588
  return result.content;
385
- }, cwd);
589
+ }, cwd, { divergedFields, preWriteState });
386
590
  if (!resultData || resultData['error']) {
591
+ // #3807: a multi-`Phase:` Current Position section carries its own cause
592
+ // and its own remedy (name the candidates; the caller resolves them).
593
+ if (resultData && resultData['reason'] === 'ambiguous_position_phase') {
594
+ output({
595
+ 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.',
596
+ reason: resultData['reason'],
597
+ phase_candidates: resultData['phase_candidates'],
598
+ }, raw, undefined);
599
+ return;
600
+ }
387
601
  output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
388
602
  return;
389
603
  }
604
+ // ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
605
+ // the bytes actually persisted — this command previously reported none of
606
+ // its per-field writes at all (`updated` never left `advancePlanCore`).
607
+ // Generalizes fix(#3351) (closes #3351's direction) and folds in any field
608
+ // preservation restored that this transform never touched (#3345's
609
+ // direction).
610
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
390
611
  if (resultData['advanced'] === false) {
391
- output(resultData, raw, 'false');
612
+ output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'false');
392
613
  }
393
614
  else {
394
- output(resultData, raw, 'true');
615
+ output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
395
616
  }
617
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): a refreshed
618
+ // state.json `updated_at` must always mean something on disk actually
619
+ // moved, in EITHER branch above — so gate on `wrote`
620
+ // (readModifyWriteStateMd's own return value) rather than assuming both
621
+ // branches are unconditional mutations. They are not: re-running
622
+ // advance-plan on a phase already parked in its post-advance state (e.g.
623
+ // two same-day calls once a phase is "ready for verification") reproduces
624
+ // byte-identical content, the #948 no-op guard skips the write, and
625
+ // `resultData`/`updated` still populate normally from the transform's OWN
626
+ // (unwritten) output — so those are not safe publish signals here either.
627
+ if (wrote)
628
+ publishStateContract(cwd);
396
629
  }
397
630
  function cmdStateRecordMetric(cwd, options, raw) {
398
631
  const statePath = planningPaths(cwd).state;
@@ -546,35 +779,126 @@ function cmdStateRecordMetric(cwd, options, raw) {
546
779
  result['created'] = true;
547
780
  output(result, raw, 'true');
548
781
  }
782
+ /**
783
+ * #3583: computes the write-path percent AND the completed/total plan counts
784
+ * reported alongside it from ONE `buildStateFrontmatter` call, so
785
+ * `cmdStateUpdateProgress`'s JSON output cannot report a `percent` that
786
+ * disagrees with its own `completed`/`total` (`buildStateFrontmatter`'s
787
+ * `progress.{percent,completed_plans,total_plans}` all come from the same
788
+ * disk scan, scoped to the STORED `milestone:` frontmatter value — #3017).
789
+ * Re-deriving completed/total from a second, differently-scoped scan (the
790
+ * auto-derived one `cmdStateUpdateProgress` still runs for its own #3217/
791
+ * #3233 withhold gates) is what let the two disagree when the auto-derived
792
+ * "current" milestone differs from the stored one.
793
+ *
794
+ * Perf note: this duplicates buildStateFrontmatter's own `getMilestoneInfo`
795
+ * (re-reads/re-parses ROADMAP.md) and `readGitHeadSha` (a `git rev-parse`
796
+ * subprocess spawn) — neither is memoized, unlike the phase/plan disk scan
797
+ * (`_diskScanCache`), which IS shared with the second `buildStateFrontmatter`
798
+ * call `readModifyWriteStateMd` makes below. Both non-cached calls therefore
799
+ * run twice per `state update-progress`.
800
+ */
801
+ function computeUpdateProgressPreview(statePath, cwd) {
802
+ const preContent = node_fs_1.default.readFileSync(statePath, 'utf-8');
803
+ const existingFm = extractFrontmatter(preContent, statePath);
804
+ const preBody = stripFrontmatter(preContent);
805
+ const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
806
+ const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
807
+ const progress = builtFm['progress'];
808
+ const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
809
+ const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
810
+ const totalPlans = progress && typeof progress['total_plans'] === 'number' ? progress['total_plans'] : null;
811
+ // A null percent is REACHABLE beyond the #3217/#3233 withholds the caller
812
+ // already applies — buildStateFrontmatter also nulls it via its own #1761
813
+ // milestone-unbounded guard, evaluated from `assertedMilestoneVersion`
814
+ // (an independent derivation, including a bare-version-token-in-prose
815
+ // fallback) rather than from `storedMilestone`/diskScope, so a STATE.md
816
+ // with no explicit `milestone:` field but a bare vX.Y token mentioned in
817
+ // ROADMAP prose can pass both of the caller's guards and still land here.
818
+ // Falling back to a locally-computed percent would reintroduce the exact
819
+ // #3583 defect for that case, so withhold instead — same shape as the
820
+ // caller's own no-op guards.
821
+ if (percent === null || completedPlans === null || totalPlans === null) {
822
+ return { withheld: true, reason: 'progress percent withheld by buildStateFrontmatter — STATE.md left unchanged' };
823
+ }
824
+ return { withheld: false, percent, completedPlans, totalPlans };
825
+ }
549
826
  function cmdStateUpdateProgress(cwd, raw) {
550
827
  const statePath = planningPaths(cwd).state;
551
828
  if (!node_fs_1.default.existsSync(statePath)) {
552
829
  output({ error: 'STATE.md not found' }, raw, undefined);
553
830
  return;
554
831
  }
555
- // Count summaries across current milestone phases only (outside lock — read-only)
832
+ // Auto-derived scan across current-milestone phases (outside lock — read-only).
833
+ // Gates the #3217/#3233 withholds below ONLY — the reported completed/total
834
+ // counts come from computeUpdateProgressPreview's differently-scoped
835
+ // (stored-milestone) scan instead, so percent and completed/total can never
836
+ // disagree (#3583, finding 1).
556
837
  const phasesDir = planningPaths(cwd).phases;
557
838
  let totalPlans = 0;
558
- let totalSummaries = 0;
559
- if (node_fs_1.default.existsSync(phasesDir)) {
560
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
561
- const phaseDirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
562
- .filter(e => e.isDirectory()).map(e => e.name)
563
- .filter(isDirInMilestone);
839
+ let phaseScope = SCOPE.UNREADABLE;
840
+ {
841
+ // #3185 (ADR-3180 Decision 1): "which phase directories belong to the
842
+ // CURRENT milestone" — routed through the canonical owner instead of a
843
+ // hand-rolled readdirSync + isDirInMilestone filter (which also never
844
+ // excluded sentinels, unlike the owner). The owner already handles an
845
+ // absent phasesDir as a real empty, so the fs.existsSync guard folds
846
+ // into it.
847
+ const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
848
+ phaseScope = scope;
564
849
  for (const dir of phaseDirs) {
565
- const { planCount, summaryCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
850
+ const { planCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
566
851
  totalPlans += planCount;
567
- totalSummaries += summaryCount;
568
852
  }
569
853
  }
570
- const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
854
+ // #3217 (ADR-3180 §7.6 rule 4): a non-COMPLETE scope means the counts
855
+ // above are not a trustworthy answer — do not write a percentage derived
856
+ // from them into STATE.md at all (A7). This is the write path, so
857
+ // "withhold" means "make no edit" rather than emitting a null value.
858
+ if (phaseScope !== SCOPE.COMPLETE) {
859
+ // #3217 finding 3 (decided: surface a warning, not silent-only
860
+ // disclosure): the JSON `reason` field alone is easy for a caller to
861
+ // never read, and STATE.md's Progress field goes stale with no
862
+ // user-visible signal beyond it. Mirrors the established
863
+ // `[gsd-tools] WARNING:` stderr convention this file already uses
864
+ // (stateReplaceFieldWithFallback above) for a comparable silent no-op.
865
+ process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — phase scope is ${phaseScope}, not complete. ` +
866
+ `STATE.md's Progress field was left unchanged.\n`);
867
+ output({ updated: false, reason: `phase scope is ${phaseScope}, not complete` }, raw, 'false');
868
+ return;
869
+ }
870
+ // #3233: zero plans in the current-milestone phases means there is nothing to
871
+ // measure — most often the milestone was just closed and its phases archived
872
+ // (.planning/phases/ empty, but scope COMPLETE — "a real empty"). clampPercent
873
+ // maps 0/0 to 0%, which would clobber the shipped Progress record (e.g.
874
+ // [██████████] 100% → [░░░░░░░░░░] 0%). No-op instead, mirroring the
875
+ // scope-withhold above and computeProgressPercent's null-for-empty contract
876
+ // ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist,
877
+ // none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0.
878
+ if (totalPlans === 0) {
879
+ process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — no plans found in current-milestone phases (0 plans). ` +
880
+ `STATE.md's Progress field was left unchanged (milestone archived?).\n`);
881
+ output({ updated: false, reason: 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)' }, raw, 'false');
882
+ return;
883
+ }
884
+ // #3583: percent AND the completed/total counts reported alongside it both
885
+ // come from the SAME buildStateFrontmatter call (computeUpdateProgressPreview)
886
+ // — never from the auto-derived scan above, which exists only to gate the
887
+ // #3217/#3233 withholds and is scoped differently (no stored-milestone
888
+ // override), so reusing its counts here could report a percent that
889
+ // disagrees with its own completed/total.
890
+ const preview = computeUpdateProgressPreview(statePath, cwd);
891
+ if (preview.withheld) {
892
+ process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — ${preview.reason}\n`);
893
+ output({ updated: false, reason: preview.reason }, raw, 'false');
894
+ return;
895
+ }
896
+ const { percent, completedPlans: fmCompletedPlans, totalPlans: fmTotalPlans } = preview;
571
897
  const barWidth = 10;
572
898
  const filled = Math.round(percent / 100 * barWidth);
573
899
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
574
900
  const progressStr = `[${bar}] ${percent}%`;
575
901
  let updated = false;
576
- const _totalPlans = totalPlans;
577
- const _totalSummaries = totalSummaries;
578
902
  readModifyWriteStateMd(statePath, (content) => {
579
903
  // #2177: match against the BODY only. With /i the patterns below would
580
904
  // otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would
@@ -603,7 +927,7 @@ function cmdStateUpdateProgress(cwd, raw) {
603
927
  return fmPrefix + body.replace(pattern, (_match, prefix, value) => `${prefix}${replaceValue(value)}`);
604
928
  }, cwd);
605
929
  if (updated) {
606
- output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
930
+ output({ updated: true, percent, completed: fmCompletedPlans, total: fmTotalPlans, bar: progressStr }, raw, progressStr);
607
931
  }
608
932
  else {
609
933
  output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
@@ -630,7 +954,20 @@ function cmdStateAddDecision(cwd, options, raw) {
630
954
  output({ error: 'summary required' }, raw, undefined);
631
955
  return;
632
956
  }
633
- const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
957
+ // #3231/#3481: `--phase` omitted → resolve from the STATE.md being written, via
958
+ // the canonical ladder `state prune` uses. A decision entry is a permanent
959
+ // record, so a literal `[Phase ?]` written while `current_phase` sat three
960
+ // lines above the insertion point loses that decision's provenance for good.
961
+ // Explicit `--phase` still wins, and its path is untouched — the file is not
962
+ // even read. When no rung resolves, `?` is still written: an unknown phase
963
+ // stays visibly unknown rather than being guessed or defaulted to a number.
964
+ let phaseId = phase;
965
+ if (!phaseId) {
966
+ const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
967
+ const fm = extractFrontmatter(rawState, statePath);
968
+ phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
969
+ }
970
+ const entry = `- [Phase ${phaseId || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
634
971
  let _added = false;
635
972
  let created = false;
636
973
  readModifyWriteStateMd(statePath, (content) => {
@@ -780,7 +1117,20 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
780
1117
  const actionText = (action && action.trim()) || 'changed';
781
1118
  const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
782
1119
  const urgentText = urgent ? ' (URGENT)' : '';
783
- const entry = `- Phase ${phase || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
1120
+ // #3481: same treatment as add-decision's #3231 fix — `--phase` omitted →
1121
+ // resolve from the STATE.md being written via the shared write-path ladder.
1122
+ // A roadmap-evolution entry is a permanent record of why the roadmap changed
1123
+ // shape, so a literal `Phase ?` written while `current_phase` sat in the
1124
+ // frontmatter above the insertion point makes that trail unattributable.
1125
+ // Explicit `--phase` still wins (the file is not even read on that path), and
1126
+ // `?` is still written when nothing resolves — never a guess.
1127
+ let phaseId = phase;
1128
+ if (!phaseId) {
1129
+ const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
1130
+ const fm = extractFrontmatter(rawState, statePath);
1131
+ phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
1132
+ }
1133
+ const entry = `- Phase ${phaseId || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
784
1134
  let duplicate = false;
785
1135
  let created = false;
786
1136
  let subsectionCreated = false;
@@ -936,6 +1286,10 @@ function cmdStateRecordSession(cwd, options, raw) {
936
1286
  const now = clock_cjs_1.realClock.nowIso();
937
1287
  const updated = [];
938
1288
  let sessionCreated = false;
1289
+ const divergedFields = [];
1290
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
1291
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
1292
+ const preWriteState = {};
939
1293
  readModifyWriteStateMd(statePath, (content) => {
940
1294
  // Update Last session / Last Date
941
1295
  let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
@@ -949,13 +1303,25 @@ function cmdStateRecordSession(cwd, options, raw) {
949
1303
  updated.push('Last Date');
950
1304
  }
951
1305
  // Update Stopped at
1306
+ // #3374 Variant B: stateReplaceField returns the replaced string on any
1307
+ // label MATCH, including when the value is already the target. Pushing
1308
+ // 'Stopped At' on match alone reported a write that never changed a byte
1309
+ // (and that the #948 no-op guard may then discard entirely), leaving a
1310
+ // stale frontmatter stopped_at undetectable to the caller. Report only on
1311
+ // real change — and track the match separately so an identical value does
1312
+ // not read as "label missing" to the #944 DWIM insertion below (whose
1313
+ // section rewrite would reset an executor-authored resume file to None).
1314
+ let stoppedAtMatched = false;
952
1315
  if (options.stopped_at) {
953
1316
  result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped At', options.stopped_at);
954
1317
  if (!result)
955
1318
  result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped at', options.stopped_at);
956
1319
  if (result) {
957
- content = result;
958
- updated.push('Stopped At');
1320
+ stoppedAtMatched = true;
1321
+ if (result !== content) {
1322
+ content = result;
1323
+ updated.push('Stopped At');
1324
+ }
959
1325
  }
960
1326
  }
961
1327
  // Update Resume File — only when the caller explicitly passed a value OR the
@@ -1011,7 +1377,10 @@ function cmdStateRecordSession(cwd, options, raw) {
1011
1377
  // missing canonical fields are inserted while the heading and any prose are
1012
1378
  // preserved (#1101). Only append a brand-new section when NEITHER heading exists.
1013
1379
  const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
1014
- const needsStoppedAt = options.stopped_at && !updated.includes('Stopped At');
1380
+ // #3374: keyed on the label MATCH, not on updated[] — a matched-but-
1381
+ // identical value is already persisted on disk and must not trigger the
1382
+ // insertion rewrite below.
1383
+ const needsStoppedAt = options.stopped_at && !stoppedAtMatched;
1015
1384
  const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
1016
1385
  const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date');
1017
1386
  if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
@@ -1135,9 +1504,14 @@ function cmdStateRecordSession(cwd, options, raw) {
1135
1504
  }
1136
1505
  }
1137
1506
  return content;
1138
- }, cwd);
1139
- if (updated.length > 0) {
1140
- const result = { recorded: true, updated };
1507
+ }, cwd, { divergedFields, preWriteState });
1508
+ // ADR-3408 §8.4 (D4): reconcile this command's own success list against the
1509
+ // bytes actually persisted (fix(#3351) generalized) and fold in any field
1510
+ // preservation restored that this transform never touched (#3345's
1511
+ // direction).
1512
+ const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields);
1513
+ if (reconciledUpdated.length > 0) {
1514
+ const result = { recorded: true, updated: reconciledUpdated };
1141
1515
  if (sessionCreated)
1142
1516
  result['created'] = true;
1143
1517
  output(result, raw, 'true');
@@ -1184,11 +1558,14 @@ function matchSessionSection(body) {
1184
1558
  * excludes unrelated headings. Built on the same `collectSection` seam as
1185
1559
  * matchSessionSection, so it inherits that seam's CRLF tolerance (#2444 fix).
1186
1560
  * Returns the section body, or null (caller falls back to full-body search).
1561
+ *
1562
+ * The scoping logic now lives in state-document.cjs's `stateCurrentPositionSlice`
1563
+ * (the module that owns STATE.md field extraction) — this is a thin alias kept
1564
+ * for call-site stability. Two copies of this scope would be exactly the kind
1565
+ * of generative-fix divergence the repo's parity rule exists to prevent.
1187
1566
  */
1188
1567
  function matchCurrentPositionSection(body) {
1189
- const isCurrentPosition = (h) => (h.level === 2 || h.level === 3) && h.text.trim().toLowerCase() === 'current position';
1190
- const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, isCurrentPosition, { levelBounded: true });
1191
- return section ? section.body : null;
1568
+ return (0, state_document_cjs_1.stateCurrentPositionSlice)(body);
1192
1569
  }
1193
1570
  /**
1194
1571
  * #2567: prevent a stale archive "Last activity:" line from overwriting a
@@ -1215,19 +1592,18 @@ function preferNewerLastActivity(existingFm, derivedFm) {
1215
1592
  const derDate = derRaw.slice(0, 10);
1216
1593
  if (!/^\d{4}-\d{2}-\d{2}$/.test(exDate) || !/^\d{4}-\d{2}-\d{2}$/.test(derDate))
1217
1594
  return;
1595
+ // #3258: this guard now protects only `last_activity` (a `derive` row) against
1596
+ // the stale-archive regression (#2567). `last_activity_desc` used to be
1597
+ // restored here too (both the older-date and the #3052 same-date branches),
1598
+ // but that was a date-comparison rule — a DIFFERENT policy from the
1599
+ // `preserve-when-unchanged` row its FIELD_CLASSIFICATION entry declares.
1600
+ // Keeping both was two rules that could disagree. last_activity_desc is now
1601
+ // governed by exactly one rule: its table row, enforced by
1602
+ // applyStatePreservation's #1230 delta heuristic on the RMW path (where every
1603
+ // desc-preserving transition — planned-phase / advance / complete / milestone
1604
+ // — runs). The #3052 same-date contract still holds via that delta rule.
1218
1605
  if (derDate < exDate) {
1219
1606
  derivedFm['last_activity'] = exRaw;
1220
- if (existingFm['last_activity_desc'] !== undefined) {
1221
- derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
1222
- }
1223
- }
1224
- else if (derDate === exDate) {
1225
- // #3052: same-date — frontmatter is authoritative for this date, so
1226
- // preserve its last_activity_desc rather than letting the derived body
1227
- // prose (which may be stale) overwrite it.
1228
- if (existingFm['last_activity_desc'] !== undefined) {
1229
- derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
1230
- }
1231
1607
  }
1232
1608
  }
1233
1609
  function parseProsePhaseField(value) {
@@ -1239,6 +1615,76 @@ function parseProsePhaseField(value) {
1239
1615
  // current_phase instead of clobbering it.
1240
1616
  return parsePhaseFromProse(value);
1241
1617
  }
1618
+ function resolveStatePhase(fm, body) {
1619
+ const currentPositionScope = matchCurrentPositionSection(body) ?? body;
1620
+ const frontmatterRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', null).value;
1621
+ const legacyRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase').value;
1622
+ const currentPositionRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Phase').value;
1623
+ const sources = {
1624
+ frontmatter: parseProsePhaseField(frontmatterRaw).phase,
1625
+ legacy_current_phase: parseProsePhaseField(legacyRaw).phase,
1626
+ current_position_phase: parseProsePhaseField(currentPositionRaw).phase,
1627
+ };
1628
+ const prosePhase = parseProsePhaseField(currentPositionRaw);
1629
+ return {
1630
+ phase: sources.frontmatter ?? sources.legacy_current_phase ?? sources.current_position_phase,
1631
+ name: (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase_name', null).value
1632
+ ?? (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase Name').value
1633
+ ?? prosePhase.name,
1634
+ sources,
1635
+ };
1636
+ }
1637
+ /**
1638
+ * Resolve a STATE.md's own current phase id from the document itself — the
1639
+ * WRITE-PATH ladder shared by `cmdStateAddDecision` (#3231) and
1640
+ * `cmdStateAddRoadmapEvolution` (#3481), extracted from the ladder
1641
+ * `cmdStatePrune` already ran (#1760).
1642
+ *
1643
+ * The rungs are the canonical ones owned by state-document.cjs's
1644
+ * `stateFieldValue` (#3187, ADR-3180 §7.7): frontmatter `current_phase` → body
1645
+ * `Current Phase` field → prose `Phase: X of Y` scoped to `## Current
1646
+ * Position`.
1647
+ *
1648
+ * #1776: the prose rung stays scoped to `## Current Position`. Over the whole
1649
+ * body, `stateExtractField`'s pipe-table fallback matches any `| Phase | N |`
1650
+ * row — e.g. a historical verification table — and would resolve a stale phase.
1651
+ * Frontmatter and the explicit `Current Phase` field are unambiguous, so they
1652
+ * stay document-wide. `cmdStateSnapshot` deliberately keeps the looser
1653
+ * whole-body fallback for its own prose rung and is not routed through here.
1654
+ *
1655
+ * Returns the id exactly as written, NOT parsed to a number: phase ids are not
1656
+ * always integers (`11-01` and `04.1` are both real). Callers needing an
1657
+ * integer parse it themselves. Returns null when no rung carries a value — a
1658
+ * genuinely absent phase is a real answer (§7.7 behavior table row 4), and
1659
+ * callers must render it as unknown rather than guess one.
1660
+ *
1661
+ * NOT the same function as `resolveStatePhase` above (#3208), and deliberately
1662
+ * not routed through it — the difference is one line and it is the whole point:
1663
+ *
1664
+ * resolveStatePhase: matchCurrentPositionSection(body) ?? body
1665
+ * resolveCurrentPhaseId: null when the section is absent
1666
+ *
1667
+ * That `?? body` fallback is exactly the #1776 hazard. With no `## Current
1668
+ * Position` section, the prose rung widens to the entire document, where
1669
+ * `stateExtractField`'s pipe-table fallback matches any `| Phase | N |` row —
1670
+ * a historical verification table included — and resolves a stale phase.
1671
+ *
1672
+ * `resolveStatePhase`'s callers (`cmdStateSnapshot`, `cmdStateValidate`) READ
1673
+ * and report; a stale guess there is a wrong line in output a human is already
1674
+ * looking at. This function's callers WRITE: `cmdStateAddDecision` and
1675
+ * `cmdStateAddRoadmapEvolution` persist the result into records that outlive
1676
+ * the session, and `cmdStatePrune` decides what to delete from it. A wrong
1677
+ * phase there is durable and silent, so the write path takes the strict rung
1678
+ * and renders `?` rather than guessing.
1679
+ *
1680
+ * Reconcile the two only by giving `resolveStatePhase` an explicit scope
1681
+ * parameter — never by pointing this at it and dropping the difference.
1682
+ */
1683
+ function resolveCurrentPhaseId(fm, body) {
1684
+ const positionSection = sliceCurrentPositionSection(body);
1685
+ const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value).phase : null;
1686
+ return (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ?? prosePhase;
1687
+ }
1242
1688
  function parseProseLastActivityField(value) {
1243
1689
  if (!value)
1244
1690
  return { date: null, description: null };
@@ -1264,21 +1710,9 @@ function cmdStateSnapshot(cwd, raw) {
1264
1710
  // reported under a content digest — STATE.md is one of the artefacts epic #1879 is about.
1265
1711
  const fm = extractFrontmatter(content, statePath);
1266
1712
  const body = stripFrontmatter(content);
1267
- // Helper: return frontmatter scalar value when present and non-empty.
1268
- // Accepts strings, numbers, and booleans — coercing non-string primitives to
1269
- // their string representation so callers always receive string | null.
1270
- // Returns null for missing, null/undefined, or empty-after-trim values so
1271
- // the caller falls back to body extraction.
1272
- const fmScalar = (key) => {
1273
- const v = fm[key];
1274
- if (v === null || v === undefined)
1275
- return null;
1276
- if (typeof v === 'string')
1277
- return v.trim() || null;
1278
- if (typeof v === 'number' || typeof v === 'boolean')
1279
- return String(v);
1280
- return null;
1281
- };
1713
+ // #3187: frontmatter-scalar-then-body-field precedence is owned by
1714
+ // state-document.cjs's `stateFieldValue` (ADR-3180 §7.7) — this function no
1715
+ // longer holds its own fmScalar ladder.
1282
1716
  // Extract basic fields — frontmatter keys take precedence over body
1283
1717
  // #2956: scope `Phase` extraction to ## Current Position so a historical
1284
1718
  // Phase: / **Phase:** line in an archive section cannot overwrite the current
@@ -1286,25 +1720,24 @@ function cmdStateSnapshot(cwd, raw) {
1286
1720
  // so it is scopeable exactly like Stopped At under ## Session. Fall back to
1287
1721
  // full-body search only when no ## Current Position section exists, so files
1288
1722
  // with no section heading keep their current behaviour.
1289
- const currentPositionScope = matchCurrentPositionSection(body) ?? body;
1290
- const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'));
1291
- const currentPhase = fmScalar('current_phase') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') ?? prosePhase.phase;
1292
- const currentPhaseName = fmScalar('current_phase_name') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase Name') ?? prosePhase.name;
1293
- const totalPhasesRaw = fmScalar('total_phases') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Total Phases');
1294
- const currentPlan = fmScalar('current_plan') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
1295
- const totalPlansRaw = fmScalar('total_plans_in_phase') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Total Plans in Phase');
1296
- const status = fmScalar('status') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Status');
1297
- const progressRaw = fmScalar('progress') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Progress');
1298
- const rawLastActivity = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last activity');
1723
+ const resolvedPhase = resolveStatePhase(fm, body);
1724
+ const currentPhase = resolvedPhase.phase;
1725
+ const currentPhaseName = resolvedPhase.name;
1726
+ const totalPhasesRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_phases', 'Total Phases').value;
1727
+ const currentPlan = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_plan', 'Current Plan').value;
1728
+ const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
1729
+ const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value;
1730
+ const progressRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'progress', 'Progress').value;
1731
+ const rawLastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, null, 'Last Activity').value ?? (0, state_document_cjs_1.stateFieldValue)(fm, body, null, 'Last activity').value;
1299
1732
  const proseLastActivity = parseProseLastActivityField(rawLastActivity);
1300
- const lastActivity = fmScalar('last_activity') ?? proseLastActivity.date ?? rawLastActivity;
1301
- const lastActivityDesc = fmScalar('last_activity_desc') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity Description') ?? proseLastActivity.description;
1733
+ const lastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity', null).value ?? proseLastActivity.date ?? rawLastActivity;
1734
+ const lastActivityDesc = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity_desc', 'Last Activity Description').value ?? proseLastActivity.description;
1302
1735
  // #2956: Paused At canonically lives in ## Session (see the comment above
1303
1736
  // preferNewerLastActivity and the write seam in buildStateFrontmatter). The
1304
1737
  // write seam already scopes it to ## Session; this read seam must agree, so a
1305
1738
  // stale "Paused At:" in a Session Continuity Archive cannot win here either.
1306
1739
  const sessionScope = matchSessionSection(body) ?? body;
1307
- const pausedAt = fmScalar('paused_at') ?? (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
1740
+ const pausedAt = (0, state_document_cjs_1.stateFieldValue)(fm, sessionScope, 'paused_at', 'Paused At').value;
1308
1741
  // Parse numeric fields
1309
1742
  const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1310
1743
  const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
@@ -1430,7 +1863,7 @@ function extractRetiredPhaseNumbers(scope) {
1430
1863
  * a YAML frontmatter object. Allows hooks and scripts to read state
1431
1864
  * reliably via `state json` instead of fragile regex parsing.
1432
1865
  */
1433
- function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1866
+ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases) {
1434
1867
  // #2956: scope `Phase` extraction to ## Current Position (mirrors the read
1435
1868
  // path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
1436
1869
  // below). Phase canonically lives in ## Current Position (templates/state.md);
@@ -1464,14 +1897,46 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1464
1897
  const pausedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Paused At');
1465
1898
  let milestone = null;
1466
1899
  let milestoneName = null;
1900
+ // #1761 regression fix (#3216): the milestone STATE.md actually ASSERTS,
1901
+ // independent of whether getMilestoneInfo's identity scope is COMPLETE.
1902
+ // Needed below by the disk-scan block's `isMilestoneBoundedInRoadmap` guard
1903
+ // — that check answers "is the ASSERTED version bounded to a versioned
1904
+ // ROADMAP heading", a different question from "is the identity trustworthy
1905
+ // enough to persist" (`milestone` above). Conflating the two regressed
1906
+ // #1761: when a real STATE `milestone:` value has no matching ROADMAP
1907
+ // heading, `info.scope` is never COMPLETE (rightly — there's no curated
1908
+ // name to persist), but the version was still genuinely asserted and the
1909
+ // bounded check must still run on it, or the guard silently no-ops and
1910
+ // `state json` reports a conflated whole-document total_phases/percent.
1911
+ let assertedMilestoneVersion = null;
1467
1912
  if (cwd) {
1468
1913
  // DEAD catch removed (#2245 audit): getMilestoneInfo has its own outer
1469
1914
  // try/catch (roadmap-parser.cts) that already swallows every internal
1470
- // failure and always returns a MilestoneInfo — it never throws, so this
1915
+ // failure and always returns a ScopedResult — it never throws, so this
1471
1916
  // wrapper could never be triggered.
1917
+ // #3216 (ADR-3180 §7.2 rule 6): this is the #3197 disk-write path. Rule 6
1918
+ // draws the line at the FIELD, not the scope as a whole — "a version known
1919
+ // but no name resolvable is TRUNCATED carrying {version, name: null} — the
1920
+ // version is a real answer, the name is a non-answer, and collapsing the
1921
+ // two is the failure this contract exists to prevent." So `milestone`
1922
+ // (the version) is written whenever COMPLETE or TRUNCATED — both carry a
1923
+ // genuine version per rule 6 — while `milestoneName` is written only on
1924
+ // COMPLETE, since TRUNCATED's name is by definition unresolved and must
1925
+ // never be fabricated. UNSCOPED/UNREADABLE have no real version either
1926
+ // way, so both stay null there. This mirrors cmdCommit (src/commands.cts),
1927
+ // which accepts COMPLETE or TRUNCATED for the same reason (the version is
1928
+ // real), and deliberately diverges from archivePhaseDirectories
1929
+ // (src/milestone.cts), which demands COMPLETE only because it uses the
1930
+ // value as a filesystem path component and a TRUNCATED version is not
1931
+ // safe to use there.
1472
1932
  const info = getMilestoneInfo(cwd);
1473
- milestone = info.version;
1474
- milestoneName = info.name;
1933
+ assertedMilestoneVersion = info.value ? info.value.version : null;
1934
+ if ((info.scope === SCOPE.COMPLETE || info.scope === SCOPE.TRUNCATED) && info.value) {
1935
+ milestone = info.value.version;
1936
+ }
1937
+ if (info.scope === SCOPE.COMPLETE && info.value) {
1938
+ milestoneName = info.value.name;
1939
+ }
1475
1940
  }
1476
1941
  let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1477
1942
  let completedPhases = null;
@@ -1480,6 +1945,14 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1480
1945
  // #1761 read-path: set from cached.milestoneBounded inside the disk-scan
1481
1946
  // block; consumed at the percent computation to mirror the cmdStateSync guard.
1482
1947
  let milestoneUnbounded = false;
1948
+ // #3217 (ADR-3180 §7.6 rule 4, finding 1): the real listMilestonePhaseDirs
1949
+ // scope for the disk-scanned counts below, set from cached.phaseDirScope
1950
+ // when a fresh disk scan runs. SCOPE.COMPLETE is the correct default here
1951
+ // — NOT a rule-4 hardcode — for the cases where no disk scan happens at all
1952
+ // (no cwd, or phasesDir absent): totalPhases/totalPlans then come straight
1953
+ // from the pre-existing frontmatter fields parsed above, a path this phase
1954
+ // does not touch and which predates listMilestonePhaseDirs entirely.
1955
+ let diskScope = SCOPE.COMPLETE;
1483
1956
  if (cwd) {
1484
1957
  try {
1485
1958
  const phasesDir = planningPaths(cwd).phases;
@@ -1507,14 +1980,16 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1507
1980
  // #3017: scope the milestone filter to the STORED milestone when available,
1508
1981
  // so a state.* write doesn't auto-derive (and mis-bind) to a different
1509
1982
  // milestone's heading and clobber the stored value + progress counts.
1510
- const isDirInMilestone = getMilestonePhaseFilter(cwd, storedMilestone ?? undefined);
1511
- const allMatchingDirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
1512
- .filter(e => e.isDirectory()).map(e => e.name)
1513
- .filter(isDirInMilestone);
1983
+ // #3185 (ADR-3180 Decision 1): "which phase directories belong to the
1984
+ // CURRENT (stored) milestone" — routed through the canonical owner
1985
+ // instead of a hand-rolled readdirSync + isDirInMilestone filter
1986
+ // (which also never excluded sentinels, unlike the owner).
1987
+ const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: storedMilestone ?? null });
1514
1988
  // Bug #2445: when stale phase dirs from a prior milestone remain in
1515
1989
  // .planning/phases/ alongside new dirs with the same phase number,
1516
- // de-duplicate by normalized phase number keeping the most recently
1517
- // modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
1990
+ // de-duplicate by normalized phase number keeping exactly one dir
1991
+ // per key (deterministic tie-break: see #3355 below). This prevents
1992
+ // double-counting (e.g. two "Phase 1" dirs).
1518
1993
  const seenPhaseNums = new Map(); // normalizedNum -> dirName
1519
1994
  for (const dir of allMatchingDirs) {
1520
1995
  // #1514: a retired/folded phase keeps a directory but no completion
@@ -1523,22 +1998,35 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1523
1998
  // exclusion below). Project-code-aware via phaseKeyFromDir.
1524
1999
  if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
1525
2000
  continue;
1526
- // phase-id-owner: dir-name dedup grouping; diverges from extractPhaseToken/phaseKeyFromDir on project-code-prefixed and multi-segment milestone dirs. Kept local.
1527
- const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1528
- const key = m ? m[1].toLowerCase() : dir;
2001
+ // #3185: dedup grouping routed through the canonical phaseKeyFromDir
2002
+ // (src/phase-id.cts) instead of a local leading-digits regex that
2003
+ // diverged from extractPhaseToken/phaseKeyFromDir on
2004
+ // project-code-prefixed dirs (whole dirname fell through as the key,
2005
+ // so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
2006
+ // multi-segment milestone dirs. Same key surface used two lines
2007
+ // above for the retiredPhaseNums exclusion, so both filters agree.
2008
+ const key = phaseKeyFromDir(dir);
1529
2009
  if (!seenPhaseNums.has(key)) {
1530
2010
  seenPhaseNums.set(key, dir);
1531
2011
  }
1532
2012
  else {
1533
- // Keep the dir that is newer on disk (more likely current milestone)
1534
- try {
1535
- const existing = node_path_1.default.join(phasesDir, seenPhaseNums.get(key));
1536
- const candidate = node_path_1.default.join(phasesDir, dir);
1537
- if (node_fs_1.default.statSync(candidate).mtimeMs > node_fs_1.default.statSync(existing).mtimeMs) {
1538
- seenPhaseNums.set(key, dir);
1539
- }
1540
- }
1541
- catch { /* keep existing on stat error */ }
2013
+ // #3355: the survivor of a same-milestone collision must be
2014
+ // chosen from repository CONTENT, never from filesystem state.
2015
+ // The pre-#3355 tie-break was `mtimeMs` — a checkout-order
2016
+ // signal — so two byte-identical checkouts of the same commit
2017
+ // that wrote the colliding dirs in a different order picked
2018
+ // different survivors, and progress.total_plans /
2019
+ // completed_plans drifted across clones and CI runs. The
2020
+ // directory NAME is git-tracked content and a total order, so
2021
+ // the lexicographically-first dir wins deterministically. The
2022
+ // collision is still a project-level defect (duplicate phase
2023
+ // number in scope), so it is surfaced on stderr instead of
2024
+ // being silently resolved. The Bug #2445 invariant — exactly
2025
+ // one survivor per normalized phase number — is unchanged.
2026
+ const incumbent = seenPhaseNums.get(key);
2027
+ const survivor = dir < incumbent ? dir : incumbent;
2028
+ seenPhaseNums.set(key, survivor);
2029
+ process.stderr.write(`gsd: warning — phase directories '${incumbent}' and '${dir}' both normalize to phase key '${key}' (duplicate phase number in .planning/phases/); keeping '${survivor}' by deterministic lexicographic order. (#3355)\n`);
1542
2030
  }
1543
2031
  }
1544
2032
  const phaseDirs = [...seenPhaseNums.values()];
@@ -1547,10 +2035,18 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1547
2035
  let diskCompletedPhases = 0;
1548
2036
  for (const dir of phaseDirs) {
1549
2037
  const phaseDir = node_path_1.default.join(phasesDir, dir);
1550
- const { planCount, summaryCount, completed } = scanPhasePlans(phaseDir);
2038
+ const { planCount, summaryCount } = scanPhasePlans(phaseDir);
1551
2039
  diskTotalPlans += planCount;
1552
2040
  diskTotalSummaries += summaryCount;
1553
- if (completed)
2041
+ // ADR-3180 §7.4 (#3186, #2957 disk-strict): "which phases are
2042
+ // complete" is the completion question, routed through the single
2043
+ // canonical owner (isPhaseComplete, src/verification.cts) — NOT
2044
+ // scanPhasePlans's own `completed` field, which only answers "are
2045
+ // all plans summarized" (a different question; see plan-scan.cts's
2046
+ // own comment on that field). Folding this consumer onto the raw
2047
+ // summaries-met flag was the exact "consolidate two of three and
2048
+ // leave the third" gap §7.4's forcing function rules out.
2049
+ if (isPhaseComplete(phaseDir).value.complete)
1554
2050
  diskCompletedPhases++;
1555
2051
  }
1556
2052
  // Count phase headings from ROADMAP using a digit-containing pattern
@@ -1567,8 +2063,9 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1567
2063
  // Only count tokens that contain at least one digit — excludes
1568
2064
  // pure-word section headings (Overview, Details) while keeping
1569
2065
  // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
1570
- // Also exclude 999.x backlog phases. Mirrors init.cts filter.
1571
- if (!/\d/.test(m[1]) || /^999\b/.test(m[1]))
2066
+ // Also exclude sentinel phases (0 and 999.x backlog).
2067
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
2068
+ if (!/\d/.test(m[1]) || isSentinelPhaseId(m[1]))
1572
2069
  continue;
1573
2070
  // #1514: retired/folded phases are struck through in the ROADMAP;
1574
2071
  // exclude them from the denominator (they can never be completed).
@@ -1586,9 +2083,22 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1586
2083
  // phase-dir count only, and mark unbounded so percent is skipped
1587
2084
  // downstream (mirrors the sync write-path guard).
1588
2085
  let milestoneBounded = true;
1589
- if (milestone && roadmapRaw !== null) {
1590
- const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(String(milestone).trim())}`, 'mi');
1591
- milestoneBounded = versionedHeading.test(roadmapRaw);
2086
+ // #3216 fix (#1761 regression): use `assertedMilestoneVersion` —
2087
+ // the version STATE.md actually asserts — not the scope-gated
2088
+ // `milestone`. `milestone` is null on any non-COMPLETE identity
2089
+ // scope (deliberately, so a non-trustworthy identity never
2090
+ // persists), but a real asserted version with no matching
2091
+ // ROADMAP heading is EXACTLY the unbounded case this guard exists
2092
+ // to catch; gating on `milestone` skipped the guard entirely and
2093
+ // let the whole-document roadmapPhaseCount conflate sibling
2094
+ // milestones again.
2095
+ if (assertedMilestoneVersion && roadmapRaw !== null) {
2096
+ // #3184: routed through the single owner (roadmap-parser.cjs)
2097
+ // instead of a hand-rolled, unbounded-substring re-derivation —
2098
+ // the prior inline regex had no boundary assertion after the
2099
+ // version token, so `v2.0` matched inside `v2.0.1` (#2562-class
2100
+ // defect, design row 17).
2101
+ milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(assertedMilestoneVersion).trim());
1592
2102
  }
1593
2103
  // #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
1594
2104
  // at all — only Phase headings) from a MILESTONED-but-unbounded one
@@ -1596,27 +2106,94 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1596
2106
  // On a flat roadmap the whole-doc count is correct (no sibling milestones to
1597
2107
  // conflate); on a sectioned-but-unbounded one it conflates siblings (#1761),
1598
2108
  // so fall back to phaseDirs.length.
1599
- const hasMilestoneSectioning = roadmapRaw !== null
1600
- && /^#{2,3}\s+(?!Phase\s+\S)/mi.test(roadmapRaw);
2109
+ // #3184: routed through the single owner (roadmap-parser.cjs) —
2110
+ // deliberately weaker than isMilestoneBoundedInRoadmap above (no
2111
+ // version-token requirement); see hasMilestoneSectioning's own
2112
+ // doc comment for why that distinction is load-bearing.
2113
+ // #3642: the flat test uses the >=1 sibling (hasAnyMilestoneSection),
2114
+ // not the >=2 predicate. >=2 under-answers the question this branch
2115
+ // asks: with EXACTLY ONE milestone section and an asserted milestone
2116
+ // absent from the ROADMAP, >=2 read "flat" and the whole-document
2117
+ // count — which IS that single section's phases — was written as the
2118
+ // asserted milestone's total, silently clobbering the stored value.
2119
+ // The >=2 threshold governs SIBLING conflation; asserted-vs-section
2120
+ // needs only one section to go wrong. Zero sections (genuinely flat)
2121
+ // keeps the whole-document count, per #2828.
2122
+ const roadmapHasAnyMilestoneSection = roadmapRaw !== null
2123
+ && hasAnyMilestoneSection(roadmapRaw);
1601
2124
  const safeToUseRoadmapCount = milestoneBounded
1602
- || (roadmapPhaseCount > 0 && !hasMilestoneSectioning);
2125
+ || (roadmapPhaseCount > 0 && !roadmapHasAnyMilestoneSection);
2126
+ // #3354: the milestoned-but-unbounded sibling of the #2828/#3204
2127
+ // shapes. The whole-document roadmapPhaseCount is rightly rejected
2128
+ // above (it would conflate sibling milestones, #1761), but the
2129
+ // on-disk phase-dir count is NOT an authoritative substitute for
2130
+ // the rejected total either — it counts only the current
2131
+ // milestone's realized directories (25 declared → 4 written in the
2132
+ // issue's report), silently shrinking progress.total_phases on
2133
+ // every STATE.md write. Mirror the branch's own percent withhold
2134
+ // (milestoneUnbounded below): return a null sentinel so the caller
2135
+ // keeps the pre-existing stored value instead of writing the
2136
+ // substitute, and warn on stderr naming the unbounded token so the
2137
+ // operator can curate the ROADMAP heading or the STATE assertion.
2138
+ // The degenerate un-sectioned zero-heading case keeps the
2139
+ // phaseDirs.length fallback — with nothing declared anywhere else,
2140
+ // the disk count is the only source and remains correct.
2141
+ const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
2142
+ if (milestonedButUnbounded) {
2143
+ process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3354/#3642)\n`);
2144
+ }
2145
+ // #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
2146
+ // absent/unreadable the #549 heading counter never ran (roadmapScope
2147
+ // stayed null), `milestoneBounded` is vacuously true (its gate requires
2148
+ // roadmapRaw), and the dir count — which only ever counts phases that
2149
+ // have STARTED — would be persisted as progress.total_phases by every
2150
+ // state.* write. A STATE that asserts a milestone (storedMilestone —
2151
+ // getMilestoneInfo is useless here, it reads the roadmap that is
2152
+ // absent) declared a total somewhere; keep the stored frontmatter
2153
+ // value instead. Without an asserted milestone (fresh project,
2154
+ // pre-roadmap) the disk count is still the only source and stays
2155
+ // authoritative (the #3354 doctrine's degenerate case).
2156
+ const roadmapAbsentWithAssertedMilestone = roadmapRaw === null &&
2157
+ typeof storedMilestone === 'string' &&
2158
+ storedMilestone.trim() !== '';
2159
+ if (roadmapAbsentWithAssertedMilestone) {
2160
+ process.stderr.write(`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3573)\n`);
2161
+ }
1603
2162
  return {
1604
- totalPhases: safeToUseRoadmapCount
1605
- ? Math.max(phaseDirs.length, roadmapPhaseCount)
1606
- : phaseDirs.length,
2163
+ // The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
2164
+ // roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
2165
+ // safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
2166
+ // vacuously true (its gate requires roadmapRaw), so the safe-count
2167
+ // arm would otherwise swallow the withhold.
2168
+ totalPhases: (milestonedButUnbounded || roadmapAbsentWithAssertedMilestone)
2169
+ ? null
2170
+ : (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
1607
2171
  milestoneBounded,
1608
2172
  completedPhases: diskCompletedPhases,
1609
2173
  totalPlans: diskTotalPlans,
1610
2174
  completedPlans: diskTotalSummaries,
2175
+ phaseDirScope,
1611
2176
  };
1612
2177
  })();
1613
2178
  _diskScanCache.set(cwd, cached);
1614
2179
  }
1615
- totalPhases = cached.totalPhases;
2180
+ // #3354: cached.totalPhases === null is the milestoned-but-unbounded
2181
+ // WITHHOLD sentinel — the scan refused to substitute the dir count for
2182
+ // a rejected whole-document total, so keep the pre-existing value:
2183
+ // the stored frontmatter total when the caller can supply it, else the
2184
+ // body "Total Phases" annotation already parsed above, else leave null
2185
+ // (the key is omitted from the progress block).
2186
+ if (cached.totalPhases !== null) {
2187
+ totalPhases = cached.totalPhases;
2188
+ }
2189
+ else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
2190
+ totalPhases = storedTotalPhases;
2191
+ }
1616
2192
  completedPhases = cached.completedPhases;
1617
2193
  totalPlans = cached.totalPlans;
1618
2194
  completedPlans = cached.completedPlans;
1619
2195
  milestoneUnbounded = cached.milestoneBounded === false;
2196
+ diskScope = cached.phaseDirScope;
1620
2197
  }
1621
2198
  /* best-effort (#2245 audit): this is a READ path building STATE.md's
1622
2199
  * display frontmatter. The real throw source is fs.readdirSync(phasesDir)
@@ -1633,17 +2210,66 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1633
2210
  // ROADMAP-declared-but-unrealized future phases cap the reported completion
1634
2211
  // instead of a false 100% from plan-only coverage (#3242 Bug B).
1635
2212
  // Falls back to the body Progress: field only when no plan files exist on disk.
1636
- let progressPercent = (0, state_document_cjs_1.computeProgressPercent)(completedPlans, totalPlans, completedPhases, totalPhases);
2213
+ // #3217 (ADR-3180 §7.6 rule 4, finding 1): computeProgressPercent requires
2214
+ // a `Scope` for its own rule-4 gate. `diskScope` is the real
2215
+ // `listMilestonePhaseDirs` scope threaded through `_diskScanCache`
2216
+ // (`phaseDirScope` above) when a fresh disk scan ran — an UNREADABLE
2217
+ // phases dir now withholds here exactly as it does at every sibling
2218
+ // surface, closing the cross-surface disagreement the isolated review
2219
+ // caught. When no disk scan ran at all (no cwd, or phasesDir absent)
2220
+ // `diskScope` keeps its SCOPE.COMPLETE default, preserving this
2221
+ // function's pre-existing behavior on that (unrelated, pre-dating
2222
+ // listMilestonePhaseDirs) fallback path. This call site also keeps its own
2223
+ // orthogonal `milestoneUnbounded` null-out below (#1761) — a different
2224
+ // guard (ROADMAP heading boundedness, not disk readability).
2225
+ let progressPercent = (0, state_document_cjs_1.computeProgressPercent)(completedPlans, totalPlans, completedPhases, totalPhases, diskScope);
1637
2226
  // #1761 read-path: when the milestone can't be bounded, percent would be
1638
2227
  // derived from a conflated/understated total — skip it (mirror cmdStateSync).
1639
2228
  if (milestoneUnbounded)
1640
2229
  progressPercent = null;
1641
- if (progressPercent === null && progressRaw && !milestoneUnbounded) {
2230
+ // #3217 finding 1 (follow-on): a non-COMPLETE diskScope must withhold the
2231
+ // percentage EVERYWHERE, including this prose fallback — without the
2232
+ // `diskScope === SCOPE.COMPLETE` guard, a stale/existing "Progress: N%"
2233
+ // body line would silently defeat computeProgressPercent's rule-4 null,
2234
+ // re-introducing a rendered percentage on the exact scope this phase
2235
+ // withholds for (this is how the reviewer's UNREADABLE-phases fixture
2236
+ // could still surface a number even after the scope threading above).
2237
+ if (progressPercent === null && progressRaw && !milestoneUnbounded && diskScope === SCOPE.COMPLETE) {
1642
2238
  const pctMatch = progressRaw.match(/(\d+)%/);
1643
2239
  if (pctMatch)
1644
2240
  progressPercent = parseInt(pctMatch[1], 10);
1645
2241
  }
1646
- const normalizedStatus = (0, state_document_cjs_1.normalizeStateStatus)(status, pausedAt);
2242
+ let normalizedStatus = (0, state_document_cjs_1.normalizeStateStatus)(status, pausedAt);
2243
+ // #3578: normalizeStateStatus matches 'complete' as a case-insensitive
2244
+ // SUBSTRING, so the phase-completion prose cmdStateCompletePhase writes to
2245
+ // the body (`Phase ${N} complete`) collapses to the milestone-level
2246
+ // 'completed' status even when other phases remain open. Phase-level
2247
+ // prose must never decide milestone-level status — completedPhases /
2248
+ // totalPhases / diskScope, already derived above from a disk scan, are
2249
+ // the authority on whether the MILESTONE is actually done. Only override
2250
+ // when: (a) normalizeStateStatus actually landed on 'completed'; (b) the
2251
+ // raw prose is UNAMBIGUOUSLY phase-completion prose — the anchored
2252
+ // pattern below deliberately excludes "All phases complete" (no `\S+`
2253
+ // phase token) and milestone-close prose like "v1.0 milestone complete"
2254
+ // (no leading "phase"); and (c) the counters are trustworthy (a COMPLETE
2255
+ // disk scope, both counts are finite numbers, and a positive
2256
+ // denominator) and affirmatively disagree with 'completed'. In every
2257
+ // other case normalizedStatus is left exactly as normalizeStateStatus
2258
+ // returned it.
2259
+ if (normalizedStatus === 'completed' &&
2260
+ typeof status === 'string' &&
2261
+ /^\s*phase\s+\S+\s+complete\s*$/i.test(status) &&
2262
+ diskScope === SCOPE.COMPLETE &&
2263
+ // #1761: an unbounded milestone yields a conflated/understated total — the
2264
+ // same authority that nulls progressPercent above. Without this, a bad
2265
+ // denominator could demote a genuinely-complete milestone.
2266
+ !milestoneUnbounded &&
2267
+ typeof completedPhases === 'number' && Number.isFinite(completedPhases) &&
2268
+ typeof totalPhases === 'number' && Number.isFinite(totalPhases) &&
2269
+ totalPhases > 0 &&
2270
+ completedPhases < totalPhases) {
2271
+ normalizedStatus = 'executing';
2272
+ }
1647
2273
  const fm = { gsd_state_version: '1.0' };
1648
2274
  if (milestone)
1649
2275
  fm['milestone'] = milestone;
@@ -1665,6 +2291,13 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1665
2291
  fm['last_activity'] = lastActivity;
1666
2292
  if (lastActivityDesc)
1667
2293
  fm['last_activity_desc'] = lastActivityDesc;
2294
+ // #2573: stamp the commit this STATE.md was written against, so consumers can
2295
+ // report how far the codebase has moved since. Omitted entirely outside a git
2296
+ // repo — an absent field reads as "unknown", which is the honest answer and
2297
+ // keeps every consumer's tri-state intact (see readStateHeadFreshness).
2298
+ const stateHead = readGitHeadSha(cwd);
2299
+ if (stateHead)
2300
+ fm['state_head'] = stateHead;
1668
2301
  const progress = {};
1669
2302
  if (totalPhases !== null)
1670
2303
  progress['total_phases'] = totalPhases;
@@ -1680,18 +2313,227 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1680
2313
  fm['progress'] = progress;
1681
2314
  return fm;
1682
2315
  }
1683
- function syncStateFrontmatter(content, cwd, authoritativeFm) {
2316
+ // ─── state_head commit provenance (#2573) ────────────────────────────────────
2317
+ //
2318
+ // STATE.md records the commit it was written against (`state_head`); consumers
2319
+ // derive how many commits the codebase has moved since. This mirrors the shipped
2320
+ // graphify commit-staleness contract (src/graphify.cts, #3170) rather than
2321
+ // inventing a second vocabulary: `commits_behind` is a count, and `commit_stale`
2322
+ // is TRI-STATE — null means "we don't know" (no git, no stamp, unresolvable
2323
+ // commit), which is deliberately distinct from false ("known fresh").
2324
+ //
2325
+ // IMPORTANT — this is a freshness PROXY, never a drift measurement.
2326
+ // `rev-list state_head..HEAD` counts every commit in between, including ones
2327
+ // that never touched anything STATE.md describes. And because `state_head`
2328
+ // restamps on EVERY state write, a low count means "something wrote STATE
2329
+ // recently", NOT "STATE's content is accurate". Consumers must word it as
2330
+ // approximate and must never gate on it.
2331
+ /** Strict hash fence before any value from disk reaches a git argument. */
2332
+ const STATE_HEAD_HASH_RE = /^[0-9a-f]{4,40}$/i;
2333
+ /**
2334
+ * Resolve the project's current HEAD sha, or null when unavailable.
2335
+ * Bounded + non-interactive via execGit (10s timeout, GIT_TERMINAL_PROMPT=0);
2336
+ * a non-repo, missing git, or timeout degrades to null rather than throwing.
2337
+ */
2338
+ /**
2339
+ * Does the project root carry its own git repository?
2340
+ *
2341
+ * #2573 D5. `git rev-parse HEAD` walks UP from cwd and stops at the FIRST
2342
+ * enclosing `.git`. So the repo that answered is the project's own exactly when
2343
+ * the project root itself carries a `.git` entry — a directory for a normal
2344
+ * clone, a file for a worktree or submodule, both of which `existsSync` accepts.
2345
+ * If it does not, the answer necessarily came from an ancestor repo and the
2346
+ * stamp would assert provenance the project cannot claim.
2347
+ *
2348
+ * Deliberately a filesystem-identity check rather than comparing
2349
+ * `--show-toplevel` against the project root as strings. That comparison is
2350
+ * unreliable across platforms — macOS resolves temp dirs through
2351
+ * `/private/var/…`, Windows adds 8.3 short names and separator/case variance —
2352
+ * and an over-strict compare degrades healthy projects to "unknown", which is
2353
+ * the very failure this check exists to prevent, inverted. No path spelling is
2354
+ * involved here at all.
2355
+ */
2356
+ function projectOwnsItsRepo(projectRoot) {
2357
+ try {
2358
+ return node_fs_1.default.existsSync(node_path_1.default.join(projectRoot, '.git'));
2359
+ }
2360
+ catch {
2361
+ return false;
2362
+ }
2363
+ }
2364
+ function readGitHeadSha(cwd) {
2365
+ if (!cwd)
2366
+ return null;
2367
+ // #2573 degrade path D5. `git rev-parse HEAD` walks UP from cwd to the nearest
2368
+ // enclosing `.git`, and nothing pins that repo to the project. A GSD project
2369
+ // living inside an unrelated checkout — a dotfiles/notes repo, or the outer
2370
+ // workspace of a `planning.sub_repos` layout where all code commits land in
2371
+ // the sub-repos — would otherwise measure freshness against a repo it has no
2372
+ // relationship to, and report `commit_stale: false` ("known fresh") while
2373
+ // doing it. Unverified provenance must degrade to unknown, never to fresh.
2374
+ //
2375
+ // TWO independent conditions must hold before a stamp is trustworthy, and both
2376
+ // are checked below because either alone is insufficient:
2377
+ // 1. the project root owns a `.git` (else an ancestor repo answered), and
2378
+ // 2. the project is not a `sub_repos` workspace (else the repo that answers
2379
+ // is the outer wrapper, whose HEAD does not move when the code does).
2380
+ // KNOWN LIMITATION, by design: in a `sub_repos` workspace this feature reports
2381
+ // unknown rather than measuring the children. Per-child freshness needs a
2382
+ // defined aggregate across N histories and is out of scope for this increment.
2383
+ //
2384
+ // `--show-toplevel HEAD` answers both in ONE spawn, so pinning costs no extra
2385
+ // subprocess on this path (the caller holds the STATE lock).
2386
+ let projectRoot;
2387
+ try {
2388
+ projectRoot = (0, project_root_cjs_1.findProjectRoot)(cwd);
2389
+ }
2390
+ catch {
2391
+ return null; // cannot prove which repo would answer → unknown
2392
+ }
2393
+ if (!projectOwnsItsRepo(projectRoot))
2394
+ return null;
2395
+ // #2573 D5, sub_repos flavor. Owning a `.git` is necessary but NOT sufficient.
2396
+ // In a `planning.sub_repos` workspace the outer directory can legitimately own
2397
+ // BOTH `.planning/` and its own repo while every code commit lands in a nested
2398
+ // child repo — `docs/CONFIGURATION.md` describes sub_repos as scoping work per
2399
+ // sub-repo "instead of treating the outer repo as a monorepo". The outer HEAD
2400
+ // then never advances, so `merge-base --is-ancestor` passes trivially and
2401
+ // `rev-list` counts 0: the stamp would report `commit_stale: false`, i.e.
2402
+ // "known fresh", while the code it describes has moved arbitrarily far.
2403
+ //
2404
+ // That is a WRONG answer, not a missing one, and it is the same invariant the
2405
+ // ancestor-repo check above exists to protect: a freshness claim the project
2406
+ // cannot substantiate must degrade to unknown, never to fresh. Measuring the
2407
+ // children instead would mean picking one HEAD out of N unrelated histories
2408
+ // (or inventing an aggregate), which is a design question beyond this
2409
+ // increment — so this scopes to the honest tri-state and declines to answer.
2410
+ // Deliberately keyed on the DECLARED config rather than probing the filesystem
2411
+ // for nested `.git` entries: the declaration is what the workspace asserts
2412
+ // about itself, and a probe would spuriously fire on a vendored dependency.
2413
+ try {
2414
+ const subRepos = loadConfig(projectRoot).sub_repos;
2415
+ if (Array.isArray(subRepos) && subRepos.length > 0)
2416
+ return null;
2417
+ }
2418
+ catch {
2419
+ return null; // cannot read the layout → cannot claim provenance → unknown
2420
+ }
2421
+ const r = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', 'HEAD'], { cwd });
2422
+ if (r.exitCode !== 0)
2423
+ return null;
2424
+ const sha = r.stdout.trim();
2425
+ return STATE_HEAD_HASH_RE.test(sha) ? sha : null;
2426
+ }
2427
+ /**
2428
+ * Derive the commit-age freshness signal from a recorded `state_head`.
2429
+ *
2430
+ * Single source of truth for the derivation — `validate.health` (W024) and
2431
+ * smart-entry both consume this rather than re-deriving it, so the tri-state
2432
+ * and the hash fence cannot drift apart between surfaces.
2433
+ *
2434
+ * Never throws: every unresolvable input degrades to nulls.
2435
+ */
2436
+ function readStateHeadFreshness(cwd, stateHead) {
2437
+ const raw = (typeof stateHead === 'string' ? stateHead : '').trim();
2438
+ const stamp = STATE_HEAD_HASH_RE.test(raw) ? raw : null;
2439
+ const head = readGitHeadSha(cwd);
2440
+ let commitsBehind = null;
2441
+ let commitStale = null;
2442
+ if (stamp && head && cwd) {
2443
+ // The stamp must be an ANCESTOR of HEAD before a distance means anything.
2444
+ // `rev-list --count A..B` exits 0 with "0" when A is not reachable from B —
2445
+ // which is what a `reset --hard` to an earlier commit, a rebase or squash
2446
+ // that drops the stamped commit, or a force-push rewriting history all
2447
+ // produce. Without this guard those cases report `commit_stale: false`,
2448
+ // i.e. "known fresh", for a codebase that was actually rewound past the
2449
+ // stamp — collapsing the exact unknown-vs-fresh distinction this tri-state
2450
+ // exists to preserve. A non-ancestor stamp is UNKNOWN, so it stays null.
2451
+ const ancestry = (0, shell_command_projection_cjs_1.execGit)(['merge-base', '--is-ancestor', stamp, head], { cwd });
2452
+ if (ancestry.exitCode === 0) {
2453
+ const r = (0, shell_command_projection_cjs_1.execGit)(['rev-list', '--count', `${stamp}..${head}`], { cwd });
2454
+ if (r.exitCode === 0) {
2455
+ const n = parseInt(r.stdout.trim(), 10);
2456
+ if (Number.isFinite(n)) {
2457
+ commitsBehind = n;
2458
+ // #2573 D4 — deliberately RAW, not thresholded. `commit_stale` means
2459
+ // exactly what its contract says: the codebase has moved since the
2460
+ // stamp. Applying an advisory threshold here would make the field lie
2461
+ // at n < threshold, and W024 needs the true count to threshold on.
2462
+ // Alarm-fatigue is handled at the ALARMING surface, not the
2463
+ // derivation: W024 (the only user-visible consumer) fires at
2464
+ // STATE_HEAD_ADVISORY_COMMITS, which absorbs the `commit_docs: true`
2465
+ // off-by-one. Smart-entry re-exports the raw tri-state as advisory
2466
+ // JSON and is not consumed by classify().
2467
+ commitStale = n > 0;
2468
+ }
2469
+ }
2470
+ }
2471
+ }
2472
+ return {
2473
+ state_head: stamp ? stamp.slice(0, 7) : null,
2474
+ current_commit: head ? head.slice(0, 7) : null,
2475
+ commits_behind: commitsBehind,
2476
+ commit_stale: commitStale,
2477
+ };
2478
+ }
2479
+ /**
2480
+ * #3354: read `progress.total_phases` out of already-extracted STATE.md
2481
+ * frontmatter as a finite number, or null. Feeds buildStateFrontmatter's
2482
+ * milestoned-but-unbounded withhold so the stored total survives the write
2483
+ * instead of being clobbered by the on-disk phase-directory count.
2484
+ */
2485
+ function readStoredTotalPhases(existingFm) {
2486
+ if (!existingFm || typeof existingFm !== 'object')
2487
+ return null;
2488
+ const progress = existingFm['progress'];
2489
+ if (!progress || typeof progress !== 'object')
2490
+ return null;
2491
+ const raw = progress['total_phases'];
2492
+ if (raw === null || raw === undefined)
2493
+ return null;
2494
+ if (typeof raw === 'string' && raw.trim() === '')
2495
+ return null;
2496
+ const n = Number(raw);
2497
+ return Number.isFinite(n) ? n : null;
2498
+ }
2499
+ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
1684
2500
  // Read existing frontmatter BEFORE stripping — it may contain values
1685
2501
  // that the body no longer has (e.g., Status field removed by an agent).
1686
2502
  // `cwd` already identifies the workspace this content came from, so the STATE.md path is
1687
2503
  // derivable here without widening the signature (#1882).
1688
2504
  const existingFm = extractFrontmatter(content, cwd ? planningPaths(cwd).state : undefined);
2505
+ // #3881 review, second round: an UNPARSEABLE frontmatter block (malformed YAML, a git
2506
+ // merge-conflict marker, a refused anchor) must never be silently REPLACED by a freshly
2507
+ // re-derived one — that destroys the only copy of what the block actually contained, with
2508
+ // no signal to the human that their document was in conflict. `beginFrontmatterReassembly`
2509
+ // (state-transition.cts) already preserves the raw fmPrefix through the pure transform
2510
+ // layer for every `transitionCore` kind; this was the gap — this function re-parses the
2511
+ // ALREADY-preserved `content` and, finding {} + the marker, rebuilt a fresh block anyway,
2512
+ // discarding the raw prefix the transform layer had just protected. Confirmed by execution
2513
+ // against `state complete-phase`/`update`/`patch`/`begin-phase`: each returned success with
2514
+ // the conflict markers gone and a freshly-derived, well-formed frontmatter block in their
2515
+ // place (re-derivation, not deletion — the document never lost its frontmatter FENCE).
2516
+ //
2517
+ // `sanctionedPermanentEmptyFallback` is threaded ONLY from `writeStateMd`, itself consumed
2518
+ // ONLY by `cmdStateSync` (#905) and `/gsd-health --repair`'s `REGENERATE_STATE` — ADR-3408
2519
+ // §8.3's CLOSED list of commands whose documented contract is "body wins, re-derive
2520
+ // unconditionally" (a factory reset / explicit resync). Those two are untouched here: this
2521
+ // guard fires only on the OTHER call path (`syncAndPreserveStateMd`, i.e. every
2522
+ // `readModifyWriteStateMd`-based command), where re-deriving over unparseable content was
2523
+ // never the intended contract in the first place — it was an unhandled gap, not a decision.
2524
+ if (!sanctionedPermanentEmptyFallback && isUnparseableFrontmatter(existingFm)) {
2525
+ return content;
2526
+ }
1689
2527
  const body = stripFrontmatter(content);
1690
2528
  // #3017: pass the stored milestone from the existing frontmatter so
1691
2529
  // buildStateFrontmatter scopes its disk scan to the correct milestone
1692
2530
  // instead of auto-deriving (and potentially mis-binding).
1693
2531
  const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
1694
- const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone);
2532
+ // #3354: also pass the stored total so buildStateFrontmatter's
2533
+ // milestoned-but-unbounded withhold can preserve it across the write
2534
+ // (the derived progress sub-block replaces the stored one wholesale below,
2535
+ // so an omitted key would otherwise DELETE the stored value).
2536
+ const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
1695
2537
  // Preserve existing frontmatter status when body-derived status is 'unknown'.
1696
2538
  // This prevents a missing Status: field in the body from overwriting a
1697
2539
  // previously valid status (e.g., 'executing' → 'unknown').
@@ -1726,52 +2568,128 @@ function syncStateFrontmatter(content, cwd, authoritativeFm) {
1726
2568
  derivedFm['milestone'] = existingFm['milestone'];
1727
2569
  }
1728
2570
  }
1729
- // Bug #905: preserve scalar fields that buildStateFrontmatter can only derive
1730
- // from body annotations (Current Phase:, Current Plan:, etc.). When those
1731
- // annotations are absent — e.g. after an agent or tool rewrites the body —
1732
- // buildStateFrontmatter returns no value for those keys. Mirror the same
1733
- // fallback pattern used in cmdStateJson so the existing frontmatter values
1734
- // survive every writeStateMd call.
2571
+ // ADR-3408 §8.5 (D1): the six empty-only "#905" guards that used to live
2572
+ // here UNCONDITIONALLY are deleted for the write-seam pipeline
2573
+ // (`syncAndPreserveStateMd`, consumed by `readModifyWriteStateMd` and by
2574
+ // `cmdPhaseComplete`'s atomic-commit adapter). An empty derived value now
2575
+ // reaches `applyStatePreservation` unmolested, so the table-driven executor
2576
+ // — not a private copy inside this function — decides whether a curated
2577
+ // frontmatter value survives, and reports the decision via
2578
+ // `divergedFields` when it does. That was the actual D1 bug: these guards
2579
+ // ran BEFORE the executor ever saw the value, so a transform that
2580
+ // deliberately emptied a body line (delta CHANGED) lost silently — the
2581
+ // guard restored the stale frontmatter, the executor's own #1230 delta
2582
+ // check then found "already restored, nothing to do", and
2583
+ // `divergedFields` stayed empty even though a curated value had just won
2584
+ // over a genuine derived-empty.
1735
2585
  //
1736
- // For stopped_at / paused_at: the original #905 "fall back when derived is
1737
- // absent" rule is preserved here. The stale-body-overwrites-frontmatter
1738
- // scenario from #948 is prevented by the no-op guard in
1739
- // readModifyWriteStateMd: when the transform produces no change the file is
1740
- // never written, so syncStateFrontmatter never even runs. Attempting to
1741
- // "always prefer frontmatter" here breaks legitimate callers like phase.complete
1742
- // that intentionally write a new stopped_at value to the body and expect
1743
- // syncStateFrontmatter to pick it up.
1744
- if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
1745
- derivedFm['stopped_at'] = existingFm['stopped_at'];
1746
- }
1747
- if (!derivedFm['paused_at'] && existingFm['paused_at']) {
1748
- derivedFm['paused_at'] = existingFm['paused_at'];
1749
- }
1750
- if (!derivedFm['current_phase'] && existingFm['current_phase']) {
1751
- derivedFm['current_phase'] = existingFm['current_phase'];
1752
- }
1753
- if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
1754
- derivedFm['current_phase_name'] = existingFm['current_phase_name'];
1755
- }
1756
- if (!derivedFm['current_plan'] && existingFm['current_plan']) {
1757
- derivedFm['current_plan'] = existingFm['current_plan'];
1758
- }
1759
- // progress is a sub-object: fall back to existing only when the body+disk
1760
- // scan produced NO progress block at all. When buildStateFrontmatter did
1761
- // derive a progress block (even a lower one), that derived value wins — the
1762
- // shouldPreserveExistingProgress cross-milestone logic is applied later in
1763
- // cmdStateJson on the read path where it is appropriate.
1764
- if (!derivedFm['progress'] && existingFm['progress']) {
1765
- derivedFm['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
2586
+ // `writeStateMd`'s two callers — `cmdStateSync` and `/gsd-health --repair`'s
2587
+ // `REGENERATE_STATE` — are §8.3's closed, sanctioned-permanent exception
2588
+ // list: NEITHER ever runs `applyStatePreservation` afterward, because their
2589
+ // whole contract is "re-derive frontmatter FROM the body, body wins" (the
2590
+ // opposite of preservation). For them, these six conditions are the ONLY
2591
+ // mechanism that has ever kept a curated frontmatter value alive when the
2592
+ // body simply carries no annotation for a field at all (most STATE.md
2593
+ // files do not restate every field in body prose on every write) — losing
2594
+ // that would blank `current_phase_name` / `stopped_at` / etc. on every
2595
+ // `state sync`, which is a regression, not this phase's fix: `state sync`'s
2596
+ // output must stay byte-identical (ADR-3408 §8.3 Amendment 2). So the same
2597
+ // six conditions are kept, verbatim, but now gated behind the explicit
2598
+ // `sanctionedPermanentEmptyFallback` parameter — threaded ONLY from
2599
+ // `writeStateMd` — instead of running unconditionally or being duplicated
2600
+ // as a second private copy. This is still ONE enforcement point: the six
2601
+ // conditions exist in exactly one place in the source, selected by caller
2602
+ // identity per the closed §8.3 exception list, never re-derived elsewhere.
2603
+ //
2604
+ // The disagreeing case (a present-but-stale body value vs a fresher
2605
+ // frontmatter value, #948/#3374/§8.5) was never handled here even before
2606
+ // this change: it is governed by applyStatePreservation's
2607
+ // preserve-when-unchanged delta, applied post-sync by the shared
2608
+ // applyPostSyncPreservation pass.
2609
+ if (sanctionedPermanentEmptyFallback) {
2610
+ if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
2611
+ derivedFm['stopped_at'] = existingFm['stopped_at'];
2612
+ }
2613
+ if (!derivedFm['paused_at'] && existingFm['paused_at']) {
2614
+ derivedFm['paused_at'] = existingFm['paused_at'];
2615
+ }
2616
+ if (!derivedFm['current_phase'] && existingFm['current_phase']) {
2617
+ derivedFm['current_phase'] = existingFm['current_phase'];
2618
+ }
2619
+ if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
2620
+ derivedFm['current_phase_name'] = existingFm['current_phase_name'];
2621
+ }
2622
+ if (!derivedFm['current_plan'] && existingFm['current_plan']) {
2623
+ derivedFm['current_plan'] = existingFm['current_plan'];
2624
+ }
2625
+ // progress is a sub-object: fall back to existing only when the
2626
+ // body+disk scan produced NO progress block at all. When
2627
+ // buildStateFrontmatter did derive a progress block (even a lower one),
2628
+ // that derived value wins — the shouldPreserveExistingProgress
2629
+ // cross-milestone logic is applied later in cmdStateJson on the read
2630
+ // path where it is appropriate.
2631
+ if (!derivedFm['progress'] && existingFm['progress']) {
2632
+ derivedFm['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
2633
+ }
1766
2634
  }
1767
2635
  // #2202: carry forward any existing frontmatter key that the schema does not
1768
2636
  // own, so custom/unknown keys are not silently dropped on every mutating verb.
1769
2637
  // Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
1770
- // preserve guards above) still win.
2638
+ // sanctioned-permanent guards above, when they ran) still win.
1771
2639
  for (const key of Object.keys(existingFm)) {
1772
- if (!(key in derivedFm) && existingFm[key] !== undefined) {
1773
- derivedFm[key] = existingFm[key];
1774
- }
2640
+ if (key in derivedFm || existingFm[key] === undefined)
2641
+ continue;
2642
+ // #2573: a `source: 'free'` field is the writer's word on every write and
2643
+ // carries no preservation (see the FieldSource doc). When buildStateFrontmatter
2644
+ // omits it — `state_head` outside a git repo, per its `if (stateHead)` guard —
2645
+ // carrying the old value forward would re-assert provenance the file no longer
2646
+ // has: a stale state_head would claim STATE.md was written against a commit it
2647
+ // wasn't, contradicting its own ADR-1769 row.
2648
+ //
2649
+ // Narrow the skip to `source: 'free'`, NOT every `derive` row. `last_activity`
2650
+ // ({source:'body'}) and the `progress.*` rows ({source:'disk'}) are also
2651
+ // `derive`, but they are body/disk-sourced and MUST still carry forward when
2652
+ // the writer omits them this pass — dropping `last_activity` here is silent
2653
+ // frontmatter data loss and would defeat #2570's staleness fix downstream.
2654
+ // `last_updated` and `gsd_state_version` are the only other `free` rows and are
2655
+ // both produced unconditionally by buildStateFrontmatter, so this loop never
2656
+ // reaches them; `state_head` is the sole field the skip governs. Consult the
2657
+ // table rather than naming fields, so the policy stays single-sourced.
2658
+ const classification = stateTransitionMod.getFieldClassification(key);
2659
+ if (classification && classification.source === 'free')
2660
+ continue;
2661
+ // ADR-3408 §8.1/§8.5 (D1 follow-on — found by probe, not predicted by the
2662
+ // design): a `preserve-when-unchanged` / `preserve-always` field must be
2663
+ // decided ONLY by `applyStatePreservation` — the single enforcement point
2664
+ // — never by this generic carry-forward, on the write-seam path. Before
2665
+ // the six sanctioned-permanent guards above were gated behind
2666
+ // `sanctionedPermanentEmptyFallback` (D1), this loop's `key in derivedFm`
2667
+ // check was effectively always true for a field the guards had already
2668
+ // restored, so this branch was unreachable for it and the distinction
2669
+ // never mattered. With the guards now OFF on the write-seam path,
2670
+ // `derivedFm` genuinely lacks the key when the body carries no
2671
+ // annotation — and without this skip, this loop silently resurrects the
2672
+ // exact stale value the executor's delta rule (§8.5 Row 2) just decided
2673
+ // to discard, re-introducing the D1 bug through a second, unrelated code
2674
+ // path (confirmed live: an A5-shaped probe restored `current_phase_name`
2675
+ // via THIS loop even with the six guards deleted).
2676
+ //
2677
+ // Gated to the write-seam path ONLY (`!sanctionedPermanentEmptyFallback`)
2678
+ // — `writeStateMd`'s two sanctioned-permanent callers never run
2679
+ // `applyStatePreservation` at all, so unconditionally skipping here would
2680
+ // blank fields this loop has always carried forward for them (e.g.
2681
+ // `last_activity_desc`, which was never one of the six explicit guards
2682
+ // above but relied on THIS loop for its empty-case fallback), breaking
2683
+ // `state sync`'s required byte-identical output for a field D1 never
2684
+ // named. On the write-seam path this executor-only rule genuinely widens
2685
+ // beyond the original six fields (e.g. also covers `last_activity_desc`)
2686
+ // — a deliberate, in-scope consequence of "one enforcement point", not a
2687
+ // separate defect.
2688
+ if (!sanctionedPermanentEmptyFallback &&
2689
+ classification &&
2690
+ (classification.preservation === 'preserve-when-unchanged' || classification.preservation === 'preserve-always'))
2691
+ continue;
2692
+ derivedFm[key] = existingFm[key];
1775
2693
  }
1776
2694
  // #2567: guard the information-losing direction — a stale archive
1777
2695
  // "Last activity:" line must not overwrite a newer frontmatter value.
@@ -1790,6 +2708,11 @@ function syncStateFrontmatter(content, cwd, authoritativeFm) {
1790
2708
  }
1791
2709
  }
1792
2710
  }
2711
+ // #3257: propagate full-line frontmatter comments from the extracted source onto the
2712
+ // rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both
2713
+ // skip the Symbol-keyed channel, so without this the comments would be lost here even
2714
+ // though parseGuardedYamlRegion/reconstructFrontmatter preserve them in isolation).
2715
+ propagateCommentChannel(existingFm, derivedFm);
1793
2716
  const yamlStr = reconstructFrontmatter(derivedFm);
1794
2717
  return `---\n${yamlStr}\n---\n\n${body}`;
1795
2718
  }
@@ -2040,7 +2963,23 @@ function withStateLock(statePath, fn) {
2040
2963
  * @param clock
2041
2964
  * Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
2042
2965
  */
2043
- function writeStateMd(statePath, content, cwd, clock) {
2966
+ /**
2967
+ * ADR-3473 §8.6: `writeStateMd` is ADR-3408 §8.3's sanctioned-exception write
2968
+ * path — only a `rebuildStateTransaction` may travel it. Enforced here rather
2969
+ * than left to caller discipline: the transaction TYPE is what makes the two
2970
+ * sanctioned exceptions (`cmdStateSync`, `REGENERATE_STATE`) greppable and
2971
+ * closed, and an `open()` transaction reaching this function would mean a
2972
+ * preservation-governed write silently skipped preservation.
2973
+ */
2974
+ function writeStateMd(statePath, content, transaction, cwd, clock) {
2975
+ if (transaction.kind !== 'rebuild') {
2976
+ const err = new Error(`writeStateMd: expected a 'rebuild' transaction, got '${transaction.kind}'. writeStateMd is ` +
2977
+ 'ADR-3408 §8.3\'s sanctioned-exception write path (cmdStateSync / REGENERATE_STATE only) — ' +
2978
+ 'only rebuildStateTransaction() may travel it (ADR-3473 §8.6). An open() transaction here ' +
2979
+ 'would silently skip preservation for a write that was supposed to run it.');
2980
+ err.code = 'STATE_TRANSACTION_KIND_INVALID';
2981
+ throw err;
2982
+ }
2044
2983
  const lockPath = acquireStateLock(statePath, clock);
2045
2984
  // Test seam (audit M8): fire AFTER the lock is taken so a test can simulate a
2046
2985
  // concurrent writer landing in the (now-closed) scan→lock window.
@@ -2060,13 +2999,354 @@ function writeStateMd(statePath, content, cwd, clock) {
2060
2999
  // files that buildStateFrontmatter must see (#1967).
2061
3000
  if (cwd)
2062
3001
  _diskScanCache.delete(cwd);
2063
- const synced = syncStateFrontmatter(content, cwd);
3002
+ // ADR-3408 §8.3: `writeStateMd` is the sole write path for the two
3003
+ // sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`) —
3004
+ // the sanctioned-permanent empty-field fallback is now DERIVED FROM THE
3005
+ // TRANSACTION KIND (ADR-3473 §8.6) rather than asserted by a literal
3006
+ // `true` at this call site: only a `rebuild` transaction can reach this
3007
+ // function (enforced above), so `transaction.kind === 'rebuild'` is
3008
+ // always `true` here today, but the derivation is what keeps the
3009
+ // fallback's scope tied to the transaction type rather than a
3010
+ // hard-coded constant that could silently drift from it.
3011
+ const synced = syncStateFrontmatter(content, cwd, undefined, transaction.kind === 'rebuild');
2064
3012
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
2065
3013
  }
2066
3014
  finally {
2067
3015
  releaseStateLock(lockPath);
2068
3016
  }
2069
3017
  }
3018
+ /**
3019
+ * #3374: the shared post-sync preservation pass — the pre/post body-source
3020
+ * snapshot + table-driven `applyStatePreservation` + #2736 authoritative
3021
+ * re-assert sequence. Extracted from readModifyWriteStateMd so
3022
+ * `cmdPhaseComplete`'s atomic-commit adapter (phase.cts) — which syncs
3023
+ * STATE.md directly because it is committed atomically with
3024
+ * ROADMAP/REQUIREMENTS and so cannot go through the RMW wrapper — applies the
3025
+ * identical policy instead of a second, weaker encoding. Previously the
3026
+ * adapter had no preservation at all, letting a stale body `Stopped at:` line
3027
+ * silently clobber a fresher frontmatter `stopped_at` on every phase
3028
+ * completion (#3374 Variant A).
3029
+ *
3030
+ * NOT applied on the writeStateMd path: `state sync`'s contract is the
3031
+ * opposite by design (#905 — "body annotation beats existing frontmatter when
3032
+ * both are present": sync exists to re-derive frontmatter from the body), so a
3033
+ * blanket preservation pass there re-locks stale frontmatter. The
3034
+ * milestone-complete equivalent of the #3374 exposure is tracked as a
3035
+ * follow-up (see PR #3491 / the closed PR #3442 review's MAJOR finding).
3036
+ *
3037
+ * `originalContent` is the pre-write on-disk content (drives the #1230
3038
+ * pre-snapshots), `transformedContent` is the post-transform content (the
3039
+ * sync only rewrites the frontmatter block, so its body IS the post-write
3040
+ * body), and `syncedContent` is what `syncStateFrontmatter` produced.
3041
+ */
3042
+ /**
3043
+ * #3471 Fix: `StatePreservationOptions` is silently mis-consumable by any
3044
+ * non-TypeScript caller — `tsc` only type-checks src/, so a plain-.cjs test
3045
+ * (or any future JS caller) can pass a boolean where this options object
3046
+ * goes and both functions below would previously proceed with `resync`,
3047
+ * `authoritativeFm`, `deriveProgressKeys`, and `divergedFields` all
3048
+ * `undefined`, degrading to a well-formed-looking but silently-empty
3049
+ * `divergedFields: []` — exactly the "stale but present" failure shape
3050
+ * ADR-3408 exists to remove. This is a contract assertion (caller-shape
3051
+ * only), not field-level validation — mirrors `throwUnwiredRow`'s
3052
+ * structured-error shape in src/state-transition.cts.
3053
+ */
3054
+ function assertStatePreservationOptions(options, caller) {
3055
+ if (typeof options !== 'object' || options === null || Array.isArray(options)) {
3056
+ const err = new Error(`${caller}: options argument must be a StatePreservationOptions object, got ${typeof options === 'object' ? 'array/null' : typeof options}. ` +
3057
+ 'This function takes a single options object as its final ' +
3058
+ 'parameter, not positional resync/authoritativeFm/deriveProgressKeys/divergedFields arguments (#3471).');
3059
+ err.code = 'STATE_PRESERVATION_OPTIONS_INVALID';
3060
+ err.receivedType = Array.isArray(options) ? 'array' : typeof options;
3061
+ throw err;
3062
+ }
3063
+ }
3064
+ function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options) {
3065
+ assertStatePreservationOptions(options, 'applyPostSyncPreservation');
3066
+ const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField, preWriteState } = options;
3067
+ // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
3068
+ // we can detect whether THIS write changed them. syncStateFrontmatter
3069
+ // re-derives frontmatter status/stopped_at from the body on every write;
3070
+ // when the body's source field was NOT changed by the transform, the
3071
+ // existing frontmatter value (e.g. a hand-set 'completed') must win over
3072
+ // the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
3073
+ // Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
3074
+ // above (null when resync:true) — these are independent snapshots.
3075
+ // Strip frontmatter before calling stateExtractField so the YAML `status:`
3076
+ // key in the frontmatter block cannot shadow the body field we are tracking.
3077
+ const preFmSnapshot = extractFrontmatter(originalContent, statePath);
3078
+ // #3881 review, second round: `syncStateFrontmatter` above already declines to re-derive
3079
+ // over an UNPARSEABLE original frontmatter block (its own matching guard), so `syncedContent`
3080
+ // here is `transformedContent` verbatim. But this function's own downstream preservation
3081
+ // machinery (`applyStatePreservation` + the `authoritativeFm` reassertion below) reads
3082
+ // `postFm = extractFrontmatter(syncedContent, ...)` — {} + the marker, since the block still
3083
+ // doesn't parse — restores curated fields from `transaction.snapshot`, and reconstructs a
3084
+ // FRESH frontmatter block from the result, destroying the raw block a second time even
3085
+ // though `syncStateFrontmatter` just finished protecting it. `applyPostSyncPreservation` is
3086
+ // reached ONLY via the non-sanctioned path (`syncAndPreserveStateMd`; `writeStateMd`'s two
3087
+ // ADR-3408 §8.3 closed-list callers — `cmdStateSync` #905 and `/gsd-health --repair`'s
3088
+ // `REGENERATE_STATE` — never call it at all), so this guard needs no extra parameter to stay
3089
+ // scoped off that list. Confirmed by execution: `state begin-phase` on a conflict-marked
3090
+ // STATE.md reached exactly this second clobber even after the `syncStateFrontmatter` fix.
3091
+ if (isUnparseableFrontmatter(preFmSnapshot)) {
3092
+ return transformedContent;
3093
+ }
3094
+ const preBody = stripFrontmatter(originalContent);
3095
+ const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
3096
+ // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
3097
+ // mirroring buildStateFrontmatter's sessionBodyScope logic.
3098
+ // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
3099
+ // Archive prose) must not interfere with the delta comparison.
3100
+ const preSessionMatch = matchSessionSection(preBody);
3101
+ const preSessionScope = preSessionMatch ?? preBody;
3102
+ const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
3103
+ // ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
3104
+ // current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
3105
+ // this write does NOT change that line, the curated frontmatter value must
3106
+ // win over syncStateFrontmatter's body re-derivation (which can harvest a
3107
+ // wrong parenthetical aside — #1695). Gated by the field-classification
3108
+ // table's preserve-always row so the rule lives in one place.
3109
+ const preBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(preBody, 'Phase');
3110
+ // #3258: snapshot the body sources for the additional preserve-when-unchanged
3111
+ // rows applyStatePreservation now honors (last_activity_desc, paused_at,
3112
+ // current_phase, current_plan). Each mirrors buildStateFrontmatter's
3113
+ // derivation so the #1230 delta ("did THIS write change the source?") is
3114
+ // accurate: current_phase combines `Current Phase` with the prose `Phase:`
3115
+ // fallback (parseProsePhaseField, scoped to ## Current Position); paused_at
3116
+ // is session-scoped (mirrors stopped_at); last_activity_desc combines the
3117
+ // `Last Activity Description` field with the prose desc fallback.
3118
+ const preCurrentPositionScope = matchCurrentPositionSection(preBody) ?? preBody;
3119
+ const preBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Plan');
3120
+ const preBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Phase')
3121
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase')).phase;
3122
+ const preBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Paused At');
3123
+ const preBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity')
3124
+ ?? (0, state_document_cjs_1.stateExtractField)(preBody, 'Last activity');
3125
+ const preBodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity Description')
3126
+ ?? parseProseLastActivityField(preBodyLastActivityRaw).description;
3127
+ // Post-transform body source fields used for the delta comparison (#1230).
3128
+ // Use `transformedContent` (not `syncedContent`): syncStateFrontmatter only
3129
+ // rewrites the frontmatter block, so the body is identical in both — and we
3130
+ // need the body the transform produced. Strip frontmatter so the YAML
3131
+ // status key cannot shadow the body field we are tracking.
3132
+ const postBody = stripFrontmatter(transformedContent);
3133
+ const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
3134
+ // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
3135
+ // consistent with the pre-transform snapshot above and buildStateFrontmatter.
3136
+ const postSessionMatch = matchSessionSection(postBody);
3137
+ const postSessionScope = postSessionMatch ?? postBody;
3138
+ const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
3139
+ // ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
3140
+ // current_phase_name delta comparison.
3141
+ const postBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(postBody, 'Phase');
3142
+ // #3258: post-transform body sources for the preserve-when-unchanged rows
3143
+ // added in #3258 (mirrors the pre-transform block above).
3144
+ const postCurrentPositionScope = matchCurrentPositionSection(postBody) ?? postBody;
3145
+ const postBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Plan');
3146
+ const postBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Phase')
3147
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase')).phase;
3148
+ const postBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Paused At');
3149
+ const postBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity')
3150
+ ?? (0, state_document_cjs_1.stateExtractField)(postBody, 'Last activity');
3151
+ const postBodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity Description')
3152
+ ?? parseProseLastActivityField(postBodyLastActivityRaw).description;
3153
+ // #3468: single channel for every preserve-when-unchanged row. Before this
3154
+ // change, seven body-source pre/post pairs travelled in two different
3155
+ // shapes — this map for four fields, six dedicated parameters
3156
+ // (preBodyStatus/postBodyStatus, preBodyStoppedAt/postBodyStoppedAt,
3157
+ // preBodyPhaseSource/postBodyPhaseSource) for the other three — same data,
3158
+ // same purpose, which is exactly why applyStatePreservation needed a
3159
+ // hand-written branch per field instead of one loop over the table. Every
3160
+ // row FIELD_CLASSIFICATION declares preserve-when-unchanged MUST appear
3161
+ // here — an omission now throws (STATE_PRESERVATION_UNWIRED_ROW, ADR-3408
3162
+ // §8.2) at the first write rather than becoming a quiet preservation bug.
3163
+ // Note current_phase_name's source is the body `Phase:` line, deliberately
3164
+ // a DIFFERENT source from current_phase's: the key names the field the
3165
+ // policy GUARDS, not the body field it reads.
3166
+ const bodyDeltas = {
3167
+ last_activity_desc: { pre: preBodyLastActivityDesc, post: postBodyLastActivityDesc },
3168
+ paused_at: { pre: preBodyPausedAt, post: postBodyPausedAt },
3169
+ current_phase: { pre: preBodyCurrentPhase, post: postBodyCurrentPhase },
3170
+ current_plan: { pre: preBodyCurrentPlan, post: postBodyCurrentPlan },
3171
+ status: { pre: preBodyStatus, post: postBodyStatus },
3172
+ stopped_at: { pre: preBodyStoppedAt, post: postBodyStoppedAt },
3173
+ current_phase_name: { pre: preBodyPhaseSource, post: postBodyPhaseSource },
3174
+ // ADR-3473 §8.7 (#3872): `last_activity` is the one `FRONTMATTER_BODY_SOURCE`
3175
+ // key that is NOT `preserve-when-unchanged` (it is `derive` — always
3176
+ // re-stamped from the body) and so was never part of this map before.
3177
+ // Added ONLY for `reconcileReportedFields`'s consumption below (via
3178
+ // `preWriteState.bodyDeltas`) — harmless here, since
3179
+ // `applyPreserveWhenUnchanged` is dispatched by
3180
+ // `getPreserveWhenUnchangedFields()`, never by iterating this object's
3181
+ // keys, so an extra non-preserve-when-unchanged entry changes no
3182
+ // preservation behavior.
3183
+ last_activity: { pre: preBodyLastActivityRaw, post: postBodyLastActivityRaw },
3184
+ };
3185
+ // ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
3186
+ // preservation block is now the pure, table-driven `applyStatePreservation`
3187
+ // in the STATE.md Transition Module. progress / status / stopped_at /
3188
+ // current_phase_name are all governed by their FIELD_CLASSIFICATION row —
3189
+ // one policy source, not three drifting encodings. #3258 extends the same
3190
+ // pass to last_activity_desc / paused_at / current_phase / current_plan
3191
+ // (preserve-when-unchanged) and milestone / milestone_name (preserve-if-
3192
+ // placeholder). Behavior-identical to the pre-#1796 inline block for the
3193
+ // original four fields; this is the absorption ADR-1769 / CONTEXT.md
3194
+ // already claimed shipped.
3195
+ const postFm = extractFrontmatter(syncedContent, statePath);
3196
+ // #3469 (ADR-3408 §8.5): snapshot the freshly-synced (pre-preservation)
3197
+ // frontmatter so a caller that wants visibility into "did preservation
3198
+ // restore a curated value over a disagreeing derived one" can diff against
3199
+ // it via the optional `divergedFields` out-param below. Additive only:
3200
+ // callers that omit it (readModifyWriteStateMd, cmdPhaseComplete) pay
3201
+ // nothing extra and see no change to `synced`/the returned content.
3202
+ const preservationInputSnapshot = divergedFields ? { ...postFm } : null;
3203
+ // ADR-3473 §8.6: the pre-write snapshot + policy flags now travel as ONE
3204
+ // transaction rather than as a nullable `preFm` alongside the always-present
3205
+ // `preFmSnapshot` (same source, same extractFrontmatter call — `preFm` was
3206
+ // `preFmSnapshot` with the `resync` policy baked in by nulling it, which is
3207
+ // what made `applyPreserveAlways` inert on the default resyncing write
3208
+ // path — #3756).
3209
+ const transaction = stateTransitionMod.openStateTransaction({
3210
+ snapshot: preFmSnapshot,
3211
+ resync,
3212
+ deriveProgressKeys: deriveProgressKeys === true,
3213
+ bodyDeltas,
3214
+ explicitProgressField: explicitProgressField === true,
3215
+ });
3216
+ // ADR-3473 §8.7 (#3872): fill the caller's out-param with the TRANSACTION'S
3217
+ // OWN snapshot object (not a second `extractFrontmatter(originalContent)`
3218
+ // derivation — `transaction.snapshot === preFmSnapshot`, reusing it is the
3219
+ // whole point) plus the pre-write body, so `reconcileReportedFields` can
3220
+ // diff persisted-vs-pre-write instead of re-deriving either side itself.
3221
+ if (preWriteState) {
3222
+ preWriteState.fm = transaction.snapshot;
3223
+ preWriteState.body = preBody;
3224
+ // ADR-3473 §8.7 (#3872): the pre/post body-source delta for every
3225
+ // FRONTMATTER_BODY_SOURCE key — see `StatePreWriteSnapshot`'s docstring
3226
+ // for why `reconcileReportedFields` needs this instead of a raw
3227
+ // frontmatter diff for these specific keys.
3228
+ preWriteState.bodyDeltas = bodyDeltas;
3229
+ }
3230
+ const preservation = applyStatePreservation({ transaction, postFm });
3231
+ if (divergedFields && preservationInputSnapshot) {
3232
+ // §8.5's "liberal but visible": every field whose value actually
3233
+ // differs before vs after `applyStatePreservation` is a field where the
3234
+ // curated (frontmatter) value won over a disagreeing freshly-derived
3235
+ // one — regardless of which policy executor fired. Diffing the object
3236
+ // (rather than special-casing which executor mutated it) is intentional:
3237
+ // it stays correct if a future FIELD_CLASSIFICATION row adds a new
3238
+ // preservation policy without this function needing to know about it.
3239
+ for (const key of Object.keys(preservation.postFm)) {
3240
+ const before = preservationInputSnapshot[key];
3241
+ const after = preservation.postFm[key];
3242
+ // ADR-3473 §8.7 (#3872 standards-axis finding): route through the ONE
3243
+ // owner of this comparison rule (`stateFieldValuesDiffer`, defined
3244
+ // below) instead of carrying a second inline `JSON.stringify`-vs-`!==`
3245
+ // copy — this is exactly the duplicated-rule shape this epic exists to
3246
+ // remove. `stateFieldValuesDiffer` is a function declaration (hoisted),
3247
+ // so calling it here, above its textual definition, is safe.
3248
+ if (stateFieldValuesDiffer(before, after))
3249
+ divergedFields.push(key);
3250
+ }
3251
+ // ADR-3408 §8.5 Row 2 (D1's actual bug, the reason the guards had to be
3252
+ // deleted rather than merely relocated): the loop above can only see a
3253
+ // field that `applyStatePreservation` itself RESTORED — it diffs
3254
+ // `postFm` before vs after the executor ran, and `preserve-when-unchanged`
3255
+ // never adds an absent key back when the body source changed this write
3256
+ // (the delta rule correctly lets the empty derived value win, so `postFm`
3257
+ // never gains the key at all). That means a curated value can vanish —
3258
+ // deliberately, per policy — with NOTHING in the loop above to report it.
3259
+ // "Liberal but visible" requires the discard itself to be named, not just
3260
+ // a restore. Scoped to exactly the fields `bodyDeltas` tracks
3261
+ // (preserve-when-unchanged rows only — `preserve-always`/`progress` and
3262
+ // `preserve-if-placeholder`/`milestone*` are unaffected by the delta rule
3263
+ // and already fully covered by the restore-diff loop above).
3264
+ for (const [field, delta] of Object.entries(bodyDeltas)) {
3265
+ if (divergedFields.includes(field))
3266
+ continue; // already reported as a restore above
3267
+ const before = preFmSnapshot[field];
3268
+ const beforeIsReal = typeof before === 'string' && before.trim().length > 0;
3269
+ if (!beforeIsReal)
3270
+ continue; // nothing curated existed to discard
3271
+ if (delta.pre === delta.post)
3272
+ continue; // body source unchanged — governed by the restore branch, not the discard rule
3273
+ const after = preservation.postFm[field];
3274
+ const afterIsEmpty = after === undefined || after === null
3275
+ || (typeof after === 'string' && after.trim().length === 0);
3276
+ if (afterIsEmpty)
3277
+ divergedFields.push(field);
3278
+ }
3279
+ }
3280
+ // #2736: re-assert the intent-first values AFTER preservation. On STATE.md
3281
+ // layouts with no body `Phase:` line, both phase-source snapshots are null
3282
+ // (equal), so the #1695 restore fires and would put the stale pre-transition
3283
+ // name back over the authoritative one. Intent beats both the prose
3284
+ // re-derivation and the curated restore — the transition just resolved it.
3285
+ let authoritativeReasserted = false;
3286
+ if (authoritativeFm) {
3287
+ for (const [key, value] of Object.entries(authoritativeFm)) {
3288
+ if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) {
3289
+ preservation.postFm[key] = value;
3290
+ authoritativeReasserted = true;
3291
+ }
3292
+ }
3293
+ }
3294
+ if (preservation.mutated || authoritativeReasserted) {
3295
+ // #3742: preservation RESTORES frontmatter keys the body-derived rebuild
3296
+ // could not produce (e.g. `current_phase` on a layout with no body
3297
+ // `**Current Phase:**` line) — but the comment channel was filtered
3298
+ // against the pre-restore key set during sync, so a full-line comment
3299
+ // attached to a restored key died with nothing to re-attach it. Propagate
3300
+ // the channel from the PRE-WRITE snapshot here, after the restores, so a
3301
+ // comment's survival depends on its key surviving the whole write — not
3302
+ // on which body line happened to feed the rebuild. Merge semantics
3303
+ // (propagateCommentChannel) keep any channel the synced content already
3304
+ // carried. No resync gate: this is the RMW path, where `resync` is the
3305
+ // DEFAULT (readModifyWriteStateMd derives it as `options.resync !==
3306
+ // false`) and preservation itself runs regardless — the factory-reset
3307
+ // semantic the #3742 review worried about lives in writeStateMd's
3308
+ // `rebuild` transactions, which never reach this branch.
3309
+ if (preFmSnapshot && !isUnparseableFrontmatter(preFmSnapshot)) {
3310
+ propagateCommentChannel(preFmSnapshot, preservation.postFm);
3311
+ }
3312
+ const yamlStr = reconstructFrontmatter(preservation.postFm);
3313
+ const body = stripFrontmatter(syncedContent);
3314
+ return `---\n${yamlStr}\n---\n\n${body}`;
3315
+ }
3316
+ return syncedContent;
3317
+ }
3318
+ /**
3319
+ * ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
3320
+ * `applyPostSyncPreservation`, as a single named `content -> content`
3321
+ * function. Every STATE.md write that (a) is not one of the two sanctioned-
3322
+ * permanent exceptions (`cmdStateSync`, `REGENERATE_STATE` — §8.3's closed
3323
+ * exception list, ADR Amendment 2) and (b) needs a non-standard I/O envelope
3324
+ * calls THIS — never `syncStateFrontmatter` + `applyPostSyncPreservation`
3325
+ * assembled locally. §8.3: "Assembling the stages at a call site is a
3326
+ * re-derivation even when every step calls the owner." Phase 2 (#3469) found
3327
+ * exactly that shape live in `cmdPhaseComplete`'s atomic-commit adapter
3328
+ * (phase.cts) — every step called an owner, so the drift guard and an
3329
+ * owner-level test both stayed green while the composition itself was free
3330
+ * to diverge from `readModifyWriteStateMd`'s.
3331
+ *
3332
+ * Both current non-RMW callers of the pair — `readModifyWriteStateMd` and
3333
+ * `cmdPhaseComplete`'s atomic 3-file commit adapter — now call this instead
3334
+ * of assembling the two stages themselves. `cmdMilestoneComplete` (the
3335
+ * #3374-shaped exposure `applyPostSyncPreservation`'s own docstring flagged
3336
+ * as a follow-up) is the third.
3337
+ *
3338
+ * Returns CONTENT ONLY — a caller that needs its own I/O envelope (a lock,
3339
+ * an atomic multi-file commit) supplies it around this call; this function
3340
+ * never takes over the write.
3341
+ *
3342
+ * `divergedFields` is passed straight through to `applyPostSyncPreservation`
3343
+ * — see its own docstring.
3344
+ */
3345
+ function syncAndPreserveStateMd(originalContent, transformedContent, statePath, cwd, options) {
3346
+ assertStatePreservationOptions(options, 'syncAndPreserveStateMd');
3347
+ const synced = syncStateFrontmatter(transformedContent, cwd, options.authoritativeFm);
3348
+ return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options);
3349
+ }
2070
3350
  /**
2071
3351
  * Atomic read-modify-write for STATE.md.
2072
3352
  * Holds the lock across the entire read -> transform -> write cycle,
@@ -2092,36 +3372,6 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
2092
3372
  const lockPath = acquireStateLock(statePath, clock);
2093
3373
  try {
2094
3374
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
2095
- // Snapshot the existing progress block BEFORE the transform so we can
2096
- // restore it when resync is false.
2097
- const preFm = resync ? null : extractFrontmatter(content, statePath);
2098
- // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
2099
- // we can detect whether THIS write changed them. syncStateFrontmatter
2100
- // re-derives frontmatter status/stopped_at from the body on every write;
2101
- // when the body's source field was NOT changed by the transform, the
2102
- // existing frontmatter value (e.g. a hand-set 'completed') must win over
2103
- // the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
2104
- // Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
2105
- // above (null when resync:true) — these are independent snapshots.
2106
- // Strip frontmatter before calling stateExtractField so the YAML `status:`
2107
- // key in the frontmatter block cannot shadow the body field we are tracking.
2108
- const preBody = stripFrontmatter(content);
2109
- const preFmSnapshot = extractFrontmatter(content, statePath);
2110
- const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
2111
- // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
2112
- // mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172).
2113
- // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
2114
- // Archive prose) must not interfere with the delta comparison.
2115
- const preSessionMatch = matchSessionSection(preBody);
2116
- const preSessionScope = preSessionMatch ?? preBody;
2117
- const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
2118
- // ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
2119
- // current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
2120
- // this write does NOT change that line, the curated frontmatter value must
2121
- // win over syncStateFrontmatter's body re-derivation (which can harvest a
2122
- // wrong parenthetical aside — #1695). Gated by the field-classification
2123
- // table's preserve-always row so the rule lives in one place.
2124
- const preBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(preBody, 'Phase');
2125
3375
  const modified = transformFn(content);
2126
3376
  // Bug #948: no-op guard — if the transform produced no change, do NOT write
2127
3377
  // the file. An unconditional write would bump `last_updated`, reset
@@ -2133,54 +3383,23 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
2133
3383
  if (modified === content) {
2134
3384
  return false;
2135
3385
  }
2136
- let synced = syncStateFrontmatter(modified, cwd, options?.authoritativeFm);
2137
- // Post-transform body source fields used for the delta comparison (#1230).
2138
- // Use `modified` (not `synced`): syncStateFrontmatter only rewrites the frontmatter block, so the body is identical in both — and we need the body the transform produced.
2139
- // Strip frontmatter so the YAML status key cannot shadow the body field we are tracking.
2140
- const postBody = stripFrontmatter(modified);
2141
- const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
2142
- // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
2143
- // consistent with the pre-transform snapshot above and buildStateFrontmatter.
2144
- const postSessionMatch = matchSessionSection(postBody);
2145
- const postSessionScope = postSessionMatch ?? postBody;
2146
- const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
2147
- // ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
2148
- // current_phase_name delta comparison.
2149
- const postBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(postBody, 'Phase');
2150
- // ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
2151
- // preservation block is now the pure, table-driven `applyStatePreservation`
2152
- // in the STATE.md Transition Module. progress / status / stopped_at /
2153
- // current_phase_name are all governed by their FIELD_CLASSIFICATION row —
2154
- // one policy source, not three drifting encodings. Behavior-identical to
2155
- // the pre-#1796 inline block; this is the absorption ADR-1769 / CONTEXT.md
2156
- // already claimed shipped.
2157
- const postFm = extractFrontmatter(synced, statePath);
2158
- const preservation = applyStatePreservation({
2159
- preFm, postFm, preFmSnapshot, resync,
3386
+ // #3469 (ADR-3408 §8.3): sync + post-sync preservation is the single
3387
+ // owned composition (`syncAndPreserveStateMd`), not assembled here — this
3388
+ // call site and `cmdPhaseComplete`'s atomic-commit adapter both route
3389
+ // through the same function so the composition cannot diverge between
3390
+ // the two.
3391
+ const synced = syncAndPreserveStateMd(content, modified, statePath, cwd, {
3392
+ resync,
3393
+ authoritativeFm: options?.authoritativeFm,
2160
3394
  deriveProgressKeys: options?.deriveProgressKeys === true,
2161
- preBodyStatus, postBodyStatus,
2162
- preBodyStoppedAt, postBodyStoppedAt,
2163
- preBodyPhaseSource, postBodyPhaseSource,
3395
+ divergedFields: options?.divergedFields,
3396
+ explicitProgressField: options?.explicitProgressField === true,
3397
+ // ADR-3473 §8.7 (#3872): forwarded so `applyPostSyncPreservation` can
3398
+ // fill it — an unenumerated option here is silently dropped
3399
+ // (Phase 1's commit message; #3871), which is exactly how a prior cut
3400
+ // of this option would have gone missing.
3401
+ preWriteState: options?.preWriteState,
2164
3402
  });
2165
- // #2736: re-assert the intent-first values AFTER preservation. On STATE.md
2166
- // layouts with no body `Phase:` line, both phase-source snapshots are null
2167
- // (equal), so the #1695 restore fires and would put the stale pre-transition
2168
- // name back over the authoritative one. Intent beats both the prose
2169
- // re-derivation and the curated restore — the transition just resolved it.
2170
- let authoritativeReasserted = false;
2171
- if (options?.authoritativeFm) {
2172
- for (const [key, value] of Object.entries(options.authoritativeFm)) {
2173
- if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) {
2174
- preservation.postFm[key] = value;
2175
- authoritativeReasserted = true;
2176
- }
2177
- }
2178
- }
2179
- if (preservation.mutated || authoritativeReasserted) {
2180
- const yamlStr = reconstructFrontmatter(preservation.postFm);
2181
- const body = stripFrontmatter(synced);
2182
- synced = `---\n${yamlStr}\n---\n\n${body}`;
2183
- }
2184
3403
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
2185
3404
  return true;
2186
3405
  }
@@ -2188,6 +3407,438 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
2188
3407
  releaseStateLock(lockPath);
2189
3408
  }
2190
3409
  }
3410
+ /**
3411
+ * ADR-3408 §8.4/§8.5 (D4): frontmatter field name → the body Title-Case
3412
+ * label the `updated` arrays below use. Every `preserve-when-unchanged` row
3413
+ * in `FIELD_CLASSIFICATION` MUST have an entry here (pinned by a parity test,
3414
+ * #3471 review) — `reconcileReportedFields` consults this so a preservation
3415
+ * event on `current_phase_name` folds into a report that otherwise only ever
3416
+ * speaks in body labels like `Current Phase Name` (#3345's direction). A
3417
+ * `preserve-when-unchanged` field missing here is a table drift bug and
3418
+ * `bodyLabelFor` throws rather than silently degrading to the raw
3419
+ * snake_case key (#3471 review — this is a second hand-maintained table
3420
+ * parallel to `FIELD_CLASSIFICATION`, so an unwired row must fail as loudly
3421
+ * as `throwUnwiredRow` in `state-transition.cts` does for the same shape of
3422
+ * omission). `preserve-always`/`preserve-if-placeholder` fields (`progress`,
3423
+ * `milestone`, `milestone_name`) are deliberately absent — `divergedFields`
3424
+ * (ADR-3408 §8.5's out-param) is NOT scoped to `preserve-when-unchanged`
3425
+ * rows alone (see `applyPostSyncPreservation`'s "regardless of which policy
3426
+ * executor fired" diff), so those fields legitimately reach the lookup with
3427
+ * no body-line label to report — `progress` is a structured sub-object and
3428
+ * `milestone`/`milestone_name` version/name pairs, neither ever rendered as
3429
+ * a body prose line — and `bodyLabelFor` falls through to the raw key for
3430
+ * exactly that closed, tested set (`tests/state.test.cjs` A2f pins
3431
+ * `divergedFields` reporting bare `'progress'`).
3432
+ */
3433
+ /**
3434
+ * #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
3435
+ * (`src/state-md-schema.cts`)'s `bodyLabel` field, in this EXPLICIT key
3436
+ * order — the pre-#3873 literal's own order, which puts `status` AFTER
3437
+ * `stopped_at`/`paused_at` (the opposite of `FRONTMATTER_BODY_SOURCE`'s order
3438
+ * in `state-transition.cts`; the two pre-existing tables disagreed with each
3439
+ * other's order too, so each projection reproduces its OWN table's order
3440
+ * rather than a shared derivation). Byte-identical to the pre-#3873 literal:
3441
+ * same 7 keys, same order, same frozen (NOT null-prototype — this table was
3442
+ * a plain `Object.freeze({...})` literal before #3873 and stays one) shape.
3443
+ * `last_activity` is deliberately excluded — see `STATE_FIELD_SCHEMA`'s
3444
+ * `last_activity` row docstring for the resolved disagreement. Pinned by
3445
+ * `tests/state.test.cjs`'s `bodyLabelProjectionMatchesTodaysTable` and
3446
+ * `lastActivityLabelResolutionMatchesShippedBehavior`.
3447
+ */
3448
+ const FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER = Object.freeze([
3449
+ 'current_phase',
3450
+ 'current_phase_name',
3451
+ 'current_plan',
3452
+ 'stopped_at',
3453
+ 'paused_at',
3454
+ 'status',
3455
+ 'last_activity_desc',
3456
+ ]);
3457
+ const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze(FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER.reduce((acc, key) => {
3458
+ const row = stateMdSchemaMod.STATE_FIELD_SCHEMA[key];
3459
+ if (row.bodyLabel !== undefined)
3460
+ acc[key] = row.bodyLabel;
3461
+ return acc;
3462
+ }, {}));
3463
+ /**
3464
+ * ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
3465
+ * entry. Throws for a `preserve-when-unchanged` field with no
3466
+ * `FRONTMATTER_KEY_TO_BODY_LABEL` row — that combination can only happen if
3467
+ * a future row is added to `FIELD_CLASSIFICATION` without a matching label,
3468
+ * an internal table-drift bug, never a user-document defect (mirrors
3469
+ * `throwUnwiredRow`'s shape in `state-transition.cts`: an `Error` carrying
3470
+ * `code` and `field` own-properties). Falls through to the raw field name
3471
+ * for every other policy (`preserve-always`, `preserve-if-placeholder`) —
3472
+ * those fields were never claimed to have a body-line label and reaching
3473
+ * this lookup with one of them is the documented, tested, working case
3474
+ * (e.g. `progress`), not a silent degrade.
3475
+ */
3476
+ function bodyLabelFor(field) {
3477
+ // ADR-3473 §8.7 (#3872 review): an OWN-PROPERTY check, never a bare
3478
+ // bracket read — `FRONTMATTER_KEY_TO_BODY_LABEL` is a plain object literal
3479
+ // (real `Object.prototype` in its chain), so `[field]` for a hostile field
3480
+ // named `__proto__`/`constructor`/`toString` returns the INHERITED
3481
+ // prototype-chain member (`Object.prototype` itself, the `Object`
3482
+ // constructor function, `Object.prototype.toString`) instead of
3483
+ // `undefined` — which would then be returned as the "label" and leak a
3484
+ // non-string value into the caller's `updated` array. Proven by
3485
+ // `dottedResolutionDoesNotPollutePrototypes` (test matrix row 25) before
3486
+ // this fix. Mirrors `resolveFrontmatterPath`'s own-property discipline.
3487
+ if (Object.prototype.hasOwnProperty.call(FRONTMATTER_KEY_TO_BODY_LABEL, field)) {
3488
+ return FRONTMATTER_KEY_TO_BODY_LABEL[field];
3489
+ }
3490
+ const cls = stateTransitionMod.getFieldClassification(field);
3491
+ if (cls && cls.preservation === 'preserve-when-unchanged') {
3492
+ const err = new Error(`reconcileReportedFields: preserve-when-unchanged field ${JSON.stringify(field)} has no ` +
3493
+ 'FRONTMATTER_KEY_TO_BODY_LABEL entry. This is an internal invariant violation (ADR-3408 ' +
3494
+ '§8.4/D4) — add a label for this field to FRONTMATTER_KEY_TO_BODY_LABEL.');
3495
+ err.code = 'STATE_BODY_LABEL_UNWIRED_ROW';
3496
+ err.field = field;
3497
+ throw err;
3498
+ }
3499
+ return field;
3500
+ }
3501
+ /**
3502
+ * ADR-3473 §8.7 (issue #3872): the provenance exclusion — the ONLY
3503
+ * frontmatter key measured to change on EVERY write, regardless of content.
3504
+ * Verified at the CLI (`40-design.md` "Two corrections from reproducing it"):
3505
+ * two content-identical writes to a git-backed fixture differ in exactly
3506
+ * this one key. `state_head` was deliberately measured OUT of this set —
3507
+ * it restamps every write but its PERSISTED VALUE changes only when git HEAD
3508
+ * actually moved, so it tracks a real fact and does not flood.
3509
+ *
3510
+ * A CLOSED, ENUMERATED set — not a predicate or a callback (Greenspun's
3511
+ * Tenth Rule, ADR-3473 §8.7's Laws section: "the moment it takes a callback
3512
+ * it has become the classification table again under a new name"). It
3513
+ * exists to protect `src/state.cts:607` — `state.patch`'s ENTIRE
3514
+ * success/failure signal is `results.updated.length > 0` — admitting an
3515
+ * always-changing key here would make that boolean permanently `true`, so a
3516
+ * fully-failed patch would report success.
3517
+ */
3518
+ const STATE_UPDATED_PROVENANCE_EXCLUSION = Object.freeze(['last_updated']);
3519
+ /** 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. */
3520
+ const STATE_FIELD_ABSENT = Symbol('state-field-absent');
3521
+ /**
3522
+ * ADR-3473 §8.7 (#3872): resolve `path` against a parsed frontmatter object.
3523
+ * Pure, never throws.
3524
+ *
3525
+ * Order is pinned (test matrix row 26, `literalDottedKeyResolvesBeforePathTraversal`):
3526
+ * a LITERAL flat key wins first — a field name that happens to contain a `.`
3527
+ * but is stored as one flat key must not be shadowed by path traversal —
3528
+ * and only when no literal key exists does `path` get split and walked as a
3529
+ * dotted path.
3530
+ *
3531
+ * Hostile-input rows (23-25 of the test matrix) all resolve to
3532
+ * `STATE_FIELD_ABSENT` rather than throwing: a missing parent, a scalar
3533
+ * parent (`typeof cursor !== 'object'`), and — the prototype-pollution
3534
+ * case — a `__proto__`/`constructor`/`toString` segment. The own-property
3535
+ * check (`Object.prototype.hasOwnProperty.call`, never a bare `in` or
3536
+ * bracket read) is what makes the last one safe: an inherited
3537
+ * `Object.prototype` member is never mistaken for an own data key, and
3538
+ * because this function only ever READS a segment (never assigns one),
3539
+ * no prototype can be polluted by walking it.
3540
+ */
3541
+ function resolveFrontmatterPath(fm, path) {
3542
+ if (Object.prototype.hasOwnProperty.call(fm, path))
3543
+ return fm[path];
3544
+ if (!path.includes('.'))
3545
+ return STATE_FIELD_ABSENT;
3546
+ let cursor = fm;
3547
+ for (const segment of path.split('.')) {
3548
+ if (typeof cursor !== 'object' || cursor === null || Array.isArray(cursor))
3549
+ return STATE_FIELD_ABSENT;
3550
+ if (!Object.prototype.hasOwnProperty.call(cursor, segment))
3551
+ return STATE_FIELD_ABSENT;
3552
+ cursor = cursor[segment];
3553
+ }
3554
+ return cursor;
3555
+ }
3556
+ /**
3557
+ * ADR-3473 §8.7 (#3872): representation-insensitive equality for a
3558
+ * persisted-vs-snapshot leaf value (test matrix rows 21/22). Frontmatter
3559
+ * scalars round-trip as STRINGS (`extractFrontmatter`, §8.1's open type
3560
+ * question) while an in-memory derivation can hold a real number or boolean
3561
+ * — a naive `!==` would report every numeric/boolean field changed on every
3562
+ * write. Mirrors the existing `divergedFields` diff's typeof-object branch
3563
+ * in `applyPostSyncPreservation` (JSON.stringify for objects, else a
3564
+ * normalized scalar compare) rather than inventing a second comparison.
3565
+ * Presence-vs-absence (`STATE_FIELD_ABSENT` on exactly one side) is always a
3566
+ * change — a deleted or newly-added key (test matrix rows 16/17) — never
3567
+ * folded into the scalar branch below it.
3568
+ */
3569
+ /**
3570
+ * ADR-3473 §8.7 (#3872): `String(v)` on an `unknown` is unsafe (a hostile
3571
+ * object could carry a custom, throwing, or `[object Object]`-degrading
3572
+ * `toString`) — narrowed per-branch here so each `String()` call below only
3573
+ * ever runs on a primitive TypeScript itself knows is safe to stringify.
3574
+ */
3575
+ function stateScalarString(v) {
3576
+ if (v === null || v === undefined)
3577
+ return '';
3578
+ if (typeof v === 'string')
3579
+ return v;
3580
+ if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint')
3581
+ return String(v);
3582
+ return JSON.stringify(v) ?? '';
3583
+ }
3584
+ function stateFieldValuesDiffer(before, after) {
3585
+ if (before === STATE_FIELD_ABSENT && after === STATE_FIELD_ABSENT)
3586
+ return false;
3587
+ if (before === STATE_FIELD_ABSENT || after === STATE_FIELD_ABSENT)
3588
+ return true;
3589
+ if (typeof before === 'object' || typeof after === 'object') {
3590
+ return JSON.stringify(before) !== JSON.stringify(after);
3591
+ }
3592
+ return stateScalarString(before).trim() !== stateScalarString(after).trim();
3593
+ }
3594
+ /**
3595
+ * ADR-3473 §8.7 (#3872): the declared dotted-leaf children of a frontmatter
3596
+ * key, read off `FIELD_CLASSIFICATION` (`progress` -> its five
3597
+ * `progress.*` rows) rather than walked from arbitrary nesting depth of a
3598
+ * user-authored document. A BOUNDED, DECLARED enumeration — the design
3599
+ * doc's Rejected #5 and the "Emit dotted leaves, not the parent" rule both
3600
+ * depend on this staying a closed set the schema names, not unbounded
3601
+ * traversal of whatever object shape happens to be on disk.
3602
+ */
3603
+ function declaredLeavesOf(key) {
3604
+ const prefix = `${key}.`;
3605
+ return Object.keys(FIELD_CLASSIFICATION).filter((k) => k.startsWith(prefix));
3606
+ }
3607
+ /**
3608
+ * ADR-3473 §8.7 (#3872): every frontmatter key — resolved at DOTTED-LEAF
3609
+ * granularity for a key with declared leaves (`progress` -> only the
3610
+ * `progress.*` leaves that actually moved, never bare `progress` itself;
3611
+ * design doc rule 4/Rejected #5) — whose PERSISTED value differs from the
3612
+ * transaction's pre-write SNAPSHOT. Pure: no I/O, no `FIELD_CLASSIFICATION`
3613
+ * preservation-policy consultation (that filter is exactly what this rule
3614
+ * deletes — ADR-3473 §8.7 "no field is excluded by classification").
3615
+ * `last_updated` is the one-element provenance exclusion; every other key,
3616
+ * including `state_head`, is a candidate.
3617
+ *
3618
+ * **A `FRONTMATTER_BODY_SOURCE` key is diffed via `bodyDeltas`, never via a
3619
+ * raw frontmatter compare.** Found while driving the #1264 regression check
3620
+ * through this rewrite at the CLI: `syncStateFrontmatter` re-derives EVERY
3621
+ * body-sourced key into frontmatter on EVERY write, independent of whether
3622
+ * this write's own transform touched it. A hand-authored (or day-1
3623
+ * bootstrap) STATE.md whose frontmatter has not yet caught up to an
3624
+ * already-stable body value — e.g. `current_phase_name` present in the body
3625
+ * but absent from a pre-write frontmatter block that only ever recorded
3626
+ * `status`/`progress` — makes that key look newly ADDED under a raw diff
3627
+ * (rows 15/17) even though nothing changed. The real "did THIS write change
3628
+ * it" signal for these keys is whether their BODY SOURCE moved, which is
3629
+ * exactly what `bodyDeltas` (built once, in `applyPostSyncPreservation`,
3630
+ * from `originalContent` vs `transformedContent`) already answers — reused
3631
+ * here rather than re-derived, and it is what correctly REPORTS #3818's
3632
+ * `current_phase` (the body source did move) while staying SILENT on a
3633
+ * merely-backfilled, body-unchanged key (the #1264 false positive this
3634
+ * function's first cut produced).
3635
+ *
3636
+ * **A declared dotted-leaf (`declaredLeavesOf`, e.g. every `progress.*` row)
3637
+ * absent from the snapshot and present in persisted is materialization, not
3638
+ * a change.** Found the same way as the paragraph above, one layer down:
3639
+ * `progress` is `source: 'disk'` (state-transition.cts), re-derived by
3640
+ * `buildStateFrontmatter`'s phase-directory scan on every write regardless
3641
+ * of whether the caller's own action touched it — and the phases directory
3642
+ * cannot move during a STATE.md write, so a fresh `progress` block appearing
3643
+ * where the snapshot had none is the scanner catching a never-synced
3644
+ * document up, not the caller changing anything. This is the SAME
3645
+ * provenance principle `STATE_UPDATED_PROVENANCE_EXCLUSION` applies to
3646
+ * `last_updated` (a field stamped by the write's occurrence, not its
3647
+ * action) — generalized to the declared-leaf case, deliberately NOT a
3648
+ * second classification-based exclusion: `progress`'s `preserve-always`
3649
+ * policy plays no part in the check below, and a leaf already PRESENT in
3650
+ * the snapshot is diffed exactly as every other field is, including
3651
+ * reporting its outright disappearance (row 16) — only the absent-in-
3652
+ * snapshot-but-materialized-in-persisted transition is suppressed.
3653
+ */
3654
+ function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
3655
+ const changed = [];
3656
+ const topKeys = new Set([...Object.keys(snapshotFm), ...Object.keys(persistedFm)]);
3657
+ for (const key of topKeys) {
3658
+ if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(key))
3659
+ continue;
3660
+ if (stateTransitionMod.getFrontmatterBodySource(key) !== null) {
3661
+ const delta = bodyDeltas ? bodyDeltas[key] : undefined;
3662
+ if (delta && stateFieldValuesDiffer(delta.pre ?? STATE_FIELD_ABSENT, delta.post ?? STATE_FIELD_ABSENT)) {
3663
+ changed.push(key);
3664
+ }
3665
+ continue;
3666
+ }
3667
+ const leaves = declaredLeavesOf(key);
3668
+ if (leaves.length > 0) {
3669
+ for (const leaf of leaves) {
3670
+ const before = resolveFrontmatterPath(snapshotFm, leaf);
3671
+ const after = resolveFrontmatterPath(persistedFm, leaf);
3672
+ // Generalizes the SAME provenance principle STATE_UPDATED_PROVENANCE_EXCLUSION
3673
+ // applies to `last_updated` one level up — this is NOT a classification-based
3674
+ // exclusion (progress's `preserve-always` policy plays no part here; that filter
3675
+ // stays deleted per §8.7). It is a fact about the DECLARED LEAF SET: every key
3676
+ // enumerated by `declaredLeavesOf` is `source: 'disk'` (state-transition.cts),
3677
+ // re-derived from a scan that cannot move during a STATE.md write (the write only
3678
+ // touches STATE.md, never the phases directory). So a leaf ABSENT from the
3679
+ // pre-write snapshot and PRESENT in persisted is the scanner catching a document
3680
+ // up to a derivation it had never synced before — the write's own OCCURRENCE
3681
+ // produced the bytes, not the caller's ACTION, exactly the `last_updated` shape.
3682
+ // A leaf already PRESENT in the snapshot behaves normally: any difference
3683
+ // (including disappearing entirely, row 16) is reported, because there the
3684
+ // snapshot proves the derivation had already run once, so a new persisted value
3685
+ // can only come from something genuinely moving (#3743/#3818).
3686
+ if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
3687
+ continue;
3688
+ if (stateFieldValuesDiffer(before, after))
3689
+ changed.push(leaf);
3690
+ }
3691
+ continue;
3692
+ }
3693
+ const before = resolveFrontmatterPath(snapshotFm, key);
3694
+ const after = resolveFrontmatterPath(persistedFm, key);
3695
+ if (stateFieldValuesDiffer(before, after))
3696
+ changed.push(key);
3697
+ }
3698
+ return changed;
3699
+ }
3700
+ /**
3701
+ * ADR-3473 §8.7 (issue #3872): the transaction diff. `updated` is derived
3702
+ * by comparing PERSISTED frontmatter against the transaction's pre-write
3703
+ * SNAPSHOT — replacing the prior comparison of the transform's own OUTPUT
3704
+ * against persisted bytes, which answered a different question ("did the
3705
+ * transform's write survive to disk", #3351) from the one §8.7 asks ("what
3706
+ * did this write actually change" — both #3351's direction and #3345/#3818's
3707
+ * fall out of ONE comparison against the pre-write state; see the design
3708
+ * doc's "ambiguity in §8.7" section for why the transform-output comparison
3709
+ * was rejected).
3710
+ *
3711
+ * No field is excluded by classification — `getFieldClassification` /
3712
+ * `preservation !== 'preserve-when-unchanged'` is gone, not relocated. The
3713
+ * ONLY exclusion is `STATE_UPDATED_PROVENANCE_EXCLUSION` (provenance, not
3714
+ * classification): an unchanged `progress` no longer needs a special filter
3715
+ * to stay unreported (#1264) because the diff itself says "unchanged" —
3716
+ * and a GENUINELY changed `progress.*` leaf (#3743, #3818) is no longer
3717
+ * suppressed by the same filter.
3718
+ *
3719
+ * @param preWriteState The transaction's pre-write snapshot + body — the
3720
+ * `preWriteState` out-param `applyPostSyncPreservation` filled during
3721
+ * THIS write (see `ReadModifyWriteOptions.preWriteState`'s docstring).
3722
+ * `.fm`/`.body` are `undefined` only when `readModifyWriteStateMd`'s own
3723
+ * #948 no-op guard fired (transform output was byte-identical to input),
3724
+ * in which case nothing was ever written and `[]` is the correct,
3725
+ * short-circuited answer — never a diff against a synthesized empty `{}`
3726
+ * snapshot, which would read every already-persisted key as newly ADDED.
3727
+ * @param reported The candidate field names — the transform's OWN success
3728
+ * list. Body Title-Case labels (`Status`, `Current Plan`, `Current
3729
+ * Position`) and frontmatter keys (including dotted leaves like
3730
+ * `progress.total_plans`) are both valid; each is resolved via the same
3731
+ * `valueOf` fallback chain used for the inclusion test below.
3732
+ * @param divergedFields Kept for signature/out-param stability (ADR-3408
3733
+ * §8.5) — populated exactly as before by `applyPostSyncPreservation` and
3734
+ * still read directly by other code and `tests/state.test.cjs`'s A2f case
3735
+ * — but no longer consulted here as a candidate SOURCE (design doc row
3736
+ * 18): the frontmatter diff subsumes what it used to contribute, and it
3737
+ * sees only what *preservation* changed, never what *sync* changed
3738
+ * (#3818's own direction), which is why keeping it as the candidate
3739
+ * source was rejected (design doc, Rejected #1).
3740
+ */
3741
+ function reconcileReportedFields(statePath, preWriteState, reported, divergedFields) {
3742
+ void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
3743
+ if (preWriteState.fm === undefined || preWriteState.body === undefined)
3744
+ return [];
3745
+ const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
3746
+ const persistedFm = extractFrontmatter(persisted, statePath);
3747
+ const persistedBody = stripFrontmatter(persisted);
3748
+ const snapshotFm = preWriteState.fm;
3749
+ const snapshotBody = preWriteState.body;
3750
+ // #3471 review (unchanged by this rewrite): body-FIRST, frontmatter-key-
3751
+ // FLAT-fallback, dotted-PATH-fallback last. Body-first mirrors the actual
3752
+ // write precedence `patchCore`/`updateCore` apply (#1162's fix — a
3753
+ // lowercase body label that happens to case-exact-match a frontmatter key
3754
+ // must still resolve against the body). `field` a literal flat key (even
3755
+ // one containing a `.`) is tried before it is split and walked as a
3756
+ // dotted path (test matrix row 26) — `resolveFrontmatterPath` pins that
3757
+ // same order for the frontmatter side alone.
3758
+ //
3759
+ // `Current Position` is special-cased: it names the WHOLE `## Current
3760
+ // Position` section, not a single `Label: value` line, so
3761
+ // `stateExtractField` can never resolve it (this is the root cause of the
3762
+ // "Current Position undercount" — a transform can correctly push
3763
+ // `'Current Position'` into its own `updated` list, and this function
3764
+ // still silently dropped it, because `valueOf` returned `null` for BOTH
3765
+ // sides and `null === null` failed the old `intended !== null` guard).
3766
+ // `sliceCurrentPositionSection` is the existing fence-aware section
3767
+ // locator (state-transition.cts) — reused rather than re-derived.
3768
+ const valueOf = (fm, body, field) => {
3769
+ if (field === 'Current Position') {
3770
+ const section = stateTransitionMod.sliceCurrentPositionSection(body);
3771
+ return section !== null ? section.trim() : null;
3772
+ }
3773
+ const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
3774
+ if (bodyValue !== null)
3775
+ return bodyValue;
3776
+ if (Object.prototype.hasOwnProperty.call(fm, field))
3777
+ return String(fm[field]);
3778
+ if (field.includes('.')) {
3779
+ const resolved = resolveFrontmatterPath(fm, field);
3780
+ if (resolved !== STATE_FIELD_ABSENT) {
3781
+ return stateScalarString(resolved);
3782
+ }
3783
+ }
3784
+ return null;
3785
+ };
3786
+ // A field in `reported` can itself be a declared derived leaf (e.g.
3787
+ // `plannedPhaseCore` pushing `'progress.total_plans'` — state-
3788
+ // transition.cts:1752). `valueOf`'s null-vs-string convention cannot tell
3789
+ // "absent from the frontmatter" apart from "resolved to the literal string
3790
+ // 'null'/''", so it cannot carry the same materialization rule
3791
+ // `computeChangedFrontmatterFields` applies below. Route these fields
3792
+ // through the SAME primitives (`resolveFrontmatterPath` + the
3793
+ // `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
3794
+ // second, parallel absence convention — one rule, reused, not duplicated.
3795
+ const isDeclaredDerivedLeaf = (candidate) => candidate.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, candidate);
3796
+ const changed = (field) => {
3797
+ if (isDeclaredDerivedLeaf(field)) {
3798
+ const before = resolveFrontmatterPath(snapshotFm, field);
3799
+ const after = resolveFrontmatterPath(persistedFm, field);
3800
+ // Same generalized provenance rule as computeChangedFrontmatterFields:
3801
+ // absent-in-snapshot-materializing-in-persisted is the disk scan
3802
+ // catching a never-synced document up, not this write's own action.
3803
+ if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
3804
+ return false;
3805
+ return stateFieldValuesDiffer(before, after);
3806
+ }
3807
+ const before = valueOf(snapshotFm, snapshotBody, field);
3808
+ const after = valueOf(persistedFm, persistedBody, field);
3809
+ if (before === null && after === null)
3810
+ return false;
3811
+ if (before === null || after === null)
3812
+ return true;
3813
+ return before.trim() !== after.trim();
3814
+ };
3815
+ // Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
3816
+ // granularity) whose persisted value differs from the snapshot, minus the
3817
+ // provenance exclusion. A frontmatter-diff-discovered field is mapped
3818
+ // through `bodyLabelFor` so it lands in the SAME output vocabulary a
3819
+ // transform would have used (`status` -> `'Status'`; `progress.total_plans`
3820
+ // has no body-line label and falls through to its raw dotted key, same as
3821
+ // today's `progress`/`milestone*` fall-through).
3822
+ const changedFrontmatterFields = computeChangedFrontmatterFields(snapshotFm, persistedFm, preWriteState.bodyDeltas);
3823
+ const mappedFrontmatterFields = changedFrontmatterFields.map((field) => bodyLabelFor(field));
3824
+ const seen = new Set();
3825
+ const reconciled = [];
3826
+ for (const field of reported) {
3827
+ if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
3828
+ continue;
3829
+ if (changed(field)) {
3830
+ seen.add(field);
3831
+ reconciled.push(field);
3832
+ }
3833
+ }
3834
+ for (const field of mappedFrontmatterFields) {
3835
+ if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
3836
+ continue;
3837
+ seen.add(field);
3838
+ reconciled.push(field);
3839
+ }
3840
+ return reconciled;
3841
+ }
2191
3842
  function cmdStateJson(cwd, raw) {
2192
3843
  const statePath = planningPaths(cwd).state;
2193
3844
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -2200,28 +3851,82 @@ function cmdStateJson(cwd, raw) {
2200
3851
  // Always rebuild from body + disk so progress counters reflect current state.
2201
3852
  // Returning cached frontmatter directly causes stale percent/completed_plans
2202
3853
  // when SUMMARY files were added after the last STATE.md write (#1589).
2203
- const built = buildStateFrontmatter(body, cwd);
2204
- // Preserve frontmatter-only fields that cannot be recovered from the body.
2205
- if (existingFm && existingFm['stopped_at'] && !built['stopped_at']) {
2206
- built['stopped_at'] = existingFm['stopped_at'];
2207
- }
2208
- if (existingFm && existingFm['paused_at'] && !built['paused_at']) {
2209
- built['paused_at'] = existingFm['paused_at'];
2210
- }
2211
- // Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
2212
- if (built['status'] === 'unknown' && existingFm && existingFm['status'] && existingFm['status'] !== 'unknown') {
2213
- built['status'] = existingFm['status'];
2214
- }
2215
- // Bug #905: preserve scalar fields when body annotations are absent.
2216
- // Mirrors the same fallback pattern applied in syncStateFrontmatter.
2217
- if (existingFm && !built['current_phase'] && existingFm['current_phase']) {
2218
- built['current_phase'] = existingFm['current_phase'];
2219
- }
2220
- if (existingFm && !built['current_phase_name'] && existingFm['current_phase_name']) {
2221
- built['current_phase_name'] = existingFm['current_phase_name'];
2222
- }
2223
- if (existingFm && !built['current_plan'] && existingFm['current_plan']) {
2224
- built['current_plan'] = existingFm['current_plan'];
3854
+ // #3354: pass the stored total so the milestoned-but-unbounded withhold can
3855
+ // report the preserved value instead of omitting the key.
3856
+ // #3573: pass the STORED MILESTONE too (same parity reasoning) — otherwise the
3857
+ // roadmap-absent withhold never fires on this read surface and `state json`
3858
+ // reports the phase-directory count while the persisted file preserves the
3859
+ // stored total, exactly the write/read divergence #3354 closed for its shape.
3860
+ const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
3861
+ const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
3862
+ // ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
3863
+ // current_phase_name / current_plan through the SAME `preserve-when-unchanged`
3864
+ // executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
3865
+ // third private copy of the empty-only guards with no delta/staleness check
3866
+ // at all — the shape that let a stale-but-present body annotation always
3867
+ // beat a fresher curated frontmatter value in `state json` output (#3395's
3868
+ // shape outside the write seam).
3869
+ //
3870
+ // `cmdStateJson` never writes — it is one snapshot read, not a
3871
+ // before/after transform — so "did THIS write change the body source"
3872
+ // (the #1230 delta the executor consults) is definitionally "no": every
3873
+ // field's body source is passed as its own delta pre/post pair (the same
3874
+ // value twice). That is what makes the executor's rule resolve to
3875
+ // "restore the curated value whenever a real one exists" here — exactly
3876
+ // §8.5's "same terms as an empty derived value" extended to a present
3877
+ // one, i.e. the exact D3 fix. Deliberately scoped to only these six
3878
+ // fields (not the full `applyStatePreservation` dispatch loop): `progress`
3879
+ // (preserve-always) keeps its own `shouldPreserveExistingProgress`
3880
+ // cross-milestone rule below — a DIFFERENT policy that must survive this
3881
+ // change untouched — and `milestone`/`milestone_name`
3882
+ // (preserve-if-placeholder) are out of D3's scope entirely.
3883
+ if (existingFm) {
3884
+ const sessionScope = matchSessionSection(body) ?? body;
3885
+ const positionScope = matchCurrentPositionSection(body) ?? body;
3886
+ const bodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped at');
3887
+ const bodyPausedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
3888
+ const bodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(body, 'Phase');
3889
+ const bodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase')
3890
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
3891
+ const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
3892
+ const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
3893
+ // #3836: mirrors applyPostSyncPreservation's own derivation (state.cts
3894
+ // bodyDeltas, `last_activity_desc`) — the `Last Activity Description`
3895
+ // label, falling back to the prose `Last Activity:` line's parsed
3896
+ // description. Read-side twin of #3258's write-side wiring; this field is
3897
+ // `preserve-when-unchanged` per FIELD_CLASSIFICATION and was previously
3898
+ // absent from this read path entirely (never derived here, never in the
3899
+ // loop below), so a stale body annotation always beat a fresher curated
3900
+ // frontmatter value on every `state json` read.
3901
+ const bodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last activity');
3902
+ const bodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity Description')
3903
+ ?? parseProseLastActivityField(bodyLastActivityRaw).description;
3904
+ const unchanged = (v) => ({ pre: v, post: v });
3905
+ const ctx = {
3906
+ postFm: built,
3907
+ snapshot: existingFm,
3908
+ resync: true,
3909
+ deriveProgressKeys: false,
3910
+ bodyDeltas: {
3911
+ status: unchanged(bodyStatus),
3912
+ stopped_at: unchanged(bodyStoppedAt),
3913
+ paused_at: unchanged(bodyPausedAt),
3914
+ current_phase: unchanged(bodyCurrentPhase),
3915
+ current_plan: unchanged(bodyCurrentPlan),
3916
+ current_phase_name: unchanged(bodyPhaseSource),
3917
+ last_activity_desc: unchanged(bodyLastActivityDesc),
3918
+ },
3919
+ mutated: false,
3920
+ };
3921
+ // #3836: derive the field set from FIELD_CLASSIFICATION's
3922
+ // `preserve-when-unchanged` rows (single source of truth) instead of a
3923
+ // hand-typed literal that can drift from the table — this IS the fix,
3924
+ // not merely an addition of one more name to the literal.
3925
+ for (const field of stateTransitionMod.getPreserveWhenUnchangedFields()) {
3926
+ const cls = stateTransitionMod.getFieldClassification(field);
3927
+ if (cls)
3928
+ stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
3929
+ }
2225
3930
  }
2226
3931
  // Preserve curated cross-milestone aggregates when local disk scanning sees
2227
3932
  // only a narrower realized subset (#3242 Bug A). Stale lower counters still
@@ -2269,13 +3974,29 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
2269
3974
  // that itself contains a parenthetical. The #1695 delta-gate preservation
2270
3975
  // still runs after the sync; the override is re-asserted after it inside
2271
3976
  // readModifyWriteStateMd for layouts with no body `Phase:` line.
3977
+ const divergedFields = [];
3978
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
3979
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
3980
+ const preWriteState = {};
2272
3981
  const rmwOptions = {
2273
3982
  authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
3983
+ divergedFields,
3984
+ preWriteState,
2274
3985
  };
2275
- let updated = [];
2276
- readModifyWriteStateMd(statePath, (content) => {
3986
+ let precomputedUpdated = [];
3987
+ // #3311: begin-phase is the claim point — it is the one Current Position
3988
+ // transition that explicitly names its phase, so it both records this
3989
+ // session's claim and detects a conflicting live claim for a different
3990
+ // phase. The check runs INSIDE the STATE.md lock so concurrent begin-phase
3991
+ // calls cannot both read "no claim" and both write.
3992
+ let milestoneConflict = null;
3993
+ const wrote = readModifyWriteStateMd(statePath, (content) => {
3994
+ milestoneConflict = milestoneLockMod.claimMilestonePhase(cwd, String(phaseNumber));
3995
+ if (milestoneConflict) {
3996
+ milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
3997
+ }
2277
3998
  const result = transitionCore(content, intent, deps);
2278
- updated = result.updated;
3999
+ precomputedUpdated = result.updated;
2279
4000
  // #3127 resume: the core preserved the mid-flight Current Phase Name, so
2280
4001
  // the intent-first override must not fire — it would drift frontmatter
2281
4002
  // away from the preserved body value. Dropping it here is safe because
@@ -2285,7 +4006,33 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
2285
4006
  }
2286
4007
  return result.content;
2287
4008
  }, cwd, rmwOptions);
2288
- output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
4009
+ // ADR-3408 §8.4 (D4): reconcile `beginPhaseCore`'s own success list against
4010
+ // the bytes actually persisted (fix(#3351) generalized) and fold in any
4011
+ // field preservation restored that this transform never touched (#3345's
4012
+ // direction).
4013
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
4014
+ output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, milestone_conflict: milestoneConflict }, raw, updated.length > 0 ? 'true' : 'false');
4015
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on `wrote`
4016
+ // (readModifyWriteStateMd's own return value — its #948 no-op guard skips
4017
+ // the write outright when the transform produced no diff), not on
4018
+ // `updated.length > 0`. Confirmed reproducer: an unrecognized-format
4019
+ // STATE.md makes `beginPhaseCore` match zero body fields AND leave
4020
+ // `existingFm` untouched, so the raw transform output is byte-identical to
4021
+ // the input, the RMW guard fires, and `wrote` is false — matching
4022
+ // `updated: []` here. Unlike `cmdStatePlannedPhase` (which must NOT use
4023
+ // this same `wrote` signal — see its comment for why `plannedPhaseCore`
4024
+ // mutates frontmatter in place even on this exact no-op shape),
4025
+ // `beginPhaseCore` never mutates `existingFm`, so `wrote` and
4026
+ // `updated.length > 0` agree on every case audited for this phase; `wrote`
4027
+ // is kept as the gate here (and on `cmdStateAdvancePlan`/
4028
+ // `cmdStateCompletePhase` below, where it is REQUIRED — `updated`/
4029
+ // `reconciled` can be non-empty there even when nothing was written,
4030
+ // confirmed by direct re-invocation) for one consistent rule across every
4031
+ // RMW-backed command in this file: publish iff `readModifyWriteStateMd`
4032
+ // itself reports a write. Best-effort — cannot throw, cannot change this
4033
+ // command's exit code or output.
4034
+ if (wrote)
4035
+ publishStateContract(cwd);
2289
4036
  }
2290
4037
  /**
2291
4038
  * Write a WAITING.json signal file when GSD hits a decision point.
@@ -2392,7 +4139,7 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
2392
4139
  // direction (#1659): canonicalize a numeric phase to its integer form so a seeded
2393
4140
  // "| 05 |" row is upserted (not duplicated) by `phase complete 5`, and vice-versa.
2394
4141
  const phaseNumStr = String(phaseNum);
2395
- const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : escapeRegex(phaseNumStr);
4142
+ const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : (0, pattern_cjs_1.escapeRegex)(phaseNumStr);
2396
4143
  const phaseCellRe = new RegExp(`^${canonCell}$`, 'i');
2397
4144
  const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
2398
4145
  const before = content.slice(0, tableStart);
@@ -2508,7 +4255,7 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
2508
4255
  * Gate 3a: Record state after plan-phase completes.
2509
4256
  * Updates Status to "Ready to execute", Total Plans, Last Activity.
2510
4257
  */
2511
- function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
4258
+ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
2512
4259
  const statePath = planningPaths(cwd).state;
2513
4260
  if (!node_fs_1.default.existsSync(statePath)) {
2514
4261
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -2525,22 +4272,87 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
2525
4272
  const intent = {
2526
4273
  kind: 'plannedPhase',
2527
4274
  phaseNumber,
4275
+ phaseName: phaseName ?? null,
2528
4276
  planCount: planCount ?? null,
2529
4277
  };
2530
4278
  const deps = {
2531
4279
  clock: clock_cjs_1.realClock,
2532
4280
  sourcePath: statePath,
2533
4281
  };
2534
- let updated = [];
4282
+ // #3395 / #2736: the transition holds the exact display name. plannedPhaseCore
4283
+ // writes it into the Current Position `Phase: N (Name) — READY TO EXECUTE`
4284
+ // line, and the prose re-derivation of current_phase_name truncates names
4285
+ // that themselves contain a parenthetical — the authoritative override keeps
4286
+ // the exact value, exactly as cmdStateBeginPhase does for its EXECUTING line.
4287
+ //
4288
+ // #3834: without a name, the body-source delta rule that would normally
4289
+ // preserve the curated `current_phase_name` (FIELD_CLASSIFICATION:
4290
+ // preserve-when-unchanged) cannot fire — THIS write rewrites the `Phase:`
4291
+ // source line to `N — READY TO EXECUTE` itself, so pre/post disagree by
4292
+ // construction and the post-sync re-derivation harvests "READY TO EXECUTE"
4293
+ // as if it were the name. The fix mirrors the named-arg path: reassert an
4294
+ // authoritative override, falling back to the pre-write curated value (read
4295
+ // inside the RMW callback, before this write's own body mutation) rather
4296
+ // than leaving the field to a delta heuristic this exact transition defeats.
4297
+ const divergedFields = [];
4298
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
4299
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
4300
+ const preWriteState = {};
4301
+ const rmwOptions = {
4302
+ resync: false,
4303
+ deriveProgressKeys: true,
4304
+ authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
4305
+ divergedFields,
4306
+ preWriteState,
4307
+ };
4308
+ let precomputedUpdated = [];
2535
4309
  readModifyWriteStateMd(statePath, (content) => {
4310
+ if (!intent.phaseName) {
4311
+ const preFm = extractFrontmatter(content, statePath);
4312
+ const curatedName = preFm['current_phase_name'];
4313
+ if (typeof curatedName === 'string' && curatedName.trim().length > 0) {
4314
+ rmwOptions.authoritativeFm = { current_phase_name: curatedName };
4315
+ }
4316
+ }
2536
4317
  const result = transitionCore(content, intent, deps);
2537
- updated = result.updated;
4318
+ precomputedUpdated = result.updated;
2538
4319
  return result.content;
2539
- }, cwd, { resync: false, deriveProgressKeys: true });
4320
+ }, cwd, rmwOptions);
4321
+ // ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
4322
+ // against the bytes actually persisted (fix(#3351) generalized) and fold
4323
+ // in any field preservation restored that this transform never touched
4324
+ // (#3345's direction) — traced for this phase (design doc: "not traced in
4325
+ // the analysis pass") and found to need exactly the same treatment as
4326
+ // `cmdStateBeginPhase`.
4327
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
2540
4328
  const result = updated.length === 0
2541
4329
  ? { 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.).' }
2542
4330
  : { updated, phase: phaseNumber, plan_count: planCount };
2543
4331
  output(result, raw, updated.length > 0 ? 'true' : 'false');
4332
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on
4333
+ // `updated.length > 0`, NOT on `readModifyWriteStateMd`'s own write-happened
4334
+ // return value. The two are NOT equivalent here: readModifyWriteStateMd's
4335
+ // #948 no-op guard compares the transform's RAW returned string against the
4336
+ // RAW original file content, but `syncStateFrontmatter`'s progress-block
4337
+ // sync and this command's `authoritativeFm: {current_phase_name}` override
4338
+ // both run INSIDE the transform (via `frontmatterMod.reconstructFrontmatter`
4339
+ // over `existingFm`), so an unrecognized-format STATE.md — zero fields the
4340
+ // transition could actually apply, `updated: []`, the "transition was a
4341
+ // no-op" warning above — can still make the raw returned string differ
4342
+ // from the input (frontmatter gets synthesized: `gsd_state_version`,
4343
+ // `last_updated`, a zeroed `progress` block, `current_phase_name`), so the
4344
+ // RMW guard does NOT fire and a real write happens. That write is not a
4345
+ // meaningful state transition by this command's OWN reporting contract
4346
+ // (`updated: []`) — publishing on it would refresh state.json's
4347
+ // `updated_at` for a call this command itself reports did nothing.
4348
+ // `updated.length > 0` is the field-classification-table-backed signal
4349
+ // that actually answers "did plannedPhaseCore itself change anything this
4350
+ // caller asked it to change" — empirically verified: an unrecognized-format
4351
+ // STATE.md reproduces `updated: []` with a genuine (frontmatter-only) disk
4352
+ // write underneath it, and gating on `updated.length > 0` is what makes
4353
+ // this reproducer NOT publish.
4354
+ if (updated.length > 0)
4355
+ publishStateContract(cwd);
2544
4356
  }
2545
4357
  /**
2546
4358
  * Bug #2630: reset STATE.md for a new milestone cycle.
@@ -2563,25 +4375,144 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
2563
4375
  // steady-state syncStateFrontmatter post-sync.
2564
4376
  const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
2565
4377
  const deps = { clock: clock_cjs_1.realClock, sourcePath: statePath };
4378
+ let switched = false;
2566
4379
  const lockPath = acquireStateLock(statePath);
2567
4380
  try {
2568
4381
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
2569
4382
  const result = transitionCore(content, intent, deps);
2570
4383
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, result.content);
2571
4384
  output({ switched: true, version, name: resolvedName, status: 'planning' }, raw, 'true');
4385
+ switched = true;
2572
4386
  }
2573
4387
  finally {
2574
4388
  releaseStateLock(lockPath);
2575
4389
  }
4390
+ // #3227: publish AFTER releaseStateLock — publishStateContract derives `next`
4391
+ // from classifyProject, which shells out to git (bounded, but up to 3 x 10s).
4392
+ // Holding the STATE.md lock across that would turn a millisecond hold into a
4393
+ // git-bound one for every concurrent GSD process.
4394
+ if (switched)
4395
+ publishStateContract(cwd);
2576
4396
  }
2577
4397
  /**
2578
4398
  * Gate 1: Validate STATE.md against filesystem.
2579
- * Returns { valid, warnings, drift } JSON.
4399
+ * Returns { valid, warnings, drift, scope } JSON.
4400
+ *
4401
+ * #3187 (ADR-3180 §7.7, Decisions 2-4): two defects fixed here.
4402
+ *
4403
+ * (1) #3162 THE HEADLINE. Every warning this function can emit used to be
4404
+ * gated behind `if (currentPhase && fs.existsSync(phasesDir))`, and
4405
+ * `currentPhase` came from a body-only `stateExtractField(content, 'Current
4406
+ * Phase')` call with no frontmatter fallback. A STATE.md whose phase lives
4407
+ * ONLY in frontmatter therefore resolved `currentPhase` to `null`, the whole
4408
+ * drift block was skipped, and the function returned
4409
+ * `{valid:true, warnings:[], drift:{}}` — "could not look" was
4410
+ * output-identical to "looked, all clean." Current Phase / Status / Total
4411
+ * Plans in Phase now route through `stateFieldValue` (the single owner of the
4412
+ * #1760 frontmatter-then-body fallback chain), so the frontmatter tier is
4413
+ * actually consulted.
4414
+ *
4415
+ * (2) #1255 FRONTMATTER SHADOWING. The old code passed UNSTRIPPED `content`
4416
+ * to the extractor. `stateExtractField`'s plain-format branch is
4417
+ * `^Field:` with the `i` flag, so a frontmatter `status:` key matched the
4418
+ * pattern for the body field `Status` and won, because the frontmatter block
4419
+ * precedes the body. Parsed once now — `extractFrontmatter` +
4420
+ * `stripFrontmatter` — and `fm`/`body` are handed to the chain owner, exactly
4421
+ * as `advancePlanCore`/`beginPhaseCore`/`completePhaseCore`/
4422
+ * `readModifyWriteStateMd` already guard against this class of defect.
4423
+ *
4424
+ * `scope` (ADR-3180 Decision 2) reports whether the derivation actually ran:
4425
+ * - `COMPLETE` — the phase-vs-disk derivation ran over usable input,
4426
+ * including when it legitimately finds no VERIFICATION.md / no matching
4427
+ * phase directory (a real answer, not a non-answer).
4428
+ * - `UNSCOPED` — Current Phase could not be resolved by ANY chain step (no
4429
+ * frontmatter scalar, no body field), so the drift derivation had no
4430
+ * phase to scope its disk lookup to and could not run at all. Reporting
4431
+ * this as COMPLETE would recreate the #3162 collapse this phase closes,
4432
+ * one layer out.
4433
+ * - `UNREADABLE` — the frontmatter parse or the phases-dir scan itself
4434
+ * could not be consulted (an existing `catch` block used to swallow this
4435
+ * silently; the degrade stays, but is now visible).
4436
+ *
4437
+ * ⛔ Rejected (ADR-3180 §7.7 Rejected #2): a non-`COMPLETE` scope is never
4438
+ * routed to `valid:false`. `valid` keeps meaning "no drift warnings were
4439
+ * found"; `scope` says whether the derivation could actually run. A caller
4440
+ * branches on both — folding them into one boolean recreates the exact
4441
+ * collapse this epic removes, in the opposite direction (a legacy STATE.md
4442
+ * with no resolvable phase is a supported degrade, not an invalid document).
4443
+ */
4444
+ /**
4445
+ * #1255/#3187: parse frontmatter and strip it from the body ONCE, shared by
4446
+ * `cmdStateValidate` and `cmdStateCompletePhase` so both consult the identical
4447
+ * fm/body precedence and degrade identically when the frontmatter half of the
4448
+ * chain cannot be consulted. Extracted (code-review finding, epic #3180): the
4449
+ * two call sites previously carried a byte-identical try/catch, comments
4450
+ * included — an epic whose own thesis is "one canonical owner per
4451
+ * derivation" must not ship a duplicated derivation in its own diff.
4452
+ *
4453
+ * Returns `scope: SCOPE.COMPLETE` unless the frontmatter parse itself threw,
4454
+ * in which case `fm` degrades to `{}` and `scope` becomes `SCOPE.UNREADABLE`
4455
+ * — callers that mutate `scope` further (e.g. `cmdStateValidate`'s later
4456
+ * UNSCOPED/disk-scan degrades) start from this returned value rather than a
4457
+ * fresh `SCOPE.COMPLETE`.
2580
4458
  */
2581
- function cmdStateValidate(cwd, raw) {
4459
+ function readStateFrontmatterScoped(content, statePath) {
4460
+ let fm;
4461
+ let scope = SCOPE.COMPLETE;
4462
+ try {
4463
+ fm = extractFrontmatter(content, statePath);
4464
+ }
4465
+ catch {
4466
+ // extractFrontmatter is documented never to throw, but this mirrors the
4467
+ // defensive try/catch already used around it elsewhere in this file
4468
+ // (e.g. spliceFrontmatter) — a parse hiccup here means the frontmatter
4469
+ // half of the chain could not be consulted; degrade visibly.
4470
+ fm = {};
4471
+ scope = SCOPE.UNREADABLE;
4472
+ }
4473
+ const body = stripFrontmatter(content);
4474
+ return { fm, body, scope };
4475
+ }
4476
+ /**
4477
+ * Builds an S0NN `Diagnostic` for `cmdStateValidate` (§8.4 rule 3 —
4478
+ * `cmdStateValidate` is a plain imperative function, not a `Rule.check`, so
4479
+ * it builds `Diagnostic[]` directly rather than going through
4480
+ * `evaluateRuleTable`/the `RULES` array machinery). Every S0NN subject is
4481
+ * advisory-only today (`cmdStateValidate` has never had a repair path), so
4482
+ * every remedy is `adviseRemedy` — `advice` is the short imperative command
4483
+ * text shown to the operator, matching the style Phase 11's rule-group files
4484
+ * already use for their own ADVISE-only findings (e.g.
4485
+ * `roadmap-disk-consistency.cts`'s `adviseRemedy('Create phase directory or
4486
+ * remove from roadmap')`).
4487
+ */
4488
+ function stateDiagnostic(code, severity, message, advice) {
4489
+ return { code, severity, message, remedy: adviseRemedy(advice) };
4490
+ }
4491
+ function cmdStateValidate(cwd, raw, opts = {}) {
2582
4492
  const statePath = planningPaths(cwd).state;
4493
+ // #3696: `valid: false` used to exit 0, so a CI step or git hook could not gate
4494
+ // on state correctness without parsing JSON — every consumer had to
4495
+ // re-implement the "is this actually valid" decision, which is the
4496
+ // duplication #3473 is about.
4497
+ //
4498
+ // The DEFAULT is deliberately unchanged. `state validate`'s exit status is
4499
+ // Tier-2 observable output reaching "downstream projects that cannot be
4500
+ // enumerated" (ADR-3180 Decision 3, Hyrum's Law), so flipping 0 -> 1 for
4501
+ // everyone would break every script that runs it unconditionally. `--strict`
4502
+ // is the opt-in the issue itself offers as the alternative.
4503
+ //
4504
+ // Routed through one emit helper rather than a trailing assignment because
4505
+ // three of the exit paths below (`STATE.md not found`, S001, and the four
4506
+ // `return` branches in the phase-drift scan) emit and return early — a fix
4507
+ // that only set the exit code at the end of the function would silently miss
4508
+ // them, which is exactly the shape of the bug being fixed.
4509
+ const emit = (payload) => {
4510
+ if (opts.strict && payload.valid !== true)
4511
+ process.exitCode = 1;
4512
+ output(payload, raw, undefined);
4513
+ };
2583
4514
  if (!node_fs_1.default.existsSync(statePath)) {
2584
- output({ error: 'STATE.md not found' }, raw, undefined);
4515
+ emit({ error: 'STATE.md not found' });
2585
4516
  return;
2586
4517
  }
2587
4518
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
@@ -2590,67 +4521,174 @@ function cmdStateValidate(cwd, raw) {
2590
4521
  // searchers downstream, reading as "absent" rather than "corrupt."
2591
4522
  const encErr = (0, validate_cjs_1.textEncodingError)(content, 'STATE.md');
2592
4523
  if (encErr) {
2593
- output({ valid: false, warnings: [encErr], drift: {} }, raw, undefined);
4524
+ // S001 — error-class severity (this branch has always set `valid: false`
4525
+ // unconditionally and returned immediately, matching every other
4526
+ // error-class code, not a mere warning). Message reused verbatim from
4527
+ // `textEncodingError`, not paraphrased.
4528
+ emit({
4529
+ valid: false,
4530
+ warnings: [stateDiagnostic('S001', SEVERITY.ERROR, encErr, 'Re-save STATE.md as UTF-8 text with the embedded NUL byte(s) removed')],
4531
+ });
2594
4532
  return;
2595
4533
  }
2596
4534
  const warnings = [];
2597
- const drift = {};
2598
- const status = (0, state_document_cjs_1.stateExtractField)(content, 'Status') || '';
2599
- const currentPhase = (0, state_document_cjs_1.stateExtractField)(content, 'Current Phase');
2600
- const totalPlansRaw = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
4535
+ // #1255/#3187: parse frontmatter and strip it from the body ONCE, so the
4536
+ // chain owner sees the same fm/body precedence every other migrated call
4537
+ // site sees. Pass statePath so a truncated STATE.md is named in the #1882
4538
+ // diagnostic rather than reported under a content digest.
4539
+ const { fm, body, scope: initialScope } = readStateFrontmatterScoped(content, statePath);
4540
+ const scope = initialScope;
4541
+ const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value || '';
4542
+ const resolvedPhase = resolveStatePhase(fm, body);
4543
+ const currentPhase = resolvedPhase.phase;
4544
+ const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
2601
4545
  const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
2602
4546
  const phasesDir = planningPaths(cwd).phases;
2603
- // Scan disk for current phase
2604
- if (currentPhase && node_fs_1.default.existsSync(phasesDir)) {
2605
- const normalized = currentPhase.replace(/\s+of\s+\d+.*/, '').trim();
2606
- try {
2607
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
2608
- const phaseDir = entries.find(e => e.isDirectory() && e.name.startsWith(normalized.replace(/^0+/, '').padStart(2, '0')));
2609
- if (phaseDir) {
2610
- const phaseDirPath = node_path_1.default.join(phasesDir, phaseDir.name);
2611
- const { planCount: diskPlans, summaryCount: diskSummaries } = scanPhasePlans(phaseDirPath);
2612
- // Check plan count mismatch
2613
- if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
2614
- warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`);
2615
- drift['plan_count'] = { state: totalPlansInPhase, disk: diskPlans };
2616
- }
2617
- // Check for VERIFICATION.md
2618
- const files = node_fs_1.default.readdirSync(phaseDirPath);
2619
- const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
2620
- for (const vf of verificationFiles) {
2621
- try {
2622
- const vContent = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDirPath, vf), 'utf-8');
2623
- if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
2624
- warnings.push(`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`);
2625
- drift['verification_status'] = { state_status: status, verification: 'passed' };
2626
- }
2627
- }
2628
- catch { /* best-effort (#2245 audit): cmdStateValidate is a diagnostic
2629
- * warnings scan across N VERIFICATION.md files — one unreadable file
2630
- * (permission/race) must not abort the scan of the rest; it's simply
2631
- * excluded from drift detection. */
2632
- }
2633
- }
2634
- // Check if all plans have summaries but status still says executing
2635
- if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
2636
- // Only warn if no verification exists (if verification passed, the above warning covers it)
2637
- if (verificationFiles.length === 0) {
2638
- warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`);
2639
- }
4547
+ if (currentPhase === null) {
4548
+ 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'));
4549
+ emit({ valid: false, warnings, scope });
4550
+ return;
4551
+ }
4552
+ const selectedPhaseKey = phaseKeyFromToken(currentPhase);
4553
+ if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source) !== selectedPhaseKey)) {
4554
+ warnings.push(stateDiagnostic('S003', SEVERITY.WARNING, `Phase reference conflict: validating authoritative phase ${currentPhase}; align STATE.md phase sources`, 'Align STATE.md phase sources (frontmatter, Current Phase, Current Position Phase) on one phase'));
4555
+ }
4556
+ if (!node_fs_1.default.existsSync(phasesDir)) {
4557
+ 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'));
4558
+ emit({ valid: false, warnings, scope });
4559
+ return;
4560
+ }
4561
+ let phaseDirPath;
4562
+ try {
4563
+ const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
4564
+ const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
4565
+ if (!phaseDir) {
4566
+ warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: no phase directory matches phase ${currentPhase}`, 'Create a phase directory matching the current phase or correct current_phase'));
4567
+ emit({ valid: false, warnings, scope });
4568
+ return;
4569
+ }
4570
+ phaseDirPath = node_path_1.default.join(phasesDir, phaseDir.name);
4571
+ }
4572
+ catch {
4573
+ 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'));
4574
+ emit({ valid: false, warnings, scope });
4575
+ return;
4576
+ }
4577
+ try {
4578
+ const scan = scanPhasePlans(phaseDirPath);
4579
+ if (scan.scope !== SCOPE.COMPLETE) {
4580
+ throw new Error('phase plan scan is incomplete');
4581
+ }
4582
+ const { planCount: diskPlans, summaryCount: diskSummaries } = scan;
4583
+ // Check plan count mismatch
4584
+ if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
4585
+ warnings.push(stateDiagnostic('S005', SEVERITY.WARNING, `Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`, 'Run state sync or correct Total Plans in Phase to match the plans on disk'));
4586
+ }
4587
+ // Check for VERIFICATION.md — scoped to THIS phase's own token (#3511)
4588
+ // so a stray, cross-phase, or ad-hoc VERIFICATION file cannot claim
4589
+ // this phase's status has drifted.
4590
+ //
4591
+ // WARNING-4 (#3511 review): the pre-filter grammar here is
4592
+ // deliberately BROADER than the `-VERIFICATION.md` suffix every
4593
+ // other site in the codebase uses — `.includes('VERIFICATION')`
4594
+ // admits names like `03_VERIFICATION.md` (underscore, no dash) that
4595
+ // the dashed grammar would reject outright. That breadth predates
4596
+ // #3511 and is intentional here (this is a best-effort drift
4597
+ // WARNING scan, not an authoritative single-pick resolver), so it is
4598
+ // left as-is rather than narrowed to match the dashed sites — doing
4599
+ // so would be a separate, un-asked-for behavior change (S006/S007).
4600
+ // What #3511 DOES change is that a name this broader grammar admits
4601
+ // is now ALSO subject to the same `scopeToPhase` membership check as
4602
+ // every dashed-grammar site, so a stray `04_VERIFICATION.md`-shaped
4603
+ // file in phase 03's directory is excluded exactly like a stray
4604
+ // `04-VERIFICATION.md` would be — while `03_VERIFICATION.md` (own
4605
+ // phase, underscore separator) is NOT excluded: `isPhaseArtifact`
4606
+ // (`phase-id.cts`) accepts `_` as a candidate-boundary separator
4607
+ // alongside `-` and `.` for exactly this reason, so an S006/S007
4608
+ // scan of `03-alpha/03_VERIFICATION.md` still resolves to S006
4609
+ // ("verification passed" drift), not a false S007.
4610
+ const files = node_fs_1.default.readdirSync(phaseDirPath);
4611
+ const phaseDirBaseName = node_path_1.default.basename(phaseDirPath);
4612
+ const verificationFiles = scopeToPhase(files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')), phaseDirBaseName);
4613
+ for (const vf of verificationFiles) {
4614
+ try {
4615
+ const vContent = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDirPath, vf), 'utf-8');
4616
+ if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
4617
+ warnings.push(stateDiagnostic('S006', SEVERITY.WARNING, `Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`, 'Run state complete-phase (or otherwise advance STATE.md status past "executing")'));
2640
4618
  }
2641
4619
  }
4620
+ catch { /* best-effort (#2245 audit): cmdStateValidate is a diagnostic
4621
+ * warnings scan across N VERIFICATION.md files — one unreadable file
4622
+ * (permission/race) must not abort the scan of the rest; it's simply
4623
+ * excluded from drift detection. Does not degrade `scope` — the other
4624
+ * N-1 files were consulted fine. */
4625
+ }
2642
4626
  }
2643
- catch { /* best-effort (#2245 audit): cmdStateValidate is a read-only
2644
- * diagnostic scan of the current phase's directory (readdirSync +
2645
- * scanPhasePlans). A disk-scan failure here means drift detection for
2646
- * this phase is skipped for this run, degrading to "no warnings from
2647
- * that scan" rather than crashing the validate command — the same
2648
- * degrade-on-scan-failure pattern buildStateFrontmatter's own disk scan
2649
- * already uses. */
4627
+ // Check if all plans have summaries but status still says executing
4628
+ if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
4629
+ // Only warn if no verification exists (if verification passed, the above warning covers it)
4630
+ if (verificationFiles.length === 0) {
4631
+ // S007 stays WARNING (not INFO): closely related to S006 (both
4632
+ // signal "phase may be ready to advance"), and S006 is WARNING —
4633
+ // giving the sibling condition a different severity for the same
4634
+ // underlying signal would be a false distinction.
4635
+ warnings.push(stateDiagnostic('S007', SEVERITY.WARNING, `All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`, 'Run phase verification, then advance STATE.md status past "executing"'));
4636
+ }
4637
+ }
4638
+ }
4639
+ catch {
4640
+ 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'));
4641
+ }
4642
+ // #3696 — the `last_activity` invariant. Three readers consumed this field
4643
+ // and none of them checked it, so a value no reader can parse validated as
4644
+ // `{valid:true, warnings:[], scope:'complete'}`: the scan ran to completion
4645
+ // and simply never looked. Read through the same owner every other field here
4646
+ // uses (ADR-3180 §7.7) — never a private `stateExtractField` call, which is
4647
+ // what `scripts/lint-state-field-drift.cjs` counts.
4648
+ const lastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity', 'Last activity').value;
4649
+ // NOT FILLED IN IS NOT DRIFT, and that covers three shapes, not one: absent,
4650
+ // blank, and the shipped template's `[YYYY-MM-DD] — [What happened]`
4651
+ // placeholder. Only a value a writer actually supplied can be wrong.
4652
+ if (!(0, state_document_cjs_1.isUnfilledFieldValue)(lastActivity)) {
4653
+ // Calendar validity, not merely `\d{4}-\d{2}-\d{2}` shape: smart-entry's
4654
+ // reader rejects 2026-02-30 via isRealCalendarDate (ADR-227 — validate shape
4655
+ // AND value). Accepting it here would leave the two surfaces disagreeing
4656
+ // about whether the file is usable, which is the complaint #3696 opens with.
4657
+ //
4658
+ // Review round 2: this asserts the LEADING date token, not
4659
+ // `parseProseLastActivityField`'s fully-anchored `date — description`
4660
+ // grammar. That grammar is stricter than any real reader, and routing the
4661
+ // check through it made S008 fire on values smart-entry parses fine (e.g.
4662
+ // `2026-08-24 Shipped feature X`, no dash separator) — the same
4663
+ // two-surfaces-disagree defect, pointing the other way. See
4664
+ // `leadingCalendarDate`.
4665
+ if ((0, state_document_cjs_1.leadingCalendarDate)(lastActivity) === null) {
4666
+ 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"'));
4667
+ }
4668
+ // The attached half of #3696: `templates/state.md` prescribes a single-line
4669
+ // field, but writers emit descriptions long enough to wrap, and
4670
+ // `stateExtractField`'s newline-excluding `(.+)` drops the remainder with no
4671
+ // diagnostic. The DOCUMENT is what violates the template here, so this
4672
+ // reports the violation rather than teaching the reader a multi-line grammar
4673
+ // the template does not sanction (ADR-3180 §7.7 Rejected #1 forbids widening
4674
+ // stateExtractField, which has 20 callers and a CRITICAL blast radius).
4675
+ //
4676
+ // Scan the body ONLY when the body is what was actually read. The ladder
4677
+ // prefers the frontmatter scalar, so a document carrying a clean
4678
+ // `last_activity:` in frontmatter AND a stale, wrapped `Last activity:` line
4679
+ // in the body would otherwise report S009 — and exit 1 under `--strict` —
4680
+ // over a remainder that no reader consumes and whose field is entirely
4681
+ // valid. Asking the owner with an EMPTY body isolates the frontmatter rung
4682
+ // without re-deriving the ladder here (which is what
4683
+ // `scripts/lint-state-field-drift.cjs` counts).
4684
+ const fromFrontmatter = (0, state_document_cjs_1.stateFieldValue)(fm, '', 'last_activity', 'Last activity').value;
4685
+ const dropped = fromFrontmatter !== null ? null : (0, state_document_cjs_1.stateFieldContinuation)(body, 'Last activity');
4686
+ if (dropped !== null) {
4687
+ 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'));
2650
4688
  }
2651
4689
  }
2652
4690
  const valid = warnings.length === 0;
2653
- output({ valid, warnings, drift }, raw, undefined);
4691
+ emit({ valid, warnings, scope });
2654
4692
  }
2655
4693
  /**
2656
4694
  * Gate 2: Sync STATE.md from filesystem ground truth.
@@ -2665,6 +4703,19 @@ function cmdStateSync(cwd, options, raw) {
2665
4703
  }
2666
4704
  const verify = options && options.verify;
2667
4705
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
4706
+ // ADR-3473 §8.5 (#3881): `state sync` is on ADR-3408 §8.3's closed
4707
+ // sanctioned-regenerate list — "the body wins" — and `syncStateFrontmatter`
4708
+ // (below, via `writeStateMd`'s `sanctionedPermanentEmptyFallback`) is
4709
+ // therefore CORRECT to overwrite even an unparseable existing frontmatter
4710
+ // block (git merge-conflict markers, malformed YAML). What was missing was
4711
+ // disclosure: a derived conclusion (`synced: true`) must not be reported as
4712
+ // authoritative when the derivation dropped input it could not resolve
4713
+ // (§8.5) — silently destroying the only copy of an unreadable block with no
4714
+ // signal is "failure is a value" (§8.4) violated. Computed once, up front,
4715
+ // from the pre-write snapshot so both the `--verify` (dry-run) and the real
4716
+ // write branch can surface it identically.
4717
+ const existingSyncFm = extractFrontmatter(content, statePath);
4718
+ const syncFrontmatterWasUnparseable = isUnparseableFrontmatter(existingSyncFm);
2668
4719
  const changes = [];
2669
4720
  let modified = content;
2670
4721
  const phasesDir = planningPaths(cwd).phases;
@@ -2710,10 +4761,17 @@ function cmdStateSync(cwd, options, raw) {
2710
4761
  let _highestIncompletePhaseSummaryCount = 0;
2711
4762
  for (const dir of entries) {
2712
4763
  const dirPath = node_path_1.default.join(phasesDir, dir);
2713
- const { planCount: plans, summaryCount: summaries, completed } = scanPhasePlans(dirPath);
4764
+ const { planCount: plans, summaryCount: summaries } = scanPhasePlans(dirPath);
2714
4765
  totalDiskPlans += plans;
2715
4766
  totalDiskSummaries += summaries;
2716
- if (completed)
4767
+ // ADR-3180 §7.4 (#3186, #2957 disk-strict): route through the single
4768
+ // canonical owner (isPhaseComplete), not scanPhasePlans's own `completed`
4769
+ // field ("are all plans summarized?" — a different question). This is the
4770
+ // same fix buildStateFrontmatter got above; cmdStateSync (`state sync`)
4771
+ // was a second, independent consumer of the same raw field the initial
4772
+ // migration missed — without it, `state sync` and `state json` disagreed
4773
+ // on completed_phases for the identical disk state.
4774
+ if (isPhaseComplete(dirPath).value.complete)
2717
4775
  diskCompletedPhases++;
2718
4776
  // Track the highest phase with incomplete plans (or any plans)
2719
4777
  const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
@@ -2774,27 +4832,64 @@ function cmdStateSync(cwd, options, raw) {
2774
4832
  const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
2775
4833
  let milestoneBounded = true;
2776
4834
  if (versionStr !== null && syncRoadmapRaw !== null) {
2777
- const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(versionStr)}`, 'mi');
2778
- milestoneBounded = versionedHeading.test(syncRoadmapRaw);
4835
+ // #3184: routed through the single owner (roadmap-parser.cjs) instead of
4836
+ // a hand-rolled, unbounded-substring re-derivation — see the identical
4837
+ // fix in buildStateFrontmatter above.
4838
+ milestoneBounded = isMilestoneBoundedInRoadmap(syncRoadmapRaw, versionStr);
2779
4839
  }
2780
4840
  let percent = null;
2781
4841
  if (!milestoneBounded) {
2782
4842
  changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`);
2783
4843
  }
2784
4844
  else {
2785
- const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
2786
- percent = p !== null ? p : 0;
4845
+ // #3217 (ADR-3180 §7.6 rule 4) BLOCKER fix: the prior comment here claimed
4846
+ // `entries` (the raw fs.readdirSync listing above) was "never routed
4847
+ // through listMilestonePhaseDirs, so there is no real Scope to pass" —
4848
+ // that was factually wrong. The same `syncRoadmapRaw`/`syncRoadmapScope`
4849
+ // already parsed above (~3104) is precisely what
4850
+ // `listMilestonePhaseDirs` (via `getMilestonePhaseFilter`) re-derives
4851
+ // from `cwd` to produce a real `Scope` — the identical shape already
4852
+ // threaded through `buildStateFrontmatter`'s `diskScope` above. Calling
4853
+ // it here (discarding `.value`, which duplicates `entries`'s own
4854
+ // retired-phase-filtered listing) gets the real scope without changing
4855
+ // the disk-scan totals computed above.
4856
+ const syncScope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
4857
+ if (syncScope !== SCOPE.COMPLETE) {
4858
+ changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);
4859
+ }
4860
+ else {
4861
+ const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases, syncScope);
4862
+ percent = p !== null ? p : 0;
4863
+ }
2787
4864
  }
2788
4865
  const syncResult = transitionCore(modified, { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, { clock: clock_cjs_1.realClock });
2789
4866
  modified = syncResult.content;
2790
4867
  const coreChanges = syncResult.data?.changes ?? [];
2791
4868
  changes.push(...coreChanges);
4869
+ // #3881 (ADR-3473 §8.5): only warn when a write will actually regenerate the
4870
+ // frontmatter — if nothing changed this run, the unparseable block (if any)
4871
+ // was never touched, so there is nothing to disclose. Mirrors the exact
4872
+ // condition the write branch below uses to decide whether to write at all.
4873
+ const syncWillWrite = changes.length > 0 || modified !== content;
4874
+ if (syncWillWrite && syncFrontmatterWasUnparseable) {
4875
+ const unparseableWarning = `gsd: warning — STATE.md's existing frontmatter could not be parsed (malformed YAML, or ` +
4876
+ `unresolved content such as git merge-conflict markers) and was regenerated from the body; ` +
4877
+ `any content in the old frontmatter block — including merge-conflict markers — has been ` +
4878
+ `replaced. (#3881)`;
4879
+ process.stderr.write(`${unparseableWarning}\n`);
4880
+ changes.push(unparseableWarning);
4881
+ }
2792
4882
  if (verify) {
2793
4883
  output({ synced: false, changes, dry_run: true }, raw, undefined);
2794
4884
  return;
2795
4885
  }
2796
- if (changes.length > 0 || modified !== content) {
2797
- writeStateMd(statePath, modified, cwd);
4886
+ if (syncWillWrite) {
4887
+ // ADR-3473 §8.6: `rebuild()` is the typed expression of #905's contract —
4888
+ // `state sync` exists to let the body win, so preservation must NOT run,
4889
+ // and the snapshot is carried anyway because §8.7's reporting needs it.
4890
+ writeStateMd(statePath, modified, stateTransitionMod.rebuildStateTransaction({
4891
+ snapshot: extractFrontmatter(content, statePath),
4892
+ }), cwd);
2798
4893
  }
2799
4894
  output({ synced: true, changes, dry_run: false }, raw, undefined);
2800
4895
  }
@@ -2817,30 +4912,18 @@ function cmdStatePrune(cwd, options, raw) {
2817
4912
  }
2818
4913
  const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
2819
4914
  const dryRun = !!options.dryRun;
2820
- // Resolve the current phase via the same canonical chain buildStateFrontmatter
2821
- // uses (frontmatter `current_phase` → `Current Phase` field → prose `Phase: X
2822
- // of Y`), so prune engages on template-conformant STATE.md instead of bailing
2823
- // "Only 0 phases" (#1760).
2824
- // #1776: scope ONLY the prose `Phase:` term to the canonical `## Current
2825
- // Position` section. Over the whole body, `stateExtractField`'s pipe-table
2826
- // fallback matches any `| Phase | N |` row (e.g. a historical verification
2827
- // table), resolving a stale phase and computing a wrong cutoff. Frontmatter and
2828
- // the explicit `Current Phase` field are unambiguous, so they stay document-wide;
2829
- // the shared extractor is not narrowed for any other caller.
4915
+ // Resolve the current phase via `resolveCurrentPhaseId` — the shared owner of
4916
+ // the canonical frontmatter → `Current Phase` field → scoped prose ladder
4917
+ // (#1760 origin, #1776 scoping, #3187 ownership; see its doc comment). Prune
4918
+ // engages on a template-conformant STATE.md instead of bailing "Only 0
4919
+ // phases" (#1760). #3231/#3481 routed the phase-labeled write commands
4920
+ // through the same helper rather than leaving a second copy of the ladder here.
2830
4921
  const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
2831
4922
  const fm = extractFrontmatter(rawState, statePath);
2832
4923
  const body = stripFrontmatter(rawState);
2833
- // Mirror buildStateFrontmatter's fmScalar: only string/number/boolean
2834
- // frontmatter scalars are usable (an object/array `current_phase` is ignored,
2835
- // which also avoids a base-to-string on a non-primitive).
2836
- const fmRawPhase = fm.current_phase;
2837
- const fmCurrentPhase = typeof fmRawPhase === 'string' ? (fmRawPhase.trim() || null)
2838
- : typeof fmRawPhase === 'number' || typeof fmRawPhase === 'boolean' ? String(fmRawPhase)
2839
- : null;
2840
- const positionSection = sliceCurrentPositionSection(body);
2841
- const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionSection, 'Phase')).phase : null;
2842
- const currentPhaseRaw = fmCurrentPhase ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') ?? prosePhase;
2843
- const currentPhase = parseInt(String(currentPhaseRaw), 10) || 0;
4924
+ // Prune needs an integer cutoff, so it parses the resolved id itself; a
4925
+ // non-numeric or absent id lands on 0 and prune bails, as before.
4926
+ const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body)), 10) || 0;
2844
4927
  const cutoff = currentPhase - keepRecent;
2845
4928
  if (cutoff <= 0) {
2846
4929
  emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
@@ -2946,6 +5029,11 @@ function cmdStateRebuild(cwd, options, raw) {
2946
5029
  const phasesDir = node_path_1.default.join(planningPaths(cwd).planning, 'phases');
2947
5030
  if (!node_fs_1.default.existsSync(phasesDir) || !node_fs_1.default.statSync(phasesDir).isDirectory())
2948
5031
  return { ok: true, phases: [] };
5032
+ // #3185: deliberately NOT listMilestonePhaseDirs. `state rebuild` is a
5033
+ // RECONCILIATION pass against ground truth -- it must see every phase
5034
+ // directory on disk so an orphan STATE.md row for a phase that no longer
5035
+ // exists (or sits outside the current window) is dropped. Scoping this
5036
+ // would make the rebuild silently preserve stale rows.
2949
5037
  const entries = node_fs_1.default.readdirSync(phasesDir);
2950
5038
  const records = [];
2951
5039
  for (const entry of entries) {
@@ -2963,9 +5051,21 @@ function cmdStateRebuild(cwd, options, raw) {
2963
5051
  const m = entry.match(/^(\d+)-(.+)$/);
2964
5052
  if (!m)
2965
5053
  continue;
2966
- const files = node_fs_1.default.readdirSync(full);
2967
- const planCount = files.filter(f => /-PLAN\.md$/i.test(f)).length;
2968
- const summaryCount = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
5054
+ // #3183 (lint-plan-count-drift / ADR-3180 Decision 2): source
5055
+ // planCount/summaryCount from the single owner (scanPhasePlans)
5056
+ // instead of a local root-only `-PLAN.md`/`-SUMMARY.md` readdirSync
5057
+ // filter — picks up bare PLAN.md/SUMMARY.md and nested plans/. A
5058
+ // non-COMPLETE scope (TRUNCATED: nested plans/ unreadable;
5059
+ // UNREADABLE: `full` itself unreadable) is not a trustworthy count —
5060
+ // throw so it surfaces via the outer catch as a real scan failure
5061
+ // (`ok:false`), mirroring the #3057 B1 contract documented above for
5062
+ // the sibling `fs.readdirSync(phasesDir)` failure mode, rather than
5063
+ // silently reporting an undercount.
5064
+ const scan = scanPhasePlans(full);
5065
+ if (scan.scope !== SCOPE.COMPLETE) {
5066
+ throw new Error(`could not fully scan plan directory (scope ${scan.scope}): ${full}`);
5067
+ }
5068
+ const { planCount, summaryCount } = scan;
2969
5069
  records.push({ number: m[1], name: m[2], planCount, summaryCount });
2970
5070
  }
2971
5071
  return { ok: true, phases: records };
@@ -3048,10 +5148,17 @@ function cmdStateRebuild(cwd, options, raw) {
3048
5148
  * that the phase execution is finished and the project is ready for the next phase.
3049
5149
  * Implements the `gsd state complete-phase` subcommand (issue #2735).
3050
5150
  */
3051
- function resolvePhaseIdForCompletePhase(content, overridePhase) {
5151
+ function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
5152
+ // #3187: route through the single #1760 fallback-chain owner (fm scalar
5153
+ // then body field) instead of two raw stateExtractField calls on
5154
+ // frontmatter-blind content — a STATE.md whose phase lives only in
5155
+ // frontmatter no longer resolves to null here. `Phase` (the historical
5156
+ // second-choice field name) has no frontmatter counterpart, so its fmKey
5157
+ // is null — same shape as cmdStateSnapshot's `stateFieldValue(fm,
5158
+ // currentPositionScope, null, 'Phase')` fallback.
3052
5159
  const candidate = overridePhase ||
3053
- (0, state_document_cjs_1.stateExtractField)(content, 'Current Phase') ||
3054
- (0, state_document_cjs_1.stateExtractField)(content, 'Phase') ||
5160
+ (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ||
5161
+ (0, state_document_cjs_1.stateFieldValue)(fm, body, null, 'Phase').value ||
3055
5162
  '';
3056
5163
  // #2125: parse via the canonical anchored parser so a narrative `Phase:`
3057
5164
  // body line (e.g. "Milestone v0.5 complete") does not mine a bogus token —
@@ -3068,7 +5175,30 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
3068
5175
  return;
3069
5176
  }
3070
5177
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
3071
- const resolvedPhase = resolvePhaseIdForCompletePhase(content, overridePhase);
5178
+ // #1255/#3187: parse frontmatter and strip it from the body ONCE, mirroring
5179
+ // cmdStateValidate/cmdStateSnapshot, so resolvePhaseIdForCompletePhase and
5180
+ // the idempotency guard below consult the identical fm/body precedence —
5181
+ // the two sites cannot drift onto different chains, extending the #2125
5182
+ // "same canonical parser" guarantee one layer earlier.
5183
+ const { fm, body, scope } = readStateFrontmatterScoped(content, statePath);
5184
+ // #3187 Postel/visibility (design doc's sharpest case): this whole handler
5185
+ // is the DESTRUCTIVE path the #3489 idempotency guard below protects — it
5186
+ // decides whether a re-run of `state complete-phase --phase N` is allowed
5187
+ // to roll STATE.md back to N's moment-of-completion. If the frontmatter
5188
+ // half of the chain could not be consulted (`scope` UNREADABLE),
5189
+ // `existingCurrentPhase` below could read as null even though the
5190
+ // project's true current phase lives only in that unreadable frontmatter —
5191
+ // silently treating a non-COMPLETE scope as "not complete" would let the
5192
+ // guard's `existingCurrentPhase &&` check fail OPEN and re-run an
5193
+ // already-completed phase. Refuse outright instead of guessing; this
5194
+ // applies even when `--phase` is explicit, because the guard's job is to
5195
+ // protect against exactly that already-completed-phase case regardless of
5196
+ // how the target phase was named.
5197
+ if (scope !== SCOPE.COMPLETE) {
5198
+ output({ error: 'Unable to read STATE.md frontmatter; refusing to run complete-phase to avoid a destructive rollback (#3489). Fix or remove the malformed frontmatter and retry.' }, raw, undefined);
5199
+ return;
5200
+ }
5201
+ const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase);
3072
5202
  if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
3073
5203
  output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw, undefined);
3074
5204
  return;
@@ -3082,7 +5212,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
3082
5212
  // Last Activity, Last Activity Description, and the Current Position body.
3083
5213
  // The handler is now a no-op in that case so re-invocation from downstream
3084
5214
  // workflows cannot regress the project state.
3085
- const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateExtractField)(content, 'Current Phase') || '';
5215
+ const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value || '';
3086
5216
  // #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two
3087
5217
  // sites cannot diverge on the token they extract.
3088
5218
  const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase;
@@ -3091,34 +5221,67 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
3091
5221
  return;
3092
5222
  }
3093
5223
  const today = clock_cjs_1.realClock.localToday();
5224
+ // #3408 review (close-known-limits): `updated` mixes two different kinds of
5225
+ // thing — FIELD names (Status, Last Activity, ...), each reconcilable
5226
+ // against the persisted bytes via `reconcileReportedFields`, and the
5227
+ // SECTION name `Current Position` (the whole Current-Position block, not a
5228
+ // single field `stateExtractField` can look up). Rather than re-deriving
5229
+ // the distinction downstream by string-matching against a Set, each entry
5230
+ // now carries its kind at the point it is PRODUCED; the flattening to a
5231
+ // flat `string[]` (the command's OUTPUT CONTRACT — unchanged) happens once
5232
+ // below, right before `output()`.
3094
5233
  const updated = [];
3095
- readModifyWriteStateMd(statePath, (content) => {
5234
+ const divergedFields = [];
5235
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
5236
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
5237
+ const preWriteState = {};
5238
+ // #3835: complete-phase unconditionally rewrites the body `Phase:` line to
5239
+ // `N — COMPLETE` below. That defeats current_phase_name's
5240
+ // preserve-when-unchanged delta rule the same way #3834's no-`--name`
5241
+ // planned-phase write does — pre/post body-source disagree BY CONSTRUCTION
5242
+ // (this write is what changed the source line), so the post-sync
5243
+ // re-derivation harvests nothing from "COMPLETE" and the curated key is
5244
+ // dropped entirely rather than preserved. The write site already documents
5245
+ // "an absent name does NOT clear an existing curated value" for the body
5246
+ // (`Current Phase Name` section below) — this reasserts the same rule for
5247
+ // the frontmatter key, mirroring cmdStatePlannedPhase's fix.
5248
+ const rmwOptions = { divergedFields, preWriteState };
5249
+ const wrote = readModifyWriteStateMd(statePath, (content) => {
3096
5250
  const currentPhase = resolvedPhase;
3097
5251
  // Bug #1255: operate on body only so the YAML frontmatter `status:` key
3098
5252
  // cannot shadow the body Status field (pipe-table or inline).
3099
- const existingFm = extractFrontmatter(content, statePath);
3100
- const hasFrontmatter = Object.keys(existingFm).length > 0;
3101
- let body = stripFrontmatter(content);
3102
- const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
5253
+ //
5254
+ // ADR-3473 §8.1 (#3881 review, finding 5): previously this block hand-reimplemented
5255
+ // the isUnparseableFrontmatter/rawFrontmatterPrefix shape inline instead of using the
5256
+ // canonical helper — the sixth copy of a block already duplicated 5x in
5257
+ // state-transition.cts. Routed through the shared `beginFrontmatterReassembly` so this
5258
+ // module can never drift from the frontmatter-preservation contract state-transition.cts
5259
+ // enforces everywhere else.
5260
+ const { existingFm, body: initialBody, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
5261
+ let body = initialBody;
5262
+ const curatedPhaseName = existingFm['current_phase_name'];
5263
+ if (typeof curatedPhaseName === 'string' && curatedPhaseName.trim().length > 0) {
5264
+ rmwOptions.authoritativeFm = { current_phase_name: curatedPhaseName };
5265
+ }
3103
5266
  // Update Status field (body only — #1255)
3104
5267
  const statusValue = `Phase ${currentPhase} complete`;
3105
5268
  let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
3106
5269
  if (result) {
3107
5270
  body = result;
3108
- updated.push('Status');
5271
+ updated.push({ kind: 'field', name: 'Status' });
3109
5272
  }
3110
5273
  // Update Last Activity date
3111
5274
  result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
3112
5275
  if (result) {
3113
5276
  body = result;
3114
- updated.push('Last Activity');
5277
+ updated.push({ kind: 'field', name: 'Last Activity' });
3115
5278
  }
3116
5279
  // Update Last Activity Description
3117
5280
  const activityDesc = `Phase ${currentPhase} marked complete`;
3118
5281
  result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
3119
5282
  if (result) {
3120
5283
  body = result;
3121
- updated.push('Last Activity Description');
5284
+ updated.push({ kind: 'field', name: 'Last Activity Description' });
3122
5285
  }
3123
5286
  // Update ## Current Position section
3124
5287
  // ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2.
@@ -3177,12 +5340,37 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
3177
5340
  posBody = replaced;
3178
5341
  }
3179
5342
  body = body.slice(0, cpBodyStart) + posBody + body.slice(cpBodyEnd);
3180
- updated.push('Current Position');
5343
+ updated.push({ kind: 'section', name: 'Current Position' });
3181
5344
  }
3182
5345
  }
3183
5346
  return reassemble(body);
3184
- }, cwd);
3185
- output({ updated, phase: resolvedPhase }, raw, updated.length > 0 ? 'true' : 'false');
5347
+ }, cwd, rmwOptions);
5348
+ // ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
5349
+ // the analysis pass"). Unlike the transitionCore-based commands, this
5350
+ // adapter's `updated` mixes FIELD entries (Status, Last Activity, Last
5351
+ // Activity Description — each reconcilable against the persisted bytes,
5352
+ // same as every other command in this phase) with the SECTION entry
5353
+ // `Current Position` (the whole Current-Position block, not a single
5354
+ // field `stateExtractField` can look up — reconciling it the same way as
5355
+ // a field would always drop it as a false negative). Reconcile only the
5356
+ // field-shaped entries (#3351's direction), pass the section entry
5357
+ // through unconditionally, and fold in any field preservation restored
5358
+ // that this transform never touched (#3345's direction). The kind was
5359
+ // decided at PUSH time above (typed producer), not re-derived here by
5360
+ // string-matching a name against a Set.
5361
+ const sectionEntries = updated.filter((e) => e.kind === 'section').map((e) => e.name);
5362
+ const fieldEntries = updated.filter((e) => e.kind === 'field').map((e) => e.name);
5363
+ const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preWriteState, fieldEntries, divergedFields)];
5364
+ output({ updated: reconciled, phase: resolvedPhase }, raw, reconciled.length > 0 ? 'true' : 'false');
5365
+ // #3227: gate on `wrote` (readModifyWriteStateMd's own return value), not
5366
+ // `reconciled.length > 0` — a re-run of complete-phase against a phase
5367
+ // that is ALREADY marked complete (same status/date/Current Position
5368
+ // values already on disk) still has `stateReplaceField` report a match for
5369
+ // every field it looks up, so `reconciled` is non-empty even though the
5370
+ // #948 no-op guard skipped the write. Same reasoning as
5371
+ // cmdStateBeginPhase/cmdStatePlannedPhase/cmdStateAdvancePlan above.
5372
+ if (wrote)
5373
+ publishStateContract(cwd);
3186
5374
  }
3187
5375
  module.exports = {
3188
5376
  stateExtractField: state_document_cjs_1.stateExtractField,
@@ -3193,6 +5381,17 @@ module.exports = {
3193
5381
  writeStateMd,
3194
5382
  readModifyWriteStateMd,
3195
5383
  syncStateFrontmatter,
5384
+ // #3374: the shared post-sync preservation pass (snapshots + table-driven
5385
+ // applyStatePreservation + #2736 re-assert).
5386
+ applyPostSyncPreservation,
5387
+ // #3469 (ADR-3408 §8.3): the ONE write-seam composition (sync +
5388
+ // preservation) as content -> content. Exported for cmdPhaseComplete's
5389
+ // atomic-commit adapter (phase.cts, syncs STATE.md directly because it is
5390
+ // committed atomically with ROADMAP/REQUIREMENTS) and for
5391
+ // cmdMilestoneComplete (milestone.cts) — both need the composition's
5392
+ // output but supply their own I/O envelope around it.
5393
+ syncAndPreserveStateMd,
5394
+ readStateHeadFreshness,
3196
5395
  withStateLock,
3197
5396
  updatePerformanceMetricsSection,
3198
5397
  cmdStateLoad,
@@ -3222,6 +5421,27 @@ module.exports = {
3222
5421
  // Test seam (#1514): the pure retired/folded-phase parser, exposed so its
3223
5422
  // strikethrough-detection logic can be property-tested directly.
3224
5423
  _extractRetiredPhaseNumbers: extractRetiredPhaseNumbers,
5424
+ // Test seam (#3471 review): the second hand-maintained table beside
5425
+ // FIELD_CLASSIFICATION, exposed so a parity test can pin that every
5426
+ // `preserve-when-unchanged` row has a label here.
5427
+ _FRONTMATTER_KEY_TO_BODY_LABEL: FRONTMATTER_KEY_TO_BODY_LABEL,
5428
+ // Test seam (ADR-3473 §8.7, #3872): the transaction diff and its pure
5429
+ // building blocks, exposed so the ~15 boundary/hostile/property rows in
5430
+ // the test matrix (dotted-path resolution, prototype-pollution safety,
5431
+ // string/number representation insensitivity, the provenance exclusion)
5432
+ // can be driven directly with fabricated snapshot/persisted objects
5433
+ // instead of round-tripping every case through a full RMW write.
5434
+ _reconcileReportedFields: reconcileReportedFields,
5435
+ _computeChangedFrontmatterFields: computeChangedFrontmatterFields,
5436
+ _resolveFrontmatterPath: resolveFrontmatterPath,
5437
+ _stateFieldValuesDiffer: stateFieldValuesDiffer,
5438
+ _STATE_UPDATED_PROVENANCE_EXCLUSION: STATE_UPDATED_PROVENANCE_EXCLUSION,
5439
+ // Test seam (#3873 phase-3 test matrix row 9): `bodyLabelFor` itself is not
5440
+ // otherwise reachable from outside this module. Exposed so a test can drive
5441
+ // the real STATE_BODY_LABEL_UNWIRED_ROW throw directly, rather than only
5442
+ // pinning the table it reads (`_FRONTMATTER_KEY_TO_BODY_LABEL`) against
5443
+ // itself.
5444
+ _bodyLabelFor: bodyLabelFor,
3225
5445
  // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
3226
5446
  // steal decision is exercised without real pids. Mirrors capability-lock.cts.
3227
5447
  _setLockProbes(probes) {