@opengsd/gsd-core 1.10.0 → 1.11.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 (328) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-debug-session-manager.md +11 -0
  4. package/agents/gsd-doc-synthesizer.md +2 -4
  5. package/agents/gsd-executor.md +5 -5
  6. package/agents/gsd-mempalace-curator.md +5 -2
  7. package/agents/gsd-phase-researcher.md +20 -1
  8. package/agents/gsd-plan-checker.md +37 -0
  9. package/agents/gsd-planner.md +44 -46
  10. package/agents/gsd-user-profiler.md +3 -0
  11. package/agents/gsd-verifier.md +12 -3
  12. package/bin/install.js +841 -971
  13. package/bin/lib/ui-safety-gate.cjs +2 -0
  14. package/commands/gsd/code-review.md +1 -1
  15. package/commands/gsd/execute-phase.md +1 -1
  16. package/commands/gsd/map-codebase.md +1 -1
  17. package/commands/gsd/mempalace-capture.md +1 -1
  18. package/commands/gsd/mempalace-recall.md +1 -1
  19. package/commands/gsd/new-milestone.md +1 -1
  20. package/commands/gsd/quick.md +1 -1
  21. package/commands/gsd/review-backlog.md +2 -1
  22. package/commands/gsd/verify-work.md +1 -1
  23. package/gsd-core/bin/gsd-tools.cjs +469 -88
  24. package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
  25. package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
  26. package/gsd-core/bin/lib/api-coverage.cjs +3 -5
  27. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  28. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  29. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  30. package/gsd-core/bin/lib/audit.cjs +876 -240
  31. package/gsd-core/bin/lib/broken-windows.cjs +1 -1
  32. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  33. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +575 -101
  35. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  36. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  37. package/gsd-core/bin/lib/capability-validator.cjs +495 -22
  38. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  39. package/gsd-core/bin/lib/check-command-router.cjs +71 -37
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  41. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  43. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  44. package/gsd-core/bin/lib/commands.cjs +651 -86
  45. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  47. package/gsd-core/bin/lib/config-loader.cjs +75 -0
  48. package/gsd-core/bin/lib/config.cjs +10 -1
  49. package/gsd-core/bin/lib/core-utils.cjs +127 -29
  50. package/gsd-core/bin/lib/decisions.cjs +23 -0
  51. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  52. package/gsd-core/bin/lib/frontmatter.cjs +155 -20
  53. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  54. package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
  55. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  56. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  57. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  58. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  59. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  60. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  61. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  62. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  63. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  64. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  65. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  66. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  67. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  68. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  69. package/gsd-core/bin/lib/init.cjs +321 -129
  70. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  71. package/gsd-core/bin/lib/install-engine.cjs +745 -258
  72. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  73. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  74. package/gsd-core/bin/lib/install-profiles.cjs +134 -57
  75. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  76. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  77. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  78. package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
  79. package/gsd-core/bin/lib/io.cjs +10 -0
  80. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  81. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  82. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  83. package/gsd-core/bin/lib/milestone.cjs +754 -70
  84. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  85. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  86. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  87. package/gsd-core/bin/lib/pattern.cjs +122 -0
  88. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +444 -36
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  91. package/gsd-core/bin/lib/phase-locator.cjs +125 -18
  92. package/gsd-core/bin/lib/phase.cjs +646 -143
  93. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  94. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  95. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  96. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  97. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  98. package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
  99. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  100. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  101. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  102. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  103. package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
  104. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  105. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  106. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  107. package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
  108. package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
  109. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  110. package/gsd-core/bin/lib/roadmap.cjs +385 -94
  111. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
  112. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  113. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
  114. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  115. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  116. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
  117. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  118. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  119. package/gsd-core/bin/lib/security.cjs +104 -5
  120. package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
  121. package/gsd-core/bin/lib/smart-entry.cjs +142 -22
  122. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  123. package/gsd-core/bin/lib/state-document.cjs +152 -8
  124. package/gsd-core/bin/lib/state-transition.cjs +371 -117
  125. package/gsd-core/bin/lib/state.cjs +1794 -357
  126. package/gsd-core/bin/lib/surface.cjs +23 -9
  127. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  128. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  129. package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
  130. package/gsd-core/bin/lib/uat.cjs +399 -56
  131. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  132. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  133. package/gsd-core/bin/lib/unusable-input.cjs +24 -0
  134. package/gsd-core/bin/lib/update-context.cjs +8 -2
  135. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  136. package/gsd-core/bin/lib/validate.cjs +20 -6
  137. package/gsd-core/bin/lib/vendor/README.md +37 -0
  138. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  139. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  140. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  141. package/gsd-core/bin/lib/verification.cjs +258 -8
  142. package/gsd-core/bin/lib/verify.cjs +368 -888
  143. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  144. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  145. package/gsd-core/bin/lib/workstream.cjs +2 -2
  146. package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
  147. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  148. package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
  149. package/gsd-core/references/agent-contracts.md +43 -26
  150. package/gsd-core/references/checkpoints.md +2 -2
  151. package/gsd-core/references/context-budget.md +1 -1
  152. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  153. package/gsd-core/references/doc-conflict-engine.md +1 -1
  154. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  155. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  156. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  157. package/gsd-core/references/execute-phase-response-language.md +1 -1
  158. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  159. package/gsd-core/references/gate-prompts.md +1 -1
  160. package/gsd-core/references/git-planning-commit.md +2 -1
  161. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  162. package/gsd-core/references/model-profiles.md +12 -4
  163. package/gsd-core/references/mvp-concepts.md +9 -9
  164. package/gsd-core/references/planner-guidance.md +3 -9
  165. package/gsd-core/references/planner-preconditions.md +1 -1
  166. package/gsd-core/references/planner-reviews.md +1 -1
  167. package/gsd-core/references/planning-config.md +8 -6
  168. package/gsd-core/references/revision-loop.md +1 -1
  169. package/gsd-core/references/specless-probe-fallback.md +1 -1
  170. package/gsd-core/references/universal-anti-patterns.md +3 -3
  171. package/gsd-core/references/verifier-phase-gates.md +192 -0
  172. package/gsd-core/references/verify-mvp-mode.md +1 -1
  173. package/gsd-core/references/workstream-flag.md +22 -6
  174. package/gsd-core/templates/discussion-log.md +1 -1
  175. package/gsd-core/templates/phase-prompt.md +2 -4
  176. package/gsd-core/templates/state.md +4 -4
  177. package/gsd-core/templates/verification-report.md +9 -1
  178. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  179. package/gsd-core/workflows/autonomous.md +1 -1
  180. package/gsd-core/workflows/cleanup.md +62 -3
  181. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
  182. package/gsd-core/workflows/code-review-fix.md +37 -10
  183. package/gsd-core/workflows/code-review.md +38 -12
  184. package/gsd-core/workflows/complete-milestone.md +141 -18
  185. package/gsd-core/workflows/debug.md +7 -5
  186. package/gsd-core/workflows/diagnose-issues.md +35 -9
  187. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  188. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  189. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
  190. package/gsd-core/workflows/edit-phase.md +26 -1
  191. package/gsd-core/workflows/eval-review.md +3 -5
  192. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
  193. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  194. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
  195. package/gsd-core/workflows/execute-phase.md +38 -50
  196. package/gsd-core/workflows/execute-plan.md +36 -4
  197. package/gsd-core/workflows/explore.md +131 -4
  198. package/gsd-core/workflows/fast.md +10 -2
  199. package/gsd-core/workflows/health.md +73 -4
  200. package/gsd-core/workflows/import.md +4 -4
  201. package/gsd-core/workflows/ingest-docs.md +5 -5
  202. package/gsd-core/workflows/mvp-phase.md +6 -3
  203. package/gsd-core/workflows/new-milestone.md +14 -9
  204. package/gsd-core/workflows/new-project.md +14 -14
  205. package/gsd-core/workflows/next.md +12 -0
  206. package/gsd-core/workflows/plan-phase.md +41 -17
  207. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  208. package/gsd-core/workflows/progress.md +34 -6
  209. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
  210. package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
  211. package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
  212. package/gsd-core/workflows/quick.md +35 -15
  213. package/gsd-core/workflows/review.md +26 -5
  214. package/gsd-core/workflows/secure-phase.md +1 -1
  215. package/gsd-core/workflows/session-report.md +2 -1
  216. package/gsd-core/workflows/settings.md +66 -2
  217. package/gsd-core/workflows/ship.md +104 -44
  218. package/gsd-core/workflows/spec-phase.md +30 -12
  219. package/gsd-core/workflows/sync-skills.md +63 -8
  220. package/gsd-core/workflows/transition.md +46 -11
  221. package/gsd-core/workflows/ui-phase.md +5 -5
  222. package/gsd-core/workflows/ui-review.md +2 -2
  223. package/gsd-core/workflows/update.md +1 -1
  224. package/gsd-core/workflows/validate-phase.md +1 -1
  225. package/gsd-core/workflows/verify-work.md +9 -7
  226. package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
  227. package/hooks/dist/gsd-check-update-worker.js +56 -13
  228. package/hooks/dist/gsd-check-update.js +19 -1
  229. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  230. package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
  231. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  232. package/hooks/dist/gsd-prompt-guard.js +21 -20
  233. package/hooks/dist/gsd-read-injection-scanner.js +38 -24
  234. package/hooks/dist/gsd-statusline.js +18 -0
  235. package/hooks/dist/gsd-update-banner.js +22 -1
  236. package/hooks/dist/gsd-workflow-guard.js +134 -36
  237. package/hooks/dist/lib/git-cmd.js +92 -59
  238. package/hooks/dist/lib/injection-patterns.js +45 -0
  239. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  240. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  241. package/hooks/gsd-agent-isolation-guard.js +103 -14
  242. package/hooks/gsd-check-update-worker.js +56 -13
  243. package/hooks/gsd-check-update.js +19 -1
  244. package/hooks/gsd-cursor-pre-tool.js +0 -3
  245. package/hooks/gsd-cursor-subagent-start.js +77 -2
  246. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  247. package/hooks/gsd-prompt-guard.js +21 -20
  248. package/hooks/gsd-read-injection-scanner.js +38 -24
  249. package/hooks/gsd-statusline.js +18 -0
  250. package/hooks/gsd-update-banner.js +22 -1
  251. package/hooks/gsd-workflow-guard.js +134 -36
  252. package/hooks/lib/git-cmd.js +92 -59
  253. package/hooks/lib/injection-patterns.js +45 -0
  254. package/hooks/lib/isolation-deny-reason.js +39 -0
  255. package/hooks/lib/isolation-sentinel.js +9 -0
  256. package/package.json +21 -9
  257. package/pi/gsd.cjs +19 -5
  258. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  259. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  260. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  261. package/scripts/changeset/lint.cjs +60 -5
  262. package/scripts/check-alias-drift.cjs +7 -43
  263. package/scripts/check-contract-drift.cjs +297 -0
  264. package/scripts/ci-test-scope.cjs +19 -2
  265. package/scripts/command-contract-helpers.cjs +903 -1
  266. package/scripts/gen-adr-index.cjs +728 -38
  267. package/scripts/gen-capability-registry.cjs +3 -15
  268. package/scripts/gen-context-index.cjs +2 -11
  269. package/scripts/gen-health-docs.cjs +390 -0
  270. package/scripts/gen-inventory-manifest.cjs +50 -4
  271. package/scripts/gen-loop-host-contract.cjs +4 -24
  272. package/scripts/gen-registry.cjs +3 -14
  273. package/scripts/lib/alias-drift-families.cjs +46 -0
  274. package/scripts/lib/drift-scan.cjs +278 -0
  275. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  276. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  277. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  278. package/scripts/lint-canary-version-leak.cjs +73 -0
  279. package/scripts/lint-command-contract.cjs +96 -13
  280. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  281. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  282. package/scripts/lint-default-flip-documentation.cjs +193 -0
  283. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  284. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  285. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  286. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  287. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  288. package/scripts/lint-milestone-window-drift.cjs +468 -0
  289. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  290. package/scripts/lint-plan-count-drift.cjs +318 -0
  291. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  292. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  293. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  294. package/scripts/lint-regression-test-names.cjs +15 -13
  295. package/scripts/lint-removed-but-needed.cjs +320 -0
  296. package/scripts/lint-state-field-drift.cjs +805 -0
  297. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  298. package/scripts/lint-test-file-count.allowlist.json +21 -10
  299. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  300. package/scripts/lint-vendored-deps.cjs +124 -0
  301. package/scripts/pr-changed-files.cjs +63 -0
  302. package/scripts/pr-template-policy.cjs +14 -4
  303. package/scripts/prompt-injection-scan.sh +25 -0
  304. package/scripts/require-issue-link-policy.cjs +192 -0
  305. package/scripts/state-write-path-drift-baseline.json +19 -0
  306. package/scripts/sync-runtime-launcher.cjs +2 -4
  307. package/skills/gsd-autonomous/SKILL.md +0 -1
  308. package/skills/gsd-code-review/SKILL.md +1 -1
  309. package/skills/gsd-execute-phase/SKILL.md +1 -2
  310. package/skills/gsd-map-codebase/SKILL.md +1 -1
  311. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  312. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  313. package/skills/gsd-new-milestone/SKILL.md +1 -1
  314. package/skills/gsd-next/SKILL.md +0 -1
  315. package/skills/gsd-plan-phase/SKILL.md +0 -1
  316. package/skills/gsd-progress/SKILL.md +0 -1
  317. package/skills/gsd-quick/SKILL.md +1 -1
  318. package/skills/gsd-review-backlog/SKILL.md +2 -1
  319. package/skills/gsd-stats/SKILL.md +0 -1
  320. package/skills/gsd-verify-work/SKILL.md +1 -1
  321. package/vscode/package.json +1 -1
  322. package/gsd-core/workflows/discovery-phase.md +0 -298
  323. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  324. package/gsd-core/workflows/verify-phase.md +0 -574
  325. package/scripts/affected-tests-lib.cjs +0 -554
  326. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  327. package/scripts/run-affected-tests.cjs +0 -7
  328. package/scripts/run-tests.cjs +0 -1051
@@ -11,6 +11,7 @@ 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;
@@ -19,10 +20,10 @@ const configLoaderMod = require("./config-loader.cjs");
19
20
  const { loadConfig } = configLoaderMod;
20
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
22
  const phaseIdMod = require("./phase-id.cjs");
22
- const { escapeRegex, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir } = phaseIdMod;
23
+ const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId, scopeToPhase, } = phaseIdMod;
23
24
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
25
  const roadmapParserMod = require("./roadmap-parser.cjs");
25
- const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
26
+ const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasMilestoneSectioning } = roadmapParserMod;
26
27
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
27
28
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
29
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -30,16 +31,36 @@ const { planningDir, planningPaths } = planningWorkspace;
30
31
  const clock_cjs_1 = require("./clock.cjs");
31
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
33
  const frontmatter = require("./frontmatter.cjs");
33
- const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter;
34
+ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel } = frontmatter;
34
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
36
  const scanPhasePlans = require("./plan-scan.cjs");
36
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const verificationMod = require("./verification.cjs");
39
+ const { isPhaseComplete } = verificationMod;
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const planningScopeMod = require("./planning-scope.cjs");
42
+ const { SCOPE } = planningScopeMod;
43
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
44
+ const phaseLocatorMod = require("./phase-locator.cjs");
45
+ const { listMilestonePhaseDirs } = phaseLocatorMod;
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
47
  const stateTransitionMod = require("./state-transition.cjs");
48
+ // #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only
49
+ // node builtins, so it introduces no cycle on this path.
50
+ const project_root_cjs_1 = require("./project-root.cjs");
51
+ // #3311: advisory (phase, session) claim over the single Current Position slot.
52
+ // Imports only node builtins + planning-workspace + active-workstream-store, so
53
+ // it introduces no cycle on this path.
54
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
55
+ const milestoneLockMod = require("./milestone-lock.cjs");
38
56
  const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
39
57
  const state_document_cjs_1 = require("./state-document.cjs");
40
58
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
41
59
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
42
60
  const validate_cjs_1 = require("./validate.cjs");
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
62
+ const healthDiagnosticTypesMod = require("./health-diagnostic-types.cjs");
63
+ const { SEVERITY, adviseRemedy } = healthDiagnosticTypesMod;
43
64
  const STATE_PROGRESS_RESYNC_FIELDS = new Set([
44
65
  'Progress',
45
66
  'Total Plans in Phase',
@@ -229,7 +250,7 @@ function cmdStateGet(cwd, section, raw) {
229
250
  return;
230
251
  }
231
252
  // Try to find markdown section or field
232
- const fieldEscaped = escapeRegex(section);
253
+ const fieldEscaped = (0, pattern_cjs_1.escapeRegex)(section);
233
254
  // Check for **field:** value (bold format)
234
255
  const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
235
256
  const boldMatch = content.match(boldPattern);
@@ -290,12 +311,33 @@ function cmdStatePatch(cwd, patches, raw) {
290
311
  // #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name
291
312
  // delta (table-driven) that this phase adds. Field-name validation (security)
292
313
  // and the resync-progress decision stay in this adapter.
293
- let results = { updated: [], failed: [] };
314
+ let precomputed = { updated: [], failed: [] };
315
+ let preSyncContent = '';
316
+ const divergedFields = [];
294
317
  readModifyWriteStateMd(statePath, (content) => {
295
318
  const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock });
296
- results = result.data ?? results;
319
+ precomputed = result.data ?? precomputed;
320
+ preSyncContent = result.content;
297
321
  return result.content;
298
- }, cwd, { resync: shouldResync });
322
+ }, cwd, { resync: shouldResync, divergedFields });
323
+ // ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
324
+ // patchCore's bookkeeping says whether the stateReplaceField text-replace
325
+ // MATCHED — but its plain-line pattern (`m` flag over the full document)
326
+ // can match the YAML frontmatter line for a lower-cased key, and the write
327
+ // pipeline (syncStateFrontmatter re-derivation + the FIELD_CLASSIFICATION
328
+ // preservation rows) then discards or restores that text before the file is
329
+ // saved. A field is only reported `updated` when its post-write on-disk
330
+ // value equals what THIS transform actually wrote (the frontmatter key
331
+ // when present, else the body field — the legitimate working case for
332
+ // state.patch is display-cased BODY fields — Status, Current Plan, Phase —
333
+ // which are never frontmatter keys). Also folds in any field
334
+ // `applyStatePreservation` restored that this patch never named at all
335
+ // (#3345's direction), a case the pre-#3471 version of this command never
336
+ // covered.
337
+ const updated = reconcileReportedFields(statePath, preSyncContent, precomputed.updated, divergedFields);
338
+ const updatedSet = new Set(updated);
339
+ const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
340
+ const results = { updated, failed };
299
341
  output(results, raw, results.updated.length > 0 ? 'true' : 'false');
300
342
  }
301
343
  catch {
@@ -316,6 +358,8 @@ function cmdStateUpdate(cwd, field, value) {
316
358
  const statePath = planningPaths(cwd).state;
317
359
  try {
318
360
  let updated = false;
361
+ let preSyncContent = '';
362
+ const divergedFields = [];
319
363
  const shouldResync = shouldResyncStateProgress([field]);
320
364
  // ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
321
365
  // body-strip/reassemble single-field update is the pure `updateCore` in
@@ -326,13 +370,24 @@ function cmdStateUpdate(cwd, field, value) {
326
370
  readModifyWriteStateMd(statePath, (content) => {
327
371
  const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock });
328
372
  updated = result.data?.updated === true;
373
+ preSyncContent = result.content;
329
374
  return result.content;
330
- }, cwd, { resync: shouldResync });
375
+ }, cwd, { resync: shouldResync, divergedFields });
376
+ // ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
377
+ // `updateCore`'s own match does not know whether sync/preservation later
378
+ // discarded the value it wrote (#3351's direction, generalized from
379
+ // `cmdStatePatch`). `preserved` folds in any OTHER field preservation
380
+ // restored during this write that this command never touched at all
381
+ // (#3345's direction) — reported separately from `updated` because this
382
+ // command's contract is a single-field boolean, not a per-field array.
383
+ const reconciled = reconcileReportedFields(statePath, preSyncContent, updated ? [field] : [], divergedFields);
384
+ updated = reconciled.includes(field);
385
+ const preserved = reconciled.filter((f) => f !== field);
331
386
  if (updated) {
332
- output({ updated: true }, false, undefined);
387
+ output({ updated: true, preserved }, false, undefined);
333
388
  }
334
389
  else {
335
- output({ updated: false, reason: `Field "${field}" not found in STATE.md` }, false, undefined);
390
+ output({ updated: false, reason: `Field "${field}" not found in STATE.md`, preserved }, false, undefined);
336
391
  }
337
392
  }
338
393
  catch {
@@ -378,20 +433,50 @@ function cmdStateAdvancePlan(cwd, raw) {
378
433
  sourcePath: statePath,
379
434
  };
380
435
  let resultData;
436
+ let precomputedUpdated = [];
437
+ let preSyncContent = '';
438
+ const divergedFields = [];
439
+ // #3311: the milestone (phase + session) claim is consulted INSIDE the
440
+ // STATE.md lock, so the position read and the claim read cannot interleave
441
+ // with another session's Current Position write.
442
+ let milestoneConflict = null;
381
443
  readModifyWriteStateMd(statePath, (content) => {
444
+ // advance-plan has no phase argument of its own — the phase it advances is
445
+ // whatever ## Current Position names. Compare that against the milestone
446
+ // claim: a mismatch means another session moved the single-slot position
447
+ // away from the claimed phase (the #3311 flip) and must be surfaced, not
448
+ // silently absorbed.
449
+ const body = stripFrontmatter(content);
450
+ const positionScope = matchCurrentPositionSection(body) ?? body;
451
+ const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
452
+ if (positionPhase !== null) {
453
+ milestoneConflict = milestoneLockMod.checkMilestonePosition(cwd, positionPhase);
454
+ if (milestoneConflict) {
455
+ milestoneLockMod.warnMilestoneConflict(milestoneConflict, 'state.advance-plan');
456
+ }
457
+ }
382
458
  const result = transitionCore(content, intent, deps);
383
459
  resultData = result.data;
460
+ precomputedUpdated = result.updated;
461
+ preSyncContent = result.content;
384
462
  return result.content;
385
- }, cwd);
463
+ }, cwd, { divergedFields });
386
464
  if (!resultData || resultData['error']) {
387
465
  output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
388
466
  return;
389
467
  }
468
+ // ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
469
+ // the bytes actually persisted — this command previously reported none of
470
+ // its per-field writes at all (`updated` never left `advancePlanCore`).
471
+ // Generalizes fix(#3351) (closes #3351's direction) and folds in any field
472
+ // preservation restored that this transform never touched (#3345's
473
+ // direction).
474
+ const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
390
475
  if (resultData['advanced'] === false) {
391
- output(resultData, raw, 'false');
476
+ output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'false');
392
477
  }
393
478
  else {
394
- output(resultData, raw, 'true');
479
+ output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
395
480
  }
396
481
  }
397
482
  function cmdStateRecordMetric(cwd, options, raw) {
@@ -546,35 +631,126 @@ function cmdStateRecordMetric(cwd, options, raw) {
546
631
  result['created'] = true;
547
632
  output(result, raw, 'true');
548
633
  }
634
+ /**
635
+ * #3583: computes the write-path percent AND the completed/total plan counts
636
+ * reported alongside it from ONE `buildStateFrontmatter` call, so
637
+ * `cmdStateUpdateProgress`'s JSON output cannot report a `percent` that
638
+ * disagrees with its own `completed`/`total` (`buildStateFrontmatter`'s
639
+ * `progress.{percent,completed_plans,total_plans}` all come from the same
640
+ * disk scan, scoped to the STORED `milestone:` frontmatter value — #3017).
641
+ * Re-deriving completed/total from a second, differently-scoped scan (the
642
+ * auto-derived one `cmdStateUpdateProgress` still runs for its own #3217/
643
+ * #3233 withhold gates) is what let the two disagree when the auto-derived
644
+ * "current" milestone differs from the stored one.
645
+ *
646
+ * Perf note: this duplicates buildStateFrontmatter's own `getMilestoneInfo`
647
+ * (re-reads/re-parses ROADMAP.md) and `readGitHeadSha` (a `git rev-parse`
648
+ * subprocess spawn) — neither is memoized, unlike the phase/plan disk scan
649
+ * (`_diskScanCache`), which IS shared with the second `buildStateFrontmatter`
650
+ * call `readModifyWriteStateMd` makes below. Both non-cached calls therefore
651
+ * run twice per `state update-progress`.
652
+ */
653
+ function computeUpdateProgressPreview(statePath, cwd) {
654
+ const preContent = node_fs_1.default.readFileSync(statePath, 'utf-8');
655
+ const existingFm = extractFrontmatter(preContent, statePath);
656
+ const preBody = stripFrontmatter(preContent);
657
+ const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
658
+ const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
659
+ const progress = builtFm['progress'];
660
+ const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
661
+ const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
662
+ const totalPlans = progress && typeof progress['total_plans'] === 'number' ? progress['total_plans'] : null;
663
+ // A null percent is REACHABLE beyond the #3217/#3233 withholds the caller
664
+ // already applies — buildStateFrontmatter also nulls it via its own #1761
665
+ // milestone-unbounded guard, evaluated from `assertedMilestoneVersion`
666
+ // (an independent derivation, including a bare-version-token-in-prose
667
+ // fallback) rather than from `storedMilestone`/diskScope, so a STATE.md
668
+ // with no explicit `milestone:` field but a bare vX.Y token mentioned in
669
+ // ROADMAP prose can pass both of the caller's guards and still land here.
670
+ // Falling back to a locally-computed percent would reintroduce the exact
671
+ // #3583 defect for that case, so withhold instead — same shape as the
672
+ // caller's own no-op guards.
673
+ if (percent === null || completedPlans === null || totalPlans === null) {
674
+ return { withheld: true, reason: 'progress percent withheld by buildStateFrontmatter — STATE.md left unchanged' };
675
+ }
676
+ return { withheld: false, percent, completedPlans, totalPlans };
677
+ }
549
678
  function cmdStateUpdateProgress(cwd, raw) {
550
679
  const statePath = planningPaths(cwd).state;
551
680
  if (!node_fs_1.default.existsSync(statePath)) {
552
681
  output({ error: 'STATE.md not found' }, raw, undefined);
553
682
  return;
554
683
  }
555
- // Count summaries across current milestone phases only (outside lock — read-only)
684
+ // Auto-derived scan across current-milestone phases (outside lock — read-only).
685
+ // Gates the #3217/#3233 withholds below ONLY — the reported completed/total
686
+ // counts come from computeUpdateProgressPreview's differently-scoped
687
+ // (stored-milestone) scan instead, so percent and completed/total can never
688
+ // disagree (#3583, finding 1).
556
689
  const phasesDir = planningPaths(cwd).phases;
557
690
  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);
691
+ let phaseScope = SCOPE.UNREADABLE;
692
+ {
693
+ // #3185 (ADR-3180 Decision 1): "which phase directories belong to the
694
+ // CURRENT milestone" — routed through the canonical owner instead of a
695
+ // hand-rolled readdirSync + isDirInMilestone filter (which also never
696
+ // excluded sentinels, unlike the owner). The owner already handles an
697
+ // absent phasesDir as a real empty, so the fs.existsSync guard folds
698
+ // into it.
699
+ const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
700
+ phaseScope = scope;
564
701
  for (const dir of phaseDirs) {
565
- const { planCount, summaryCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
702
+ const { planCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
566
703
  totalPlans += planCount;
567
- totalSummaries += summaryCount;
568
704
  }
569
705
  }
570
- const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
706
+ // #3217 (ADR-3180 §7.6 rule 4): a non-COMPLETE scope means the counts
707
+ // above are not a trustworthy answer — do not write a percentage derived
708
+ // from them into STATE.md at all (A7). This is the write path, so
709
+ // "withhold" means "make no edit" rather than emitting a null value.
710
+ if (phaseScope !== SCOPE.COMPLETE) {
711
+ // #3217 finding 3 (decided: surface a warning, not silent-only
712
+ // disclosure): the JSON `reason` field alone is easy for a caller to
713
+ // never read, and STATE.md's Progress field goes stale with no
714
+ // user-visible signal beyond it. Mirrors the established
715
+ // `[gsd-tools] WARNING:` stderr convention this file already uses
716
+ // (stateReplaceFieldWithFallback above) for a comparable silent no-op.
717
+ process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — phase scope is ${phaseScope}, not complete. ` +
718
+ `STATE.md's Progress field was left unchanged.\n`);
719
+ output({ updated: false, reason: `phase scope is ${phaseScope}, not complete` }, raw, 'false');
720
+ return;
721
+ }
722
+ // #3233: zero plans in the current-milestone phases means there is nothing to
723
+ // measure — most often the milestone was just closed and its phases archived
724
+ // (.planning/phases/ empty, but scope COMPLETE — "a real empty"). clampPercent
725
+ // maps 0/0 to 0%, which would clobber the shipped Progress record (e.g.
726
+ // [██████████] 100% → [░░░░░░░░░░] 0%). No-op instead, mirroring the
727
+ // scope-withhold above and computeProgressPercent's null-for-empty contract
728
+ // ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist,
729
+ // none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0.
730
+ if (totalPlans === 0) {
731
+ process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — no plans found in current-milestone phases (0 plans). ` +
732
+ `STATE.md's Progress field was left unchanged (milestone archived?).\n`);
733
+ output({ updated: false, reason: 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)' }, raw, 'false');
734
+ return;
735
+ }
736
+ // #3583: percent AND the completed/total counts reported alongside it both
737
+ // come from the SAME buildStateFrontmatter call (computeUpdateProgressPreview)
738
+ // — never from the auto-derived scan above, which exists only to gate the
739
+ // #3217/#3233 withholds and is scoped differently (no stored-milestone
740
+ // override), so reusing its counts here could report a percent that
741
+ // disagrees with its own completed/total.
742
+ const preview = computeUpdateProgressPreview(statePath, cwd);
743
+ if (preview.withheld) {
744
+ process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — ${preview.reason}\n`);
745
+ output({ updated: false, reason: preview.reason }, raw, 'false');
746
+ return;
747
+ }
748
+ const { percent, completedPlans: fmCompletedPlans, totalPlans: fmTotalPlans } = preview;
571
749
  const barWidth = 10;
572
750
  const filled = Math.round(percent / 100 * barWidth);
573
751
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
574
752
  const progressStr = `[${bar}] ${percent}%`;
575
753
  let updated = false;
576
- const _totalPlans = totalPlans;
577
- const _totalSummaries = totalSummaries;
578
754
  readModifyWriteStateMd(statePath, (content) => {
579
755
  // #2177: match against the BODY only. With /i the patterns below would
580
756
  // otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would
@@ -603,7 +779,7 @@ function cmdStateUpdateProgress(cwd, raw) {
603
779
  return fmPrefix + body.replace(pattern, (_match, prefix, value) => `${prefix}${replaceValue(value)}`);
604
780
  }, cwd);
605
781
  if (updated) {
606
- output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
782
+ output({ updated: true, percent, completed: fmCompletedPlans, total: fmTotalPlans, bar: progressStr }, raw, progressStr);
607
783
  }
608
784
  else {
609
785
  output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
@@ -630,7 +806,20 @@ function cmdStateAddDecision(cwd, options, raw) {
630
806
  output({ error: 'summary required' }, raw, undefined);
631
807
  return;
632
808
  }
633
- const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
809
+ // #3231/#3481: `--phase` omitted → resolve from the STATE.md being written, via
810
+ // the canonical ladder `state prune` uses. A decision entry is a permanent
811
+ // record, so a literal `[Phase ?]` written while `current_phase` sat three
812
+ // lines above the insertion point loses that decision's provenance for good.
813
+ // Explicit `--phase` still wins, and its path is untouched — the file is not
814
+ // even read. When no rung resolves, `?` is still written: an unknown phase
815
+ // stays visibly unknown rather than being guessed or defaulted to a number.
816
+ let phaseId = phase;
817
+ if (!phaseId) {
818
+ const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
819
+ const fm = extractFrontmatter(rawState, statePath);
820
+ phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
821
+ }
822
+ const entry = `- [Phase ${phaseId || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
634
823
  let _added = false;
635
824
  let created = false;
636
825
  readModifyWriteStateMd(statePath, (content) => {
@@ -780,7 +969,20 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
780
969
  const actionText = (action && action.trim()) || 'changed';
781
970
  const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
782
971
  const urgentText = urgent ? ' (URGENT)' : '';
783
- const entry = `- Phase ${phase || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
972
+ // #3481: same treatment as add-decision's #3231 fix — `--phase` omitted →
973
+ // resolve from the STATE.md being written via the shared write-path ladder.
974
+ // A roadmap-evolution entry is a permanent record of why the roadmap changed
975
+ // shape, so a literal `Phase ?` written while `current_phase` sat in the
976
+ // frontmatter above the insertion point makes that trail unattributable.
977
+ // Explicit `--phase` still wins (the file is not even read on that path), and
978
+ // `?` is still written when nothing resolves — never a guess.
979
+ let phaseId = phase;
980
+ if (!phaseId) {
981
+ const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
982
+ const fm = extractFrontmatter(rawState, statePath);
983
+ phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
984
+ }
985
+ const entry = `- Phase ${phaseId || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
784
986
  let duplicate = false;
785
987
  let created = false;
786
988
  let subsectionCreated = false;
@@ -936,6 +1138,8 @@ function cmdStateRecordSession(cwd, options, raw) {
936
1138
  const now = clock_cjs_1.realClock.nowIso();
937
1139
  const updated = [];
938
1140
  let sessionCreated = false;
1141
+ let preSyncContent = '';
1142
+ const divergedFields = [];
939
1143
  readModifyWriteStateMd(statePath, (content) => {
940
1144
  // Update Last session / Last Date
941
1145
  let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
@@ -949,13 +1153,25 @@ function cmdStateRecordSession(cwd, options, raw) {
949
1153
  updated.push('Last Date');
950
1154
  }
951
1155
  // Update Stopped at
1156
+ // #3374 Variant B: stateReplaceField returns the replaced string on any
1157
+ // label MATCH, including when the value is already the target. Pushing
1158
+ // 'Stopped At' on match alone reported a write that never changed a byte
1159
+ // (and that the #948 no-op guard may then discard entirely), leaving a
1160
+ // stale frontmatter stopped_at undetectable to the caller. Report only on
1161
+ // real change — and track the match separately so an identical value does
1162
+ // not read as "label missing" to the #944 DWIM insertion below (whose
1163
+ // section rewrite would reset an executor-authored resume file to None).
1164
+ let stoppedAtMatched = false;
952
1165
  if (options.stopped_at) {
953
1166
  result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped At', options.stopped_at);
954
1167
  if (!result)
955
1168
  result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped at', options.stopped_at);
956
1169
  if (result) {
957
- content = result;
958
- updated.push('Stopped At');
1170
+ stoppedAtMatched = true;
1171
+ if (result !== content) {
1172
+ content = result;
1173
+ updated.push('Stopped At');
1174
+ }
959
1175
  }
960
1176
  }
961
1177
  // Update Resume File — only when the caller explicitly passed a value OR the
@@ -1011,7 +1227,10 @@ function cmdStateRecordSession(cwd, options, raw) {
1011
1227
  // missing canonical fields are inserted while the heading and any prose are
1012
1228
  // preserved (#1101). Only append a brand-new section when NEITHER heading exists.
1013
1229
  const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
1014
- const needsStoppedAt = options.stopped_at && !updated.includes('Stopped At');
1230
+ // #3374: keyed on the label MATCH, not on updated[] — a matched-but-
1231
+ // identical value is already persisted on disk and must not trigger the
1232
+ // insertion rewrite below.
1233
+ const needsStoppedAt = options.stopped_at && !stoppedAtMatched;
1015
1234
  const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
1016
1235
  const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date');
1017
1236
  if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
@@ -1134,10 +1353,16 @@ function cmdStateRecordSession(cwd, options, raw) {
1134
1353
  updated.push('Resume File');
1135
1354
  }
1136
1355
  }
1356
+ preSyncContent = content;
1137
1357
  return content;
1138
- }, cwd);
1139
- if (updated.length > 0) {
1140
- const result = { recorded: true, updated };
1358
+ }, cwd, { divergedFields });
1359
+ // ADR-3408 §8.4 (D4): reconcile this command's own success list against the
1360
+ // bytes actually persisted (fix(#3351) generalized) and fold in any field
1361
+ // preservation restored that this transform never touched (#3345's
1362
+ // direction).
1363
+ const reconciledUpdated = reconcileReportedFields(statePath, preSyncContent, updated, divergedFields);
1364
+ if (reconciledUpdated.length > 0) {
1365
+ const result = { recorded: true, updated: reconciledUpdated };
1141
1366
  if (sessionCreated)
1142
1367
  result['created'] = true;
1143
1368
  output(result, raw, 'true');
@@ -1184,11 +1409,14 @@ function matchSessionSection(body) {
1184
1409
  * excludes unrelated headings. Built on the same `collectSection` seam as
1185
1410
  * matchSessionSection, so it inherits that seam's CRLF tolerance (#2444 fix).
1186
1411
  * Returns the section body, or null (caller falls back to full-body search).
1412
+ *
1413
+ * The scoping logic now lives in state-document.cjs's `stateCurrentPositionSlice`
1414
+ * (the module that owns STATE.md field extraction) — this is a thin alias kept
1415
+ * for call-site stability. Two copies of this scope would be exactly the kind
1416
+ * of generative-fix divergence the repo's parity rule exists to prevent.
1187
1417
  */
1188
1418
  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;
1419
+ return (0, state_document_cjs_1.stateCurrentPositionSlice)(body);
1192
1420
  }
1193
1421
  /**
1194
1422
  * #2567: prevent a stale archive "Last activity:" line from overwriting a
@@ -1215,19 +1443,18 @@ function preferNewerLastActivity(existingFm, derivedFm) {
1215
1443
  const derDate = derRaw.slice(0, 10);
1216
1444
  if (!/^\d{4}-\d{2}-\d{2}$/.test(exDate) || !/^\d{4}-\d{2}-\d{2}$/.test(derDate))
1217
1445
  return;
1446
+ // #3258: this guard now protects only `last_activity` (a `derive` row) against
1447
+ // the stale-archive regression (#2567). `last_activity_desc` used to be
1448
+ // restored here too (both the older-date and the #3052 same-date branches),
1449
+ // but that was a date-comparison rule — a DIFFERENT policy from the
1450
+ // `preserve-when-unchanged` row its FIELD_CLASSIFICATION entry declares.
1451
+ // Keeping both was two rules that could disagree. last_activity_desc is now
1452
+ // governed by exactly one rule: its table row, enforced by
1453
+ // applyStatePreservation's #1230 delta heuristic on the RMW path (where every
1454
+ // desc-preserving transition — planned-phase / advance / complete / milestone
1455
+ // — runs). The #3052 same-date contract still holds via that delta rule.
1218
1456
  if (derDate < exDate) {
1219
1457
  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
1458
  }
1232
1459
  }
1233
1460
  function parseProsePhaseField(value) {
@@ -1239,6 +1466,76 @@ function parseProsePhaseField(value) {
1239
1466
  // current_phase instead of clobbering it.
1240
1467
  return parsePhaseFromProse(value);
1241
1468
  }
1469
+ function resolveStatePhase(fm, body) {
1470
+ const currentPositionScope = matchCurrentPositionSection(body) ?? body;
1471
+ const frontmatterRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', null).value;
1472
+ const legacyRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase').value;
1473
+ const currentPositionRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Phase').value;
1474
+ const sources = {
1475
+ frontmatter: parseProsePhaseField(frontmatterRaw).phase,
1476
+ legacy_current_phase: parseProsePhaseField(legacyRaw).phase,
1477
+ current_position_phase: parseProsePhaseField(currentPositionRaw).phase,
1478
+ };
1479
+ const prosePhase = parseProsePhaseField(currentPositionRaw);
1480
+ return {
1481
+ phase: sources.frontmatter ?? sources.legacy_current_phase ?? sources.current_position_phase,
1482
+ name: (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase_name', null).value
1483
+ ?? (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase Name').value
1484
+ ?? prosePhase.name,
1485
+ sources,
1486
+ };
1487
+ }
1488
+ /**
1489
+ * Resolve a STATE.md's own current phase id from the document itself — the
1490
+ * WRITE-PATH ladder shared by `cmdStateAddDecision` (#3231) and
1491
+ * `cmdStateAddRoadmapEvolution` (#3481), extracted from the ladder
1492
+ * `cmdStatePrune` already ran (#1760).
1493
+ *
1494
+ * The rungs are the canonical ones owned by state-document.cjs's
1495
+ * `stateFieldValue` (#3187, ADR-3180 §7.7): frontmatter `current_phase` → body
1496
+ * `Current Phase` field → prose `Phase: X of Y` scoped to `## Current
1497
+ * Position`.
1498
+ *
1499
+ * #1776: the prose rung stays scoped to `## Current Position`. Over the whole
1500
+ * body, `stateExtractField`'s pipe-table fallback matches any `| Phase | N |`
1501
+ * row — e.g. a historical verification table — and would resolve a stale phase.
1502
+ * Frontmatter and the explicit `Current Phase` field are unambiguous, so they
1503
+ * stay document-wide. `cmdStateSnapshot` deliberately keeps the looser
1504
+ * whole-body fallback for its own prose rung and is not routed through here.
1505
+ *
1506
+ * Returns the id exactly as written, NOT parsed to a number: phase ids are not
1507
+ * always integers (`11-01` and `04.1` are both real). Callers needing an
1508
+ * integer parse it themselves. Returns null when no rung carries a value — a
1509
+ * genuinely absent phase is a real answer (§7.7 behavior table row 4), and
1510
+ * callers must render it as unknown rather than guess one.
1511
+ *
1512
+ * NOT the same function as `resolveStatePhase` above (#3208), and deliberately
1513
+ * not routed through it — the difference is one line and it is the whole point:
1514
+ *
1515
+ * resolveStatePhase: matchCurrentPositionSection(body) ?? body
1516
+ * resolveCurrentPhaseId: null when the section is absent
1517
+ *
1518
+ * That `?? body` fallback is exactly the #1776 hazard. With no `## Current
1519
+ * Position` section, the prose rung widens to the entire document, where
1520
+ * `stateExtractField`'s pipe-table fallback matches any `| Phase | N |` row —
1521
+ * a historical verification table included — and resolves a stale phase.
1522
+ *
1523
+ * `resolveStatePhase`'s callers (`cmdStateSnapshot`, `cmdStateValidate`) READ
1524
+ * and report; a stale guess there is a wrong line in output a human is already
1525
+ * looking at. This function's callers WRITE: `cmdStateAddDecision` and
1526
+ * `cmdStateAddRoadmapEvolution` persist the result into records that outlive
1527
+ * the session, and `cmdStatePrune` decides what to delete from it. A wrong
1528
+ * phase there is durable and silent, so the write path takes the strict rung
1529
+ * and renders `?` rather than guessing.
1530
+ *
1531
+ * Reconcile the two only by giving `resolveStatePhase` an explicit scope
1532
+ * parameter — never by pointing this at it and dropping the difference.
1533
+ */
1534
+ function resolveCurrentPhaseId(fm, body) {
1535
+ const positionSection = sliceCurrentPositionSection(body);
1536
+ const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value).phase : null;
1537
+ return (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ?? prosePhase;
1538
+ }
1242
1539
  function parseProseLastActivityField(value) {
1243
1540
  if (!value)
1244
1541
  return { date: null, description: null };
@@ -1264,21 +1561,9 @@ function cmdStateSnapshot(cwd, raw) {
1264
1561
  // reported under a content digest — STATE.md is one of the artefacts epic #1879 is about.
1265
1562
  const fm = extractFrontmatter(content, statePath);
1266
1563
  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
- };
1564
+ // #3187: frontmatter-scalar-then-body-field precedence is owned by
1565
+ // state-document.cjs's `stateFieldValue` (ADR-3180 §7.7) — this function no
1566
+ // longer holds its own fmScalar ladder.
1282
1567
  // Extract basic fields — frontmatter keys take precedence over body
1283
1568
  // #2956: scope `Phase` extraction to ## Current Position so a historical
1284
1569
  // Phase: / **Phase:** line in an archive section cannot overwrite the current
@@ -1286,25 +1571,24 @@ function cmdStateSnapshot(cwd, raw) {
1286
1571
  // so it is scopeable exactly like Stopped At under ## Session. Fall back to
1287
1572
  // full-body search only when no ## Current Position section exists, so files
1288
1573
  // 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');
1574
+ const resolvedPhase = resolveStatePhase(fm, body);
1575
+ const currentPhase = resolvedPhase.phase;
1576
+ const currentPhaseName = resolvedPhase.name;
1577
+ const totalPhasesRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_phases', 'Total Phases').value;
1578
+ const currentPlan = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_plan', 'Current Plan').value;
1579
+ const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
1580
+ const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value;
1581
+ const progressRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'progress', 'Progress').value;
1582
+ 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
1583
  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;
1584
+ const lastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity', null).value ?? proseLastActivity.date ?? rawLastActivity;
1585
+ const lastActivityDesc = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity_desc', 'Last Activity Description').value ?? proseLastActivity.description;
1302
1586
  // #2956: Paused At canonically lives in ## Session (see the comment above
1303
1587
  // preferNewerLastActivity and the write seam in buildStateFrontmatter). The
1304
1588
  // write seam already scopes it to ## Session; this read seam must agree, so a
1305
1589
  // stale "Paused At:" in a Session Continuity Archive cannot win here either.
1306
1590
  const sessionScope = matchSessionSection(body) ?? body;
1307
- const pausedAt = fmScalar('paused_at') ?? (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
1591
+ const pausedAt = (0, state_document_cjs_1.stateFieldValue)(fm, sessionScope, 'paused_at', 'Paused At').value;
1308
1592
  // Parse numeric fields
1309
1593
  const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1310
1594
  const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
@@ -1430,7 +1714,7 @@ function extractRetiredPhaseNumbers(scope) {
1430
1714
  * a YAML frontmatter object. Allows hooks and scripts to read state
1431
1715
  * reliably via `state json` instead of fragile regex parsing.
1432
1716
  */
1433
- function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1717
+ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases) {
1434
1718
  // #2956: scope `Phase` extraction to ## Current Position (mirrors the read
1435
1719
  // path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
1436
1720
  // below). Phase canonically lives in ## Current Position (templates/state.md);
@@ -1464,14 +1748,46 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1464
1748
  const pausedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Paused At');
1465
1749
  let milestone = null;
1466
1750
  let milestoneName = null;
1751
+ // #1761 regression fix (#3216): the milestone STATE.md actually ASSERTS,
1752
+ // independent of whether getMilestoneInfo's identity scope is COMPLETE.
1753
+ // Needed below by the disk-scan block's `isMilestoneBoundedInRoadmap` guard
1754
+ // — that check answers "is the ASSERTED version bounded to a versioned
1755
+ // ROADMAP heading", a different question from "is the identity trustworthy
1756
+ // enough to persist" (`milestone` above). Conflating the two regressed
1757
+ // #1761: when a real STATE `milestone:` value has no matching ROADMAP
1758
+ // heading, `info.scope` is never COMPLETE (rightly — there's no curated
1759
+ // name to persist), but the version was still genuinely asserted and the
1760
+ // bounded check must still run on it, or the guard silently no-ops and
1761
+ // `state json` reports a conflated whole-document total_phases/percent.
1762
+ let assertedMilestoneVersion = null;
1467
1763
  if (cwd) {
1468
1764
  // DEAD catch removed (#2245 audit): getMilestoneInfo has its own outer
1469
1765
  // try/catch (roadmap-parser.cts) that already swallows every internal
1470
- // failure and always returns a MilestoneInfo — it never throws, so this
1766
+ // failure and always returns a ScopedResult — it never throws, so this
1471
1767
  // wrapper could never be triggered.
1768
+ // #3216 (ADR-3180 §7.2 rule 6): this is the #3197 disk-write path. Rule 6
1769
+ // draws the line at the FIELD, not the scope as a whole — "a version known
1770
+ // but no name resolvable is TRUNCATED carrying {version, name: null} — the
1771
+ // version is a real answer, the name is a non-answer, and collapsing the
1772
+ // two is the failure this contract exists to prevent." So `milestone`
1773
+ // (the version) is written whenever COMPLETE or TRUNCATED — both carry a
1774
+ // genuine version per rule 6 — while `milestoneName` is written only on
1775
+ // COMPLETE, since TRUNCATED's name is by definition unresolved and must
1776
+ // never be fabricated. UNSCOPED/UNREADABLE have no real version either
1777
+ // way, so both stay null there. This mirrors cmdCommit (src/commands.cts),
1778
+ // which accepts COMPLETE or TRUNCATED for the same reason (the version is
1779
+ // real), and deliberately diverges from archivePhaseDirectories
1780
+ // (src/milestone.cts), which demands COMPLETE only because it uses the
1781
+ // value as a filesystem path component and a TRUNCATED version is not
1782
+ // safe to use there.
1472
1783
  const info = getMilestoneInfo(cwd);
1473
- milestone = info.version;
1474
- milestoneName = info.name;
1784
+ assertedMilestoneVersion = info.value ? info.value.version : null;
1785
+ if ((info.scope === SCOPE.COMPLETE || info.scope === SCOPE.TRUNCATED) && info.value) {
1786
+ milestone = info.value.version;
1787
+ }
1788
+ if (info.scope === SCOPE.COMPLETE && info.value) {
1789
+ milestoneName = info.value.name;
1790
+ }
1475
1791
  }
1476
1792
  let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1477
1793
  let completedPhases = null;
@@ -1480,6 +1796,14 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1480
1796
  // #1761 read-path: set from cached.milestoneBounded inside the disk-scan
1481
1797
  // block; consumed at the percent computation to mirror the cmdStateSync guard.
1482
1798
  let milestoneUnbounded = false;
1799
+ // #3217 (ADR-3180 §7.6 rule 4, finding 1): the real listMilestonePhaseDirs
1800
+ // scope for the disk-scanned counts below, set from cached.phaseDirScope
1801
+ // when a fresh disk scan runs. SCOPE.COMPLETE is the correct default here
1802
+ // — NOT a rule-4 hardcode — for the cases where no disk scan happens at all
1803
+ // (no cwd, or phasesDir absent): totalPhases/totalPlans then come straight
1804
+ // from the pre-existing frontmatter fields parsed above, a path this phase
1805
+ // does not touch and which predates listMilestonePhaseDirs entirely.
1806
+ let diskScope = SCOPE.COMPLETE;
1483
1807
  if (cwd) {
1484
1808
  try {
1485
1809
  const phasesDir = planningPaths(cwd).phases;
@@ -1507,14 +1831,16 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1507
1831
  // #3017: scope the milestone filter to the STORED milestone when available,
1508
1832
  // so a state.* write doesn't auto-derive (and mis-bind) to a different
1509
1833
  // 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);
1834
+ // #3185 (ADR-3180 Decision 1): "which phase directories belong to the
1835
+ // CURRENT (stored) milestone" — routed through the canonical owner
1836
+ // instead of a hand-rolled readdirSync + isDirInMilestone filter
1837
+ // (which also never excluded sentinels, unlike the owner).
1838
+ const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: storedMilestone ?? null });
1514
1839
  // Bug #2445: when stale phase dirs from a prior milestone remain in
1515
1840
  // .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).
1841
+ // de-duplicate by normalized phase number keeping exactly one dir
1842
+ // per key (deterministic tie-break: see #3355 below). This prevents
1843
+ // double-counting (e.g. two "Phase 1" dirs).
1518
1844
  const seenPhaseNums = new Map(); // normalizedNum -> dirName
1519
1845
  for (const dir of allMatchingDirs) {
1520
1846
  // #1514: a retired/folded phase keeps a directory but no completion
@@ -1523,22 +1849,35 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1523
1849
  // exclusion below). Project-code-aware via phaseKeyFromDir.
1524
1850
  if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
1525
1851
  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;
1852
+ // #3185: dedup grouping routed through the canonical phaseKeyFromDir
1853
+ // (src/phase-id.cts) instead of a local leading-digits regex that
1854
+ // diverged from extractPhaseToken/phaseKeyFromDir on
1855
+ // project-code-prefixed dirs (whole dirname fell through as the key,
1856
+ // so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
1857
+ // multi-segment milestone dirs. Same key surface used two lines
1858
+ // above for the retiredPhaseNums exclusion, so both filters agree.
1859
+ const key = phaseKeyFromDir(dir);
1529
1860
  if (!seenPhaseNums.has(key)) {
1530
1861
  seenPhaseNums.set(key, dir);
1531
1862
  }
1532
1863
  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 */ }
1864
+ // #3355: the survivor of a same-milestone collision must be
1865
+ // chosen from repository CONTENT, never from filesystem state.
1866
+ // The pre-#3355 tie-break was `mtimeMs` — a checkout-order
1867
+ // signal — so two byte-identical checkouts of the same commit
1868
+ // that wrote the colliding dirs in a different order picked
1869
+ // different survivors, and progress.total_plans /
1870
+ // completed_plans drifted across clones and CI runs. The
1871
+ // directory NAME is git-tracked content and a total order, so
1872
+ // the lexicographically-first dir wins deterministically. The
1873
+ // collision is still a project-level defect (duplicate phase
1874
+ // number in scope), so it is surfaced on stderr instead of
1875
+ // being silently resolved. The Bug #2445 invariant — exactly
1876
+ // one survivor per normalized phase number — is unchanged.
1877
+ const incumbent = seenPhaseNums.get(key);
1878
+ const survivor = dir < incumbent ? dir : incumbent;
1879
+ seenPhaseNums.set(key, survivor);
1880
+ 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
1881
  }
1543
1882
  }
1544
1883
  const phaseDirs = [...seenPhaseNums.values()];
@@ -1547,10 +1886,18 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1547
1886
  let diskCompletedPhases = 0;
1548
1887
  for (const dir of phaseDirs) {
1549
1888
  const phaseDir = node_path_1.default.join(phasesDir, dir);
1550
- const { planCount, summaryCount, completed } = scanPhasePlans(phaseDir);
1889
+ const { planCount, summaryCount } = scanPhasePlans(phaseDir);
1551
1890
  diskTotalPlans += planCount;
1552
1891
  diskTotalSummaries += summaryCount;
1553
- if (completed)
1892
+ // ADR-3180 §7.4 (#3186, #2957 disk-strict): "which phases are
1893
+ // complete" is the completion question, routed through the single
1894
+ // canonical owner (isPhaseComplete, src/verification.cts) — NOT
1895
+ // scanPhasePlans's own `completed` field, which only answers "are
1896
+ // all plans summarized" (a different question; see plan-scan.cts's
1897
+ // own comment on that field). Folding this consumer onto the raw
1898
+ // summaries-met flag was the exact "consolidate two of three and
1899
+ // leave the third" gap §7.4's forcing function rules out.
1900
+ if (isPhaseComplete(phaseDir).value.complete)
1554
1901
  diskCompletedPhases++;
1555
1902
  }
1556
1903
  // Count phase headings from ROADMAP using a digit-containing pattern
@@ -1567,8 +1914,9 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1567
1914
  // Only count tokens that contain at least one digit — excludes
1568
1915
  // pure-word section headings (Overview, Details) while keeping
1569
1916
  // 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]))
1917
+ // Also exclude sentinel phases (0 and 999.x backlog).
1918
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
1919
+ if (!/\d/.test(m[1]) || isSentinelPhaseId(m[1]))
1572
1920
  continue;
1573
1921
  // #1514: retired/folded phases are struck through in the ROADMAP;
1574
1922
  // exclude them from the denominator (they can never be completed).
@@ -1586,9 +1934,22 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1586
1934
  // phase-dir count only, and mark unbounded so percent is skipped
1587
1935
  // downstream (mirrors the sync write-path guard).
1588
1936
  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);
1937
+ // #3216 fix (#1761 regression): use `assertedMilestoneVersion` —
1938
+ // the version STATE.md actually asserts — not the scope-gated
1939
+ // `milestone`. `milestone` is null on any non-COMPLETE identity
1940
+ // scope (deliberately, so a non-trustworthy identity never
1941
+ // persists), but a real asserted version with no matching
1942
+ // ROADMAP heading is EXACTLY the unbounded case this guard exists
1943
+ // to catch; gating on `milestone` skipped the guard entirely and
1944
+ // let the whole-document roadmapPhaseCount conflate sibling
1945
+ // milestones again.
1946
+ if (assertedMilestoneVersion && roadmapRaw !== null) {
1947
+ // #3184: routed through the single owner (roadmap-parser.cjs)
1948
+ // instead of a hand-rolled, unbounded-substring re-derivation —
1949
+ // the prior inline regex had no boundary assertion after the
1950
+ // version token, so `v2.0` matched inside `v2.0.1` (#2562-class
1951
+ // defect, design row 17).
1952
+ milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(assertedMilestoneVersion).trim());
1592
1953
  }
1593
1954
  // #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
1594
1955
  // at all — only Phase headings) from a MILESTONED-but-unbounded one
@@ -1596,27 +1957,85 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1596
1957
  // On a flat roadmap the whole-doc count is correct (no sibling milestones to
1597
1958
  // conflate); on a sectioned-but-unbounded one it conflates siblings (#1761),
1598
1959
  // so fall back to phaseDirs.length.
1599
- const hasMilestoneSectioning = roadmapRaw !== null
1600
- && /^#{2,3}\s+(?!Phase\s+\S)/mi.test(roadmapRaw);
1960
+ // #3184: routed through the single owner (roadmap-parser.cjs) —
1961
+ // deliberately weaker than isMilestoneBoundedInRoadmap above (no
1962
+ // version-token requirement); see hasMilestoneSectioning's own
1963
+ // doc comment for why that distinction is load-bearing.
1964
+ const roadmapHasMilestoneSectioning = roadmapRaw !== null
1965
+ && hasMilestoneSectioning(roadmapRaw);
1601
1966
  const safeToUseRoadmapCount = milestoneBounded
1602
- || (roadmapPhaseCount > 0 && !hasMilestoneSectioning);
1967
+ || (roadmapPhaseCount > 0 && !roadmapHasMilestoneSectioning);
1968
+ // #3354: the milestoned-but-unbounded sibling of the #2828/#3204
1969
+ // shapes. The whole-document roadmapPhaseCount is rightly rejected
1970
+ // above (it would conflate sibling milestones, #1761), but the
1971
+ // on-disk phase-dir count is NOT an authoritative substitute for
1972
+ // the rejected total either — it counts only the current
1973
+ // milestone's realized directories (25 declared → 4 written in the
1974
+ // issue's report), silently shrinking progress.total_phases on
1975
+ // every STATE.md write. Mirror the branch's own percent withhold
1976
+ // (milestoneUnbounded below): return a null sentinel so the caller
1977
+ // keeps the pre-existing stored value instead of writing the
1978
+ // substitute, and warn on stderr naming the unbounded token so the
1979
+ // operator can curate the ROADMAP heading or the STATE assertion.
1980
+ // The degenerate un-sectioned zero-heading case keeps the
1981
+ // phaseDirs.length fallback — with nothing declared anywhere else,
1982
+ // the disk count is the only source and remains correct.
1983
+ const milestonedButUnbounded = !milestoneBounded && roadmapHasMilestoneSectioning;
1984
+ if (milestonedButUnbounded) {
1985
+ process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries multiple milestone sections; the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3354)\n`);
1986
+ }
1987
+ // #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
1988
+ // absent/unreadable the #549 heading counter never ran (roadmapScope
1989
+ // stayed null), `milestoneBounded` is vacuously true (its gate requires
1990
+ // roadmapRaw), and the dir count — which only ever counts phases that
1991
+ // have STARTED — would be persisted as progress.total_phases by every
1992
+ // state.* write. A STATE that asserts a milestone (storedMilestone —
1993
+ // getMilestoneInfo is useless here, it reads the roadmap that is
1994
+ // absent) declared a total somewhere; keep the stored frontmatter
1995
+ // value instead. Without an asserted milestone (fresh project,
1996
+ // pre-roadmap) the disk count is still the only source and stays
1997
+ // authoritative (the #3354 doctrine's degenerate case).
1998
+ const roadmapAbsentWithAssertedMilestone = roadmapRaw === null &&
1999
+ typeof storedMilestone === 'string' &&
2000
+ storedMilestone.trim() !== '';
2001
+ if (roadmapAbsentWithAssertedMilestone) {
2002
+ process.stderr.write(`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3573)\n`);
2003
+ }
1603
2004
  return {
1604
- totalPhases: safeToUseRoadmapCount
1605
- ? Math.max(phaseDirs.length, roadmapPhaseCount)
1606
- : phaseDirs.length,
2005
+ // The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
2006
+ // roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
2007
+ // safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
2008
+ // vacuously true (its gate requires roadmapRaw), so the safe-count
2009
+ // arm would otherwise swallow the withhold.
2010
+ totalPhases: (milestonedButUnbounded || roadmapAbsentWithAssertedMilestone)
2011
+ ? null
2012
+ : (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
1607
2013
  milestoneBounded,
1608
2014
  completedPhases: diskCompletedPhases,
1609
2015
  totalPlans: diskTotalPlans,
1610
2016
  completedPlans: diskTotalSummaries,
2017
+ phaseDirScope,
1611
2018
  };
1612
2019
  })();
1613
2020
  _diskScanCache.set(cwd, cached);
1614
2021
  }
1615
- totalPhases = cached.totalPhases;
2022
+ // #3354: cached.totalPhases === null is the milestoned-but-unbounded
2023
+ // WITHHOLD sentinel — the scan refused to substitute the dir count for
2024
+ // a rejected whole-document total, so keep the pre-existing value:
2025
+ // the stored frontmatter total when the caller can supply it, else the
2026
+ // body "Total Phases" annotation already parsed above, else leave null
2027
+ // (the key is omitted from the progress block).
2028
+ if (cached.totalPhases !== null) {
2029
+ totalPhases = cached.totalPhases;
2030
+ }
2031
+ else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
2032
+ totalPhases = storedTotalPhases;
2033
+ }
1616
2034
  completedPhases = cached.completedPhases;
1617
2035
  totalPlans = cached.totalPlans;
1618
2036
  completedPlans = cached.completedPlans;
1619
2037
  milestoneUnbounded = cached.milestoneBounded === false;
2038
+ diskScope = cached.phaseDirScope;
1620
2039
  }
1621
2040
  /* best-effort (#2245 audit): this is a READ path building STATE.md's
1622
2041
  * display frontmatter. The real throw source is fs.readdirSync(phasesDir)
@@ -1633,17 +2052,66 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1633
2052
  // ROADMAP-declared-but-unrealized future phases cap the reported completion
1634
2053
  // instead of a false 100% from plan-only coverage (#3242 Bug B).
1635
2054
  // 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);
2055
+ // #3217 (ADR-3180 §7.6 rule 4, finding 1): computeProgressPercent requires
2056
+ // a `Scope` for its own rule-4 gate. `diskScope` is the real
2057
+ // `listMilestonePhaseDirs` scope threaded through `_diskScanCache`
2058
+ // (`phaseDirScope` above) when a fresh disk scan ran — an UNREADABLE
2059
+ // phases dir now withholds here exactly as it does at every sibling
2060
+ // surface, closing the cross-surface disagreement the isolated review
2061
+ // caught. When no disk scan ran at all (no cwd, or phasesDir absent)
2062
+ // `diskScope` keeps its SCOPE.COMPLETE default, preserving this
2063
+ // function's pre-existing behavior on that (unrelated, pre-dating
2064
+ // listMilestonePhaseDirs) fallback path. This call site also keeps its own
2065
+ // orthogonal `milestoneUnbounded` null-out below (#1761) — a different
2066
+ // guard (ROADMAP heading boundedness, not disk readability).
2067
+ let progressPercent = (0, state_document_cjs_1.computeProgressPercent)(completedPlans, totalPlans, completedPhases, totalPhases, diskScope);
1637
2068
  // #1761 read-path: when the milestone can't be bounded, percent would be
1638
2069
  // derived from a conflated/understated total — skip it (mirror cmdStateSync).
1639
2070
  if (milestoneUnbounded)
1640
2071
  progressPercent = null;
1641
- if (progressPercent === null && progressRaw && !milestoneUnbounded) {
2072
+ // #3217 finding 1 (follow-on): a non-COMPLETE diskScope must withhold the
2073
+ // percentage EVERYWHERE, including this prose fallback — without the
2074
+ // `diskScope === SCOPE.COMPLETE` guard, a stale/existing "Progress: N%"
2075
+ // body line would silently defeat computeProgressPercent's rule-4 null,
2076
+ // re-introducing a rendered percentage on the exact scope this phase
2077
+ // withholds for (this is how the reviewer's UNREADABLE-phases fixture
2078
+ // could still surface a number even after the scope threading above).
2079
+ if (progressPercent === null && progressRaw && !milestoneUnbounded && diskScope === SCOPE.COMPLETE) {
1642
2080
  const pctMatch = progressRaw.match(/(\d+)%/);
1643
2081
  if (pctMatch)
1644
2082
  progressPercent = parseInt(pctMatch[1], 10);
1645
2083
  }
1646
- const normalizedStatus = (0, state_document_cjs_1.normalizeStateStatus)(status, pausedAt);
2084
+ let normalizedStatus = (0, state_document_cjs_1.normalizeStateStatus)(status, pausedAt);
2085
+ // #3578: normalizeStateStatus matches 'complete' as a case-insensitive
2086
+ // SUBSTRING, so the phase-completion prose cmdStateCompletePhase writes to
2087
+ // the body (`Phase ${N} complete`) collapses to the milestone-level
2088
+ // 'completed' status even when other phases remain open. Phase-level
2089
+ // prose must never decide milestone-level status — completedPhases /
2090
+ // totalPhases / diskScope, already derived above from a disk scan, are
2091
+ // the authority on whether the MILESTONE is actually done. Only override
2092
+ // when: (a) normalizeStateStatus actually landed on 'completed'; (b) the
2093
+ // raw prose is UNAMBIGUOUSLY phase-completion prose — the anchored
2094
+ // pattern below deliberately excludes "All phases complete" (no `\S+`
2095
+ // phase token) and milestone-close prose like "v1.0 milestone complete"
2096
+ // (no leading "phase"); and (c) the counters are trustworthy (a COMPLETE
2097
+ // disk scope, both counts are finite numbers, and a positive
2098
+ // denominator) and affirmatively disagree with 'completed'. In every
2099
+ // other case normalizedStatus is left exactly as normalizeStateStatus
2100
+ // returned it.
2101
+ if (normalizedStatus === 'completed' &&
2102
+ typeof status === 'string' &&
2103
+ /^\s*phase\s+\S+\s+complete\s*$/i.test(status) &&
2104
+ diskScope === SCOPE.COMPLETE &&
2105
+ // #1761: an unbounded milestone yields a conflated/understated total — the
2106
+ // same authority that nulls progressPercent above. Without this, a bad
2107
+ // denominator could demote a genuinely-complete milestone.
2108
+ !milestoneUnbounded &&
2109
+ typeof completedPhases === 'number' && Number.isFinite(completedPhases) &&
2110
+ typeof totalPhases === 'number' && Number.isFinite(totalPhases) &&
2111
+ totalPhases > 0 &&
2112
+ completedPhases < totalPhases) {
2113
+ normalizedStatus = 'executing';
2114
+ }
1647
2115
  const fm = { gsd_state_version: '1.0' };
1648
2116
  if (milestone)
1649
2117
  fm['milestone'] = milestone;
@@ -1665,6 +2133,13 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1665
2133
  fm['last_activity'] = lastActivity;
1666
2134
  if (lastActivityDesc)
1667
2135
  fm['last_activity_desc'] = lastActivityDesc;
2136
+ // #2573: stamp the commit this STATE.md was written against, so consumers can
2137
+ // report how far the codebase has moved since. Omitted entirely outside a git
2138
+ // repo — an absent field reads as "unknown", which is the honest answer and
2139
+ // keeps every consumer's tri-state intact (see readStateHeadFreshness).
2140
+ const stateHead = readGitHeadSha(cwd);
2141
+ if (stateHead)
2142
+ fm['state_head'] = stateHead;
1668
2143
  const progress = {};
1669
2144
  if (totalPhases !== null)
1670
2145
  progress['total_phases'] = totalPhases;
@@ -1680,7 +2155,190 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
1680
2155
  fm['progress'] = progress;
1681
2156
  return fm;
1682
2157
  }
1683
- function syncStateFrontmatter(content, cwd, authoritativeFm) {
2158
+ // ─── state_head commit provenance (#2573) ────────────────────────────────────
2159
+ //
2160
+ // STATE.md records the commit it was written against (`state_head`); consumers
2161
+ // derive how many commits the codebase has moved since. This mirrors the shipped
2162
+ // graphify commit-staleness contract (src/graphify.cts, #3170) rather than
2163
+ // inventing a second vocabulary: `commits_behind` is a count, and `commit_stale`
2164
+ // is TRI-STATE — null means "we don't know" (no git, no stamp, unresolvable
2165
+ // commit), which is deliberately distinct from false ("known fresh").
2166
+ //
2167
+ // IMPORTANT — this is a freshness PROXY, never a drift measurement.
2168
+ // `rev-list state_head..HEAD` counts every commit in between, including ones
2169
+ // that never touched anything STATE.md describes. And because `state_head`
2170
+ // restamps on EVERY state write, a low count means "something wrote STATE
2171
+ // recently", NOT "STATE's content is accurate". Consumers must word it as
2172
+ // approximate and must never gate on it.
2173
+ /** Strict hash fence before any value from disk reaches a git argument. */
2174
+ const STATE_HEAD_HASH_RE = /^[0-9a-f]{4,40}$/i;
2175
+ /**
2176
+ * Resolve the project's current HEAD sha, or null when unavailable.
2177
+ * Bounded + non-interactive via execGit (10s timeout, GIT_TERMINAL_PROMPT=0);
2178
+ * a non-repo, missing git, or timeout degrades to null rather than throwing.
2179
+ */
2180
+ /**
2181
+ * Does the project root carry its own git repository?
2182
+ *
2183
+ * #2573 D5. `git rev-parse HEAD` walks UP from cwd and stops at the FIRST
2184
+ * enclosing `.git`. So the repo that answered is the project's own exactly when
2185
+ * the project root itself carries a `.git` entry — a directory for a normal
2186
+ * clone, a file for a worktree or submodule, both of which `existsSync` accepts.
2187
+ * If it does not, the answer necessarily came from an ancestor repo and the
2188
+ * stamp would assert provenance the project cannot claim.
2189
+ *
2190
+ * Deliberately a filesystem-identity check rather than comparing
2191
+ * `--show-toplevel` against the project root as strings. That comparison is
2192
+ * unreliable across platforms — macOS resolves temp dirs through
2193
+ * `/private/var/…`, Windows adds 8.3 short names and separator/case variance —
2194
+ * and an over-strict compare degrades healthy projects to "unknown", which is
2195
+ * the very failure this check exists to prevent, inverted. No path spelling is
2196
+ * involved here at all.
2197
+ */
2198
+ function projectOwnsItsRepo(projectRoot) {
2199
+ try {
2200
+ return node_fs_1.default.existsSync(node_path_1.default.join(projectRoot, '.git'));
2201
+ }
2202
+ catch {
2203
+ return false;
2204
+ }
2205
+ }
2206
+ function readGitHeadSha(cwd) {
2207
+ if (!cwd)
2208
+ return null;
2209
+ // #2573 degrade path D5. `git rev-parse HEAD` walks UP from cwd to the nearest
2210
+ // enclosing `.git`, and nothing pins that repo to the project. A GSD project
2211
+ // living inside an unrelated checkout — a dotfiles/notes repo, or the outer
2212
+ // workspace of a `planning.sub_repos` layout where all code commits land in
2213
+ // the sub-repos — would otherwise measure freshness against a repo it has no
2214
+ // relationship to, and report `commit_stale: false` ("known fresh") while
2215
+ // doing it. Unverified provenance must degrade to unknown, never to fresh.
2216
+ //
2217
+ // TWO independent conditions must hold before a stamp is trustworthy, and both
2218
+ // are checked below because either alone is insufficient:
2219
+ // 1. the project root owns a `.git` (else an ancestor repo answered), and
2220
+ // 2. the project is not a `sub_repos` workspace (else the repo that answers
2221
+ // is the outer wrapper, whose HEAD does not move when the code does).
2222
+ // KNOWN LIMITATION, by design: in a `sub_repos` workspace this feature reports
2223
+ // unknown rather than measuring the children. Per-child freshness needs a
2224
+ // defined aggregate across N histories and is out of scope for this increment.
2225
+ //
2226
+ // `--show-toplevel HEAD` answers both in ONE spawn, so pinning costs no extra
2227
+ // subprocess on this path (the caller holds the STATE lock).
2228
+ let projectRoot;
2229
+ try {
2230
+ projectRoot = (0, project_root_cjs_1.findProjectRoot)(cwd);
2231
+ }
2232
+ catch {
2233
+ return null; // cannot prove which repo would answer → unknown
2234
+ }
2235
+ if (!projectOwnsItsRepo(projectRoot))
2236
+ return null;
2237
+ // #2573 D5, sub_repos flavor. Owning a `.git` is necessary but NOT sufficient.
2238
+ // In a `planning.sub_repos` workspace the outer directory can legitimately own
2239
+ // BOTH `.planning/` and its own repo while every code commit lands in a nested
2240
+ // child repo — `docs/CONFIGURATION.md` describes sub_repos as scoping work per
2241
+ // sub-repo "instead of treating the outer repo as a monorepo". The outer HEAD
2242
+ // then never advances, so `merge-base --is-ancestor` passes trivially and
2243
+ // `rev-list` counts 0: the stamp would report `commit_stale: false`, i.e.
2244
+ // "known fresh", while the code it describes has moved arbitrarily far.
2245
+ //
2246
+ // That is a WRONG answer, not a missing one, and it is the same invariant the
2247
+ // ancestor-repo check above exists to protect: a freshness claim the project
2248
+ // cannot substantiate must degrade to unknown, never to fresh. Measuring the
2249
+ // children instead would mean picking one HEAD out of N unrelated histories
2250
+ // (or inventing an aggregate), which is a design question beyond this
2251
+ // increment — so this scopes to the honest tri-state and declines to answer.
2252
+ // Deliberately keyed on the DECLARED config rather than probing the filesystem
2253
+ // for nested `.git` entries: the declaration is what the workspace asserts
2254
+ // about itself, and a probe would spuriously fire on a vendored dependency.
2255
+ try {
2256
+ const subRepos = loadConfig(projectRoot).sub_repos;
2257
+ if (Array.isArray(subRepos) && subRepos.length > 0)
2258
+ return null;
2259
+ }
2260
+ catch {
2261
+ return null; // cannot read the layout → cannot claim provenance → unknown
2262
+ }
2263
+ const r = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', 'HEAD'], { cwd });
2264
+ if (r.exitCode !== 0)
2265
+ return null;
2266
+ const sha = r.stdout.trim();
2267
+ return STATE_HEAD_HASH_RE.test(sha) ? sha : null;
2268
+ }
2269
+ /**
2270
+ * Derive the commit-age freshness signal from a recorded `state_head`.
2271
+ *
2272
+ * Single source of truth for the derivation — `validate.health` (W024) and
2273
+ * smart-entry both consume this rather than re-deriving it, so the tri-state
2274
+ * and the hash fence cannot drift apart between surfaces.
2275
+ *
2276
+ * Never throws: every unresolvable input degrades to nulls.
2277
+ */
2278
+ function readStateHeadFreshness(cwd, stateHead) {
2279
+ const raw = (typeof stateHead === 'string' ? stateHead : '').trim();
2280
+ const stamp = STATE_HEAD_HASH_RE.test(raw) ? raw : null;
2281
+ const head = readGitHeadSha(cwd);
2282
+ let commitsBehind = null;
2283
+ let commitStale = null;
2284
+ if (stamp && head && cwd) {
2285
+ // The stamp must be an ANCESTOR of HEAD before a distance means anything.
2286
+ // `rev-list --count A..B` exits 0 with "0" when A is not reachable from B —
2287
+ // which is what a `reset --hard` to an earlier commit, a rebase or squash
2288
+ // that drops the stamped commit, or a force-push rewriting history all
2289
+ // produce. Without this guard those cases report `commit_stale: false`,
2290
+ // i.e. "known fresh", for a codebase that was actually rewound past the
2291
+ // stamp — collapsing the exact unknown-vs-fresh distinction this tri-state
2292
+ // exists to preserve. A non-ancestor stamp is UNKNOWN, so it stays null.
2293
+ const ancestry = (0, shell_command_projection_cjs_1.execGit)(['merge-base', '--is-ancestor', stamp, head], { cwd });
2294
+ if (ancestry.exitCode === 0) {
2295
+ const r = (0, shell_command_projection_cjs_1.execGit)(['rev-list', '--count', `${stamp}..${head}`], { cwd });
2296
+ if (r.exitCode === 0) {
2297
+ const n = parseInt(r.stdout.trim(), 10);
2298
+ if (Number.isFinite(n)) {
2299
+ commitsBehind = n;
2300
+ // #2573 D4 — deliberately RAW, not thresholded. `commit_stale` means
2301
+ // exactly what its contract says: the codebase has moved since the
2302
+ // stamp. Applying an advisory threshold here would make the field lie
2303
+ // at n < threshold, and W024 needs the true count to threshold on.
2304
+ // Alarm-fatigue is handled at the ALARMING surface, not the
2305
+ // derivation: W024 (the only user-visible consumer) fires at
2306
+ // STATE_HEAD_ADVISORY_COMMITS, which absorbs the `commit_docs: true`
2307
+ // off-by-one. Smart-entry re-exports the raw tri-state as advisory
2308
+ // JSON and is not consumed by classify().
2309
+ commitStale = n > 0;
2310
+ }
2311
+ }
2312
+ }
2313
+ }
2314
+ return {
2315
+ state_head: stamp ? stamp.slice(0, 7) : null,
2316
+ current_commit: head ? head.slice(0, 7) : null,
2317
+ commits_behind: commitsBehind,
2318
+ commit_stale: commitStale,
2319
+ };
2320
+ }
2321
+ /**
2322
+ * #3354: read `progress.total_phases` out of already-extracted STATE.md
2323
+ * frontmatter as a finite number, or null. Feeds buildStateFrontmatter's
2324
+ * milestoned-but-unbounded withhold so the stored total survives the write
2325
+ * instead of being clobbered by the on-disk phase-directory count.
2326
+ */
2327
+ function readStoredTotalPhases(existingFm) {
2328
+ if (!existingFm || typeof existingFm !== 'object')
2329
+ return null;
2330
+ const progress = existingFm['progress'];
2331
+ if (!progress || typeof progress !== 'object')
2332
+ return null;
2333
+ const raw = progress['total_phases'];
2334
+ if (raw === null || raw === undefined)
2335
+ return null;
2336
+ if (typeof raw === 'string' && raw.trim() === '')
2337
+ return null;
2338
+ const n = Number(raw);
2339
+ return Number.isFinite(n) ? n : null;
2340
+ }
2341
+ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
1684
2342
  // Read existing frontmatter BEFORE stripping — it may contain values
1685
2343
  // that the body no longer has (e.g., Status field removed by an agent).
1686
2344
  // `cwd` already identifies the workspace this content came from, so the STATE.md path is
@@ -1691,7 +2349,11 @@ function syncStateFrontmatter(content, cwd, authoritativeFm) {
1691
2349
  // buildStateFrontmatter scopes its disk scan to the correct milestone
1692
2350
  // instead of auto-deriving (and potentially mis-binding).
1693
2351
  const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
1694
- const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone);
2352
+ // #3354: also pass the stored total so buildStateFrontmatter's
2353
+ // milestoned-but-unbounded withhold can preserve it across the write
2354
+ // (the derived progress sub-block replaces the stored one wholesale below,
2355
+ // so an omitted key would otherwise DELETE the stored value).
2356
+ const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
1695
2357
  // Preserve existing frontmatter status when body-derived status is 'unknown'.
1696
2358
  // This prevents a missing Status: field in the body from overwriting a
1697
2359
  // previously valid status (e.g., 'executing' → 'unknown').
@@ -1726,52 +2388,128 @@ function syncStateFrontmatter(content, cwd, authoritativeFm) {
1726
2388
  derivedFm['milestone'] = existingFm['milestone'];
1727
2389
  }
1728
2390
  }
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.
2391
+ // ADR-3408 §8.5 (D1): the six empty-only "#905" guards that used to live
2392
+ // here UNCONDITIONALLY are deleted for the write-seam pipeline
2393
+ // (`syncAndPreserveStateMd`, consumed by `readModifyWriteStateMd` and by
2394
+ // `cmdPhaseComplete`'s atomic-commit adapter). An empty derived value now
2395
+ // reaches `applyStatePreservation` unmolested, so the table-driven executor
2396
+ // — not a private copy inside this function — decides whether a curated
2397
+ // frontmatter value survives, and reports the decision via
2398
+ // `divergedFields` when it does. That was the actual D1 bug: these guards
2399
+ // ran BEFORE the executor ever saw the value, so a transform that
2400
+ // deliberately emptied a body line (delta CHANGED) lost silently — the
2401
+ // guard restored the stale frontmatter, the executor's own #1230 delta
2402
+ // check then found "already restored, nothing to do", and
2403
+ // `divergedFields` stayed empty even though a curated value had just won
2404
+ // over a genuine derived-empty.
1735
2405
  //
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']);
2406
+ // `writeStateMd`'s two callers — `cmdStateSync` and `/gsd-health --repair`'s
2407
+ // `REGENERATE_STATE` — are §8.3's closed, sanctioned-permanent exception
2408
+ // list: NEITHER ever runs `applyStatePreservation` afterward, because their
2409
+ // whole contract is "re-derive frontmatter FROM the body, body wins" (the
2410
+ // opposite of preservation). For them, these six conditions are the ONLY
2411
+ // mechanism that has ever kept a curated frontmatter value alive when the
2412
+ // body simply carries no annotation for a field at all (most STATE.md
2413
+ // files do not restate every field in body prose on every write) — losing
2414
+ // that would blank `current_phase_name` / `stopped_at` / etc. on every
2415
+ // `state sync`, which is a regression, not this phase's fix: `state sync`'s
2416
+ // output must stay byte-identical (ADR-3408 §8.3 Amendment 2). So the same
2417
+ // six conditions are kept, verbatim, but now gated behind the explicit
2418
+ // `sanctionedPermanentEmptyFallback` parameter — threaded ONLY from
2419
+ // `writeStateMd` — instead of running unconditionally or being duplicated
2420
+ // as a second private copy. This is still ONE enforcement point: the six
2421
+ // conditions exist in exactly one place in the source, selected by caller
2422
+ // identity per the closed §8.3 exception list, never re-derived elsewhere.
2423
+ //
2424
+ // The disagreeing case (a present-but-stale body value vs a fresher
2425
+ // frontmatter value, #948/#3374/§8.5) was never handled here even before
2426
+ // this change: it is governed by applyStatePreservation's
2427
+ // preserve-when-unchanged delta, applied post-sync by the shared
2428
+ // applyPostSyncPreservation pass.
2429
+ if (sanctionedPermanentEmptyFallback) {
2430
+ if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
2431
+ derivedFm['stopped_at'] = existingFm['stopped_at'];
2432
+ }
2433
+ if (!derivedFm['paused_at'] && existingFm['paused_at']) {
2434
+ derivedFm['paused_at'] = existingFm['paused_at'];
2435
+ }
2436
+ if (!derivedFm['current_phase'] && existingFm['current_phase']) {
2437
+ derivedFm['current_phase'] = existingFm['current_phase'];
2438
+ }
2439
+ if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
2440
+ derivedFm['current_phase_name'] = existingFm['current_phase_name'];
2441
+ }
2442
+ if (!derivedFm['current_plan'] && existingFm['current_plan']) {
2443
+ derivedFm['current_plan'] = existingFm['current_plan'];
2444
+ }
2445
+ // progress is a sub-object: fall back to existing only when the
2446
+ // body+disk scan produced NO progress block at all. When
2447
+ // buildStateFrontmatter did derive a progress block (even a lower one),
2448
+ // that derived value wins — the shouldPreserveExistingProgress
2449
+ // cross-milestone logic is applied later in cmdStateJson on the read
2450
+ // path where it is appropriate.
2451
+ if (!derivedFm['progress'] && existingFm['progress']) {
2452
+ derivedFm['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
2453
+ }
1766
2454
  }
1767
2455
  // #2202: carry forward any existing frontmatter key that the schema does not
1768
2456
  // own, so custom/unknown keys are not silently dropped on every mutating verb.
1769
2457
  // Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
1770
- // preserve guards above) still win.
2458
+ // sanctioned-permanent guards above, when they ran) still win.
1771
2459
  for (const key of Object.keys(existingFm)) {
1772
- if (!(key in derivedFm) && existingFm[key] !== undefined) {
1773
- derivedFm[key] = existingFm[key];
1774
- }
2460
+ if (key in derivedFm || existingFm[key] === undefined)
2461
+ continue;
2462
+ // #2573: a `source: 'free'` field is the writer's word on every write and
2463
+ // carries no preservation (see the FieldSource doc). When buildStateFrontmatter
2464
+ // omits it — `state_head` outside a git repo, per its `if (stateHead)` guard —
2465
+ // carrying the old value forward would re-assert provenance the file no longer
2466
+ // has: a stale state_head would claim STATE.md was written against a commit it
2467
+ // wasn't, contradicting its own ADR-1769 row.
2468
+ //
2469
+ // Narrow the skip to `source: 'free'`, NOT every `derive` row. `last_activity`
2470
+ // ({source:'body'}) and the `progress.*` rows ({source:'disk'}) are also
2471
+ // `derive`, but they are body/disk-sourced and MUST still carry forward when
2472
+ // the writer omits them this pass — dropping `last_activity` here is silent
2473
+ // frontmatter data loss and would defeat #2570's staleness fix downstream.
2474
+ // `last_updated` and `gsd_state_version` are the only other `free` rows and are
2475
+ // both produced unconditionally by buildStateFrontmatter, so this loop never
2476
+ // reaches them; `state_head` is the sole field the skip governs. Consult the
2477
+ // table rather than naming fields, so the policy stays single-sourced.
2478
+ const classification = stateTransitionMod.getFieldClassification(key);
2479
+ if (classification && classification.source === 'free')
2480
+ continue;
2481
+ // ADR-3408 §8.1/§8.5 (D1 follow-on — found by probe, not predicted by the
2482
+ // design): a `preserve-when-unchanged` / `preserve-always` field must be
2483
+ // decided ONLY by `applyStatePreservation` — the single enforcement point
2484
+ // — never by this generic carry-forward, on the write-seam path. Before
2485
+ // the six sanctioned-permanent guards above were gated behind
2486
+ // `sanctionedPermanentEmptyFallback` (D1), this loop's `key in derivedFm`
2487
+ // check was effectively always true for a field the guards had already
2488
+ // restored, so this branch was unreachable for it and the distinction
2489
+ // never mattered. With the guards now OFF on the write-seam path,
2490
+ // `derivedFm` genuinely lacks the key when the body carries no
2491
+ // annotation — and without this skip, this loop silently resurrects the
2492
+ // exact stale value the executor's delta rule (§8.5 Row 2) just decided
2493
+ // to discard, re-introducing the D1 bug through a second, unrelated code
2494
+ // path (confirmed live: an A5-shaped probe restored `current_phase_name`
2495
+ // via THIS loop even with the six guards deleted).
2496
+ //
2497
+ // Gated to the write-seam path ONLY (`!sanctionedPermanentEmptyFallback`)
2498
+ // — `writeStateMd`'s two sanctioned-permanent callers never run
2499
+ // `applyStatePreservation` at all, so unconditionally skipping here would
2500
+ // blank fields this loop has always carried forward for them (e.g.
2501
+ // `last_activity_desc`, which was never one of the six explicit guards
2502
+ // above but relied on THIS loop for its empty-case fallback), breaking
2503
+ // `state sync`'s required byte-identical output for a field D1 never
2504
+ // named. On the write-seam path this executor-only rule genuinely widens
2505
+ // beyond the original six fields (e.g. also covers `last_activity_desc`)
2506
+ // — a deliberate, in-scope consequence of "one enforcement point", not a
2507
+ // separate defect.
2508
+ if (!sanctionedPermanentEmptyFallback &&
2509
+ classification &&
2510
+ (classification.preservation === 'preserve-when-unchanged' || classification.preservation === 'preserve-always'))
2511
+ continue;
2512
+ derivedFm[key] = existingFm[key];
1775
2513
  }
1776
2514
  // #2567: guard the information-losing direction — a stale archive
1777
2515
  // "Last activity:" line must not overwrite a newer frontmatter value.
@@ -1790,6 +2528,11 @@ function syncStateFrontmatter(content, cwd, authoritativeFm) {
1790
2528
  }
1791
2529
  }
1792
2530
  }
2531
+ // #3257: propagate full-line frontmatter comments from the extracted source onto the
2532
+ // rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both
2533
+ // skip the Symbol-keyed channel, so without this the comments would be lost here even
2534
+ // though parseYamlRegion/reconstructFrontmatter preserve them in isolation).
2535
+ propagateCommentChannel(existingFm, derivedFm);
1793
2536
  const yamlStr = reconstructFrontmatter(derivedFm);
1794
2537
  return `---\n${yamlStr}\n---\n\n${body}`;
1795
2538
  }
@@ -2060,13 +2803,284 @@ function writeStateMd(statePath, content, cwd, clock) {
2060
2803
  // files that buildStateFrontmatter must see (#1967).
2061
2804
  if (cwd)
2062
2805
  _diskScanCache.delete(cwd);
2063
- const synced = syncStateFrontmatter(content, cwd);
2806
+ // ADR-3408 §8.3: `writeStateMd` is the sole write path for the two
2807
+ // sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`) —
2808
+ // pass `sanctionedPermanentEmptyFallback: true` so their long-standing
2809
+ // empty-field fallback behavior stays byte-identical (see
2810
+ // `syncStateFrontmatter`'s docstring above the guard block).
2811
+ const synced = syncStateFrontmatter(content, cwd, undefined, true);
2064
2812
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
2065
2813
  }
2066
2814
  finally {
2067
2815
  releaseStateLock(lockPath);
2068
2816
  }
2069
2817
  }
2818
+ /**
2819
+ * #3374: the shared post-sync preservation pass — the pre/post body-source
2820
+ * snapshot + table-driven `applyStatePreservation` + #2736 authoritative
2821
+ * re-assert sequence. Extracted from readModifyWriteStateMd so
2822
+ * `cmdPhaseComplete`'s atomic-commit adapter (phase.cts) — which syncs
2823
+ * STATE.md directly because it is committed atomically with
2824
+ * ROADMAP/REQUIREMENTS and so cannot go through the RMW wrapper — applies the
2825
+ * identical policy instead of a second, weaker encoding. Previously the
2826
+ * adapter had no preservation at all, letting a stale body `Stopped at:` line
2827
+ * silently clobber a fresher frontmatter `stopped_at` on every phase
2828
+ * completion (#3374 Variant A).
2829
+ *
2830
+ * NOT applied on the writeStateMd path: `state sync`'s contract is the
2831
+ * opposite by design (#905 — "body annotation beats existing frontmatter when
2832
+ * both are present": sync exists to re-derive frontmatter from the body), so a
2833
+ * blanket preservation pass there re-locks stale frontmatter. The
2834
+ * milestone-complete equivalent of the #3374 exposure is tracked as a
2835
+ * follow-up (see PR #3491 / the closed PR #3442 review's MAJOR finding).
2836
+ *
2837
+ * `originalContent` is the pre-write on-disk content (drives the #1230
2838
+ * pre-snapshots), `transformedContent` is the post-transform content (the
2839
+ * sync only rewrites the frontmatter block, so its body IS the post-write
2840
+ * body), and `syncedContent` is what `syncStateFrontmatter` produced.
2841
+ */
2842
+ /**
2843
+ * #3471 Fix: `StatePreservationOptions` is silently mis-consumable by any
2844
+ * non-TypeScript caller — `tsc` only type-checks src/, so a plain-.cjs test
2845
+ * (or any future JS caller) can pass a boolean where this options object
2846
+ * goes and both functions below would previously proceed with `resync`,
2847
+ * `authoritativeFm`, `deriveProgressKeys`, and `divergedFields` all
2848
+ * `undefined`, degrading to a well-formed-looking but silently-empty
2849
+ * `divergedFields: []` — exactly the "stale but present" failure shape
2850
+ * ADR-3408 exists to remove. This is a contract assertion (caller-shape
2851
+ * only), not field-level validation — mirrors `throwUnwiredRow`'s
2852
+ * structured-error shape in src/state-transition.cts.
2853
+ */
2854
+ function assertStatePreservationOptions(options, caller) {
2855
+ if (typeof options !== 'object' || options === null || Array.isArray(options)) {
2856
+ const err = new Error(`${caller}: options argument must be a StatePreservationOptions object, got ${typeof options === 'object' ? 'array/null' : typeof options}. ` +
2857
+ 'This function takes a single options object as its final ' +
2858
+ 'parameter, not positional resync/authoritativeFm/deriveProgressKeys/divergedFields arguments (#3471).');
2859
+ err.code = 'STATE_PRESERVATION_OPTIONS_INVALID';
2860
+ err.receivedType = Array.isArray(options) ? 'array' : typeof options;
2861
+ throw err;
2862
+ }
2863
+ }
2864
+ function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options) {
2865
+ assertStatePreservationOptions(options, 'applyPostSyncPreservation');
2866
+ const { resync, authoritativeFm, deriveProgressKeys, divergedFields } = options;
2867
+ // Snapshot the existing progress block BEFORE the transform so we can
2868
+ // restore it when resync is false.
2869
+ const preFm = resync ? null : extractFrontmatter(originalContent, statePath);
2870
+ // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
2871
+ // we can detect whether THIS write changed them. syncStateFrontmatter
2872
+ // re-derives frontmatter status/stopped_at from the body on every write;
2873
+ // when the body's source field was NOT changed by the transform, the
2874
+ // existing frontmatter value (e.g. a hand-set 'completed') must win over
2875
+ // the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
2876
+ // Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
2877
+ // above (null when resync:true) — these are independent snapshots.
2878
+ // Strip frontmatter before calling stateExtractField so the YAML `status:`
2879
+ // key in the frontmatter block cannot shadow the body field we are tracking.
2880
+ const preBody = stripFrontmatter(originalContent);
2881
+ const preFmSnapshot = extractFrontmatter(originalContent, statePath);
2882
+ const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
2883
+ // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
2884
+ // mirroring buildStateFrontmatter's sessionBodyScope logic.
2885
+ // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
2886
+ // Archive prose) must not interfere with the delta comparison.
2887
+ const preSessionMatch = matchSessionSection(preBody);
2888
+ const preSessionScope = preSessionMatch ?? preBody;
2889
+ const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
2890
+ // ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
2891
+ // current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
2892
+ // this write does NOT change that line, the curated frontmatter value must
2893
+ // win over syncStateFrontmatter's body re-derivation (which can harvest a
2894
+ // wrong parenthetical aside — #1695). Gated by the field-classification
2895
+ // table's preserve-always row so the rule lives in one place.
2896
+ const preBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(preBody, 'Phase');
2897
+ // #3258: snapshot the body sources for the additional preserve-when-unchanged
2898
+ // rows applyStatePreservation now honors (last_activity_desc, paused_at,
2899
+ // current_phase, current_plan). Each mirrors buildStateFrontmatter's
2900
+ // derivation so the #1230 delta ("did THIS write change the source?") is
2901
+ // accurate: current_phase combines `Current Phase` with the prose `Phase:`
2902
+ // fallback (parseProsePhaseField, scoped to ## Current Position); paused_at
2903
+ // is session-scoped (mirrors stopped_at); last_activity_desc combines the
2904
+ // `Last Activity Description` field with the prose desc fallback.
2905
+ const preCurrentPositionScope = matchCurrentPositionSection(preBody) ?? preBody;
2906
+ const preBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Plan');
2907
+ const preBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Phase')
2908
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase')).phase;
2909
+ const preBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Paused At');
2910
+ const preBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity')
2911
+ ?? (0, state_document_cjs_1.stateExtractField)(preBody, 'Last activity');
2912
+ const preBodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity Description')
2913
+ ?? parseProseLastActivityField(preBodyLastActivityRaw).description;
2914
+ // Post-transform body source fields used for the delta comparison (#1230).
2915
+ // Use `transformedContent` (not `syncedContent`): syncStateFrontmatter only
2916
+ // rewrites the frontmatter block, so the body is identical in both — and we
2917
+ // need the body the transform produced. Strip frontmatter so the YAML
2918
+ // status key cannot shadow the body field we are tracking.
2919
+ const postBody = stripFrontmatter(transformedContent);
2920
+ const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
2921
+ // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
2922
+ // consistent with the pre-transform snapshot above and buildStateFrontmatter.
2923
+ const postSessionMatch = matchSessionSection(postBody);
2924
+ const postSessionScope = postSessionMatch ?? postBody;
2925
+ const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
2926
+ // ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
2927
+ // current_phase_name delta comparison.
2928
+ const postBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(postBody, 'Phase');
2929
+ // #3258: post-transform body sources for the preserve-when-unchanged rows
2930
+ // added in #3258 (mirrors the pre-transform block above).
2931
+ const postCurrentPositionScope = matchCurrentPositionSection(postBody) ?? postBody;
2932
+ const postBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Plan');
2933
+ const postBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Phase')
2934
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase')).phase;
2935
+ const postBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Paused At');
2936
+ const postBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity')
2937
+ ?? (0, state_document_cjs_1.stateExtractField)(postBody, 'Last activity');
2938
+ const postBodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity Description')
2939
+ ?? parseProseLastActivityField(postBodyLastActivityRaw).description;
2940
+ // #3468: single channel for every preserve-when-unchanged row. Before this
2941
+ // change, seven body-source pre/post pairs travelled in two different
2942
+ // shapes — this map for four fields, six dedicated parameters
2943
+ // (preBodyStatus/postBodyStatus, preBodyStoppedAt/postBodyStoppedAt,
2944
+ // preBodyPhaseSource/postBodyPhaseSource) for the other three — same data,
2945
+ // same purpose, which is exactly why applyStatePreservation needed a
2946
+ // hand-written branch per field instead of one loop over the table. Every
2947
+ // row FIELD_CLASSIFICATION declares preserve-when-unchanged MUST appear
2948
+ // here — an omission now throws (STATE_PRESERVATION_UNWIRED_ROW, ADR-3408
2949
+ // §8.2) at the first write rather than becoming a quiet preservation bug.
2950
+ // Note current_phase_name's source is the body `Phase:` line, deliberately
2951
+ // a DIFFERENT source from current_phase's: the key names the field the
2952
+ // policy GUARDS, not the body field it reads.
2953
+ const bodyDeltas = {
2954
+ last_activity_desc: { pre: preBodyLastActivityDesc, post: postBodyLastActivityDesc },
2955
+ paused_at: { pre: preBodyPausedAt, post: postBodyPausedAt },
2956
+ current_phase: { pre: preBodyCurrentPhase, post: postBodyCurrentPhase },
2957
+ current_plan: { pre: preBodyCurrentPlan, post: postBodyCurrentPlan },
2958
+ status: { pre: preBodyStatus, post: postBodyStatus },
2959
+ stopped_at: { pre: preBodyStoppedAt, post: postBodyStoppedAt },
2960
+ current_phase_name: { pre: preBodyPhaseSource, post: postBodyPhaseSource },
2961
+ };
2962
+ // ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
2963
+ // preservation block is now the pure, table-driven `applyStatePreservation`
2964
+ // in the STATE.md Transition Module. progress / status / stopped_at /
2965
+ // current_phase_name are all governed by their FIELD_CLASSIFICATION row —
2966
+ // one policy source, not three drifting encodings. #3258 extends the same
2967
+ // pass to last_activity_desc / paused_at / current_phase / current_plan
2968
+ // (preserve-when-unchanged) and milestone / milestone_name (preserve-if-
2969
+ // placeholder). Behavior-identical to the pre-#1796 inline block for the
2970
+ // original four fields; this is the absorption ADR-1769 / CONTEXT.md
2971
+ // already claimed shipped.
2972
+ const postFm = extractFrontmatter(syncedContent, statePath);
2973
+ // #3469 (ADR-3408 §8.5): snapshot the freshly-synced (pre-preservation)
2974
+ // frontmatter so a caller that wants visibility into "did preservation
2975
+ // restore a curated value over a disagreeing derived one" can diff against
2976
+ // it via the optional `divergedFields` out-param below. Additive only:
2977
+ // callers that omit it (readModifyWriteStateMd, cmdPhaseComplete) pay
2978
+ // nothing extra and see no change to `synced`/the returned content.
2979
+ const preservationInputSnapshot = divergedFields ? { ...postFm } : null;
2980
+ const preservation = applyStatePreservation({
2981
+ preFm, postFm, preFmSnapshot, resync,
2982
+ deriveProgressKeys: deriveProgressKeys === true,
2983
+ bodyDeltas,
2984
+ });
2985
+ if (divergedFields && preservationInputSnapshot) {
2986
+ // §8.5's "liberal but visible": every field whose value actually
2987
+ // differs before vs after `applyStatePreservation` is a field where the
2988
+ // curated (frontmatter) value won over a disagreeing freshly-derived
2989
+ // one — regardless of which policy executor fired. Diffing the object
2990
+ // (rather than special-casing which executor mutated it) is intentional:
2991
+ // it stays correct if a future FIELD_CLASSIFICATION row adds a new
2992
+ // preservation policy without this function needing to know about it.
2993
+ for (const key of Object.keys(preservation.postFm)) {
2994
+ const before = preservationInputSnapshot[key];
2995
+ const after = preservation.postFm[key];
2996
+ const changed = (typeof before === 'object' || typeof after === 'object')
2997
+ ? JSON.stringify(before) !== JSON.stringify(after)
2998
+ : before !== after;
2999
+ if (changed)
3000
+ divergedFields.push(key);
3001
+ }
3002
+ // ADR-3408 §8.5 Row 2 (D1's actual bug, the reason the guards had to be
3003
+ // deleted rather than merely relocated): the loop above can only see a
3004
+ // field that `applyStatePreservation` itself RESTORED — it diffs
3005
+ // `postFm` before vs after the executor ran, and `preserve-when-unchanged`
3006
+ // never adds an absent key back when the body source changed this write
3007
+ // (the delta rule correctly lets the empty derived value win, so `postFm`
3008
+ // never gains the key at all). That means a curated value can vanish —
3009
+ // deliberately, per policy — with NOTHING in the loop above to report it.
3010
+ // "Liberal but visible" requires the discard itself to be named, not just
3011
+ // a restore. Scoped to exactly the fields `bodyDeltas` tracks
3012
+ // (preserve-when-unchanged rows only — `preserve-always`/`progress` and
3013
+ // `preserve-if-placeholder`/`milestone*` are unaffected by the delta rule
3014
+ // and already fully covered by the restore-diff loop above).
3015
+ for (const [field, delta] of Object.entries(bodyDeltas)) {
3016
+ if (divergedFields.includes(field))
3017
+ continue; // already reported as a restore above
3018
+ const before = preFmSnapshot[field];
3019
+ const beforeIsReal = typeof before === 'string' && before.trim().length > 0;
3020
+ if (!beforeIsReal)
3021
+ continue; // nothing curated existed to discard
3022
+ if (delta.pre === delta.post)
3023
+ continue; // body source unchanged — governed by the restore branch, not the discard rule
3024
+ const after = preservation.postFm[field];
3025
+ const afterIsEmpty = after === undefined || after === null
3026
+ || (typeof after === 'string' && after.trim().length === 0);
3027
+ if (afterIsEmpty)
3028
+ divergedFields.push(field);
3029
+ }
3030
+ }
3031
+ // #2736: re-assert the intent-first values AFTER preservation. On STATE.md
3032
+ // layouts with no body `Phase:` line, both phase-source snapshots are null
3033
+ // (equal), so the #1695 restore fires and would put the stale pre-transition
3034
+ // name back over the authoritative one. Intent beats both the prose
3035
+ // re-derivation and the curated restore — the transition just resolved it.
3036
+ let authoritativeReasserted = false;
3037
+ if (authoritativeFm) {
3038
+ for (const [key, value] of Object.entries(authoritativeFm)) {
3039
+ if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) {
3040
+ preservation.postFm[key] = value;
3041
+ authoritativeReasserted = true;
3042
+ }
3043
+ }
3044
+ }
3045
+ if (preservation.mutated || authoritativeReasserted) {
3046
+ const yamlStr = reconstructFrontmatter(preservation.postFm);
3047
+ const body = stripFrontmatter(syncedContent);
3048
+ return `---\n${yamlStr}\n---\n\n${body}`;
3049
+ }
3050
+ return syncedContent;
3051
+ }
3052
+ /**
3053
+ * ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
3054
+ * `applyPostSyncPreservation`, as a single named `content -> content`
3055
+ * function. Every STATE.md write that (a) is not one of the two sanctioned-
3056
+ * permanent exceptions (`cmdStateSync`, `REGENERATE_STATE` — §8.3's closed
3057
+ * exception list, ADR Amendment 2) and (b) needs a non-standard I/O envelope
3058
+ * calls THIS — never `syncStateFrontmatter` + `applyPostSyncPreservation`
3059
+ * assembled locally. §8.3: "Assembling the stages at a call site is a
3060
+ * re-derivation even when every step calls the owner." Phase 2 (#3469) found
3061
+ * exactly that shape live in `cmdPhaseComplete`'s atomic-commit adapter
3062
+ * (phase.cts) — every step called an owner, so the drift guard and an
3063
+ * owner-level test both stayed green while the composition itself was free
3064
+ * to diverge from `readModifyWriteStateMd`'s.
3065
+ *
3066
+ * Both current non-RMW callers of the pair — `readModifyWriteStateMd` and
3067
+ * `cmdPhaseComplete`'s atomic 3-file commit adapter — now call this instead
3068
+ * of assembling the two stages themselves. `cmdMilestoneComplete` (the
3069
+ * #3374-shaped exposure `applyPostSyncPreservation`'s own docstring flagged
3070
+ * as a follow-up) is the third.
3071
+ *
3072
+ * Returns CONTENT ONLY — a caller that needs its own I/O envelope (a lock,
3073
+ * an atomic multi-file commit) supplies it around this call; this function
3074
+ * never takes over the write.
3075
+ *
3076
+ * `divergedFields` is passed straight through to `applyPostSyncPreservation`
3077
+ * — see its own docstring.
3078
+ */
3079
+ function syncAndPreserveStateMd(originalContent, transformedContent, statePath, cwd, options) {
3080
+ assertStatePreservationOptions(options, 'syncAndPreserveStateMd');
3081
+ const synced = syncStateFrontmatter(transformedContent, cwd, options.authoritativeFm);
3082
+ return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options);
3083
+ }
2070
3084
  /**
2071
3085
  * Atomic read-modify-write for STATE.md.
2072
3086
  * Holds the lock across the entire read -> transform -> write cycle,
@@ -2092,36 +3106,6 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
2092
3106
  const lockPath = acquireStateLock(statePath, clock);
2093
3107
  try {
2094
3108
  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
3109
  const modified = transformFn(content);
2126
3110
  // Bug #948: no-op guard — if the transform produced no change, do NOT write
2127
3111
  // the file. An unconditional write would bump `last_updated`, reset
@@ -2133,54 +3117,17 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
2133
3117
  if (modified === content) {
2134
3118
  return false;
2135
3119
  }
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,
3120
+ // #3469 (ADR-3408 §8.3): sync + post-sync preservation is the single
3121
+ // owned composition (`syncAndPreserveStateMd`), not assembled here — this
3122
+ // call site and `cmdPhaseComplete`'s atomic-commit adapter both route
3123
+ // through the same function so the composition cannot diverge between
3124
+ // the two.
3125
+ const synced = syncAndPreserveStateMd(content, modified, statePath, cwd, {
3126
+ resync,
3127
+ authoritativeFm: options?.authoritativeFm,
2160
3128
  deriveProgressKeys: options?.deriveProgressKeys === true,
2161
- preBodyStatus, postBodyStatus,
2162
- preBodyStoppedAt, postBodyStoppedAt,
2163
- preBodyPhaseSource, postBodyPhaseSource,
3129
+ divergedFields: options?.divergedFields,
2164
3130
  });
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
3131
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
2185
3132
  return true;
2186
3133
  }
@@ -2188,6 +3135,162 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
2188
3135
  releaseStateLock(lockPath);
2189
3136
  }
2190
3137
  }
3138
+ /**
3139
+ * ADR-3408 §8.4/§8.5 (D4): frontmatter field name → the body Title-Case
3140
+ * label the `updated` arrays below use. Every `preserve-when-unchanged` row
3141
+ * in `FIELD_CLASSIFICATION` MUST have an entry here (pinned by a parity test,
3142
+ * #3471 review) — `reconcileReportedFields` consults this so a preservation
3143
+ * event on `current_phase_name` folds into a report that otherwise only ever
3144
+ * speaks in body labels like `Current Phase Name` (#3345's direction). A
3145
+ * `preserve-when-unchanged` field missing here is a table drift bug and
3146
+ * `bodyLabelFor` throws rather than silently degrading to the raw
3147
+ * snake_case key (#3471 review — this is a second hand-maintained table
3148
+ * parallel to `FIELD_CLASSIFICATION`, so an unwired row must fail as loudly
3149
+ * as `throwUnwiredRow` in `state-transition.cts` does for the same shape of
3150
+ * omission). `preserve-always`/`preserve-if-placeholder` fields (`progress`,
3151
+ * `milestone`, `milestone_name`) are deliberately absent — `divergedFields`
3152
+ * (ADR-3408 §8.5's out-param) is NOT scoped to `preserve-when-unchanged`
3153
+ * rows alone (see `applyPostSyncPreservation`'s "regardless of which policy
3154
+ * executor fired" diff), so those fields legitimately reach the lookup with
3155
+ * no body-line label to report — `progress` is a structured sub-object and
3156
+ * `milestone`/`milestone_name` version/name pairs, neither ever rendered as
3157
+ * a body prose line — and `bodyLabelFor` falls through to the raw key for
3158
+ * exactly that closed, tested set (`tests/state.test.cjs` A2f pins
3159
+ * `divergedFields` reporting bare `'progress'`).
3160
+ */
3161
+ const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze({
3162
+ current_phase: 'Current Phase',
3163
+ current_phase_name: 'Current Phase Name',
3164
+ current_plan: 'Current Plan',
3165
+ stopped_at: 'Stopped At',
3166
+ paused_at: 'Paused At',
3167
+ status: 'Status',
3168
+ last_activity_desc: 'Last Activity Description',
3169
+ });
3170
+ /**
3171
+ * ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
3172
+ * entry. Throws for a `preserve-when-unchanged` field with no
3173
+ * `FRONTMATTER_KEY_TO_BODY_LABEL` row — that combination can only happen if
3174
+ * a future row is added to `FIELD_CLASSIFICATION` without a matching label,
3175
+ * an internal table-drift bug, never a user-document defect (mirrors
3176
+ * `throwUnwiredRow`'s shape in `state-transition.cts`: an `Error` carrying
3177
+ * `code` and `field` own-properties). Falls through to the raw field name
3178
+ * for every other policy (`preserve-always`, `preserve-if-placeholder`) —
3179
+ * those fields were never claimed to have a body-line label and reaching
3180
+ * this lookup with one of them is the documented, tested, working case
3181
+ * (e.g. `progress`), not a silent degrade.
3182
+ */
3183
+ function bodyLabelFor(field) {
3184
+ const label = FRONTMATTER_KEY_TO_BODY_LABEL[field];
3185
+ if (label !== undefined)
3186
+ return label;
3187
+ const cls = stateTransitionMod.getFieldClassification(field);
3188
+ if (cls && cls.preservation === 'preserve-when-unchanged') {
3189
+ const err = new Error(`reconcileReportedFields: preserve-when-unchanged field ${JSON.stringify(field)} has no ` +
3190
+ 'FRONTMATTER_KEY_TO_BODY_LABEL entry. This is an internal invariant violation (ADR-3408 ' +
3191
+ '§8.4/D4) — add a label for this field to FRONTMATTER_KEY_TO_BODY_LABEL.');
3192
+ err.code = 'STATE_BODY_LABEL_UNWIRED_ROW';
3193
+ err.field = field;
3194
+ throw err;
3195
+ }
3196
+ return field;
3197
+ }
3198
+ /**
3199
+ * ADR-3408 §8.4 (D4): shared persisted-bytes reconciliation, generalized
3200
+ * from fix(#3351)'s `cmdStatePatch`-only version so every RMW-based command
3201
+ * that reports a per-field `updated` array shares ONE comparison instead of
3202
+ * re-deriving it per call site — duplicated policy is exactly what this
3203
+ * epic exists to remove.
3204
+ *
3205
+ * Closes BOTH directions:
3206
+ * - **#3351's** (reported-but-discarded): a field the transform's own
3207
+ * return value held a value for, that sync/preservation then discarded
3208
+ * or overwrote before the file was saved, must NOT be reported.
3209
+ * - **#3345's** (persisted-but-unreported): a field `applyStatePreservation`
3210
+ * restored that the transform never touched at all must still be
3211
+ * reported — preservation can mutate a field the pre-sync intent never
3212
+ * knew about.
3213
+ *
3214
+ * @param preSyncContent The transformFn's OWN return value — the content
3215
+ * BEFORE `syncAndPreserveStateMd` ran this write — captured by the caller
3216
+ * inside its own `readModifyWriteStateMd` callback. Comparing that against
3217
+ * the actual bytes on disk after the full write pipeline settled is what
3218
+ * makes the report reflect what POST-sync bytes hold (ADR-3408 §8.4),
3219
+ * not what the pre-sync intent merely hoped for.
3220
+ * @param reported The candidate field names — the transform's OWN
3221
+ * success list (e.g. `beginPhaseCore`'s `updated`), never a raw intent
3222
+ * list the transform might not have actually matched. Body Title-Case
3223
+ * labels (`Status`, `Current Plan`) and frontmatter keys are both valid;
3224
+ * each is looked up as a frontmatter key first, else as a body field —
3225
+ * the same fallback chain `cmdStatePatch` used before this generalization.
3226
+ * @param divergedFields Frontmatter field names `applyStatePreservation`
3227
+ * actually restored during this write (ADR-3408 §8.5's out-param).
3228
+ */
3229
+ function reconcileReportedFields(statePath, preSyncContent, reported, divergedFields) {
3230
+ const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
3231
+ const persistedFm = extractFrontmatter(persisted, statePath);
3232
+ const persistedBody = stripFrontmatter(persisted);
3233
+ const preFm = extractFrontmatter(preSyncContent, statePath);
3234
+ const preBody = stripFrontmatter(preSyncContent);
3235
+ // #3471 review: body-FIRST, frontmatter-fallback — mirrors the actual write
3236
+ // precedence `patchCore`/`updateCore` apply (their own docstrings: "a key
3237
+ // that resolves against the STRIPPED body ... wins deterministically even
3238
+ // when the same key also happens to exist as a parsed frontmatter key").
3239
+ // The prior frontmatter-first order was silently correct for every
3240
+ // Title-Case body label (`Status`, `Current Plan`, ...) only because those
3241
+ // never case-exact-match a frontmatter key (frontmatter keys are always
3242
+ // lowercase snake_case) — so `hasOwnProperty` always missed and it fell
3243
+ // through to the body anyway. It broke the one case where `field` IS
3244
+ // lowercase and DOES exact-match a frontmatter key: a table-format
3245
+ // STATE.md with a lowercase field name (e.g. `state update status ...`
3246
+ // against `| status | ... |`). There, `preFm` (extracted from the
3247
+ // transform's own pre-sync output) never has a `status` key yet — but
3248
+ // `persistedFm` (extracted after `syncStateFrontmatter` ran) always does,
3249
+ // since `status` is a schema-owned frontmatter key re-derived on every
3250
+ // write. Reading frontmatter first made `intended` (body text) and
3251
+ // `persistedValue` (frontmatter-derived enum) compare two different
3252
+ // representations of the same field, and the write was never reconciled
3253
+ // (regression: #1162's "state update is case-insensitive for table field
3254
+ // names").
3255
+ const valueOf = (fm, body, field) => {
3256
+ const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
3257
+ if (bodyValue !== null)
3258
+ return bodyValue;
3259
+ return Object.prototype.hasOwnProperty.call(fm, field) ? String(fm[field]) : null;
3260
+ };
3261
+ const reconciled = [];
3262
+ for (const field of reported) {
3263
+ const intended = valueOf(preFm, preBody, field);
3264
+ const persistedValue = valueOf(persistedFm, persistedBody, field);
3265
+ if (intended !== null && intended.trim() === (persistedValue ?? '').trim()) {
3266
+ reconciled.push(field);
3267
+ }
3268
+ }
3269
+ // #3471 review: only fold a `divergedFields` entry into the reported array
3270
+ // when it is a `preserve-when-unchanged` row (has a genuine body-line
3271
+ // label — Status, Current Plan, Current Phase, ...). #3345's direction
3272
+ // ("preservation restored a field the intent never named") is about a
3273
+ // caller-visible BODY field the transform could plausibly have named —
3274
+ // never about `progress` (`preserve-always`) or `milestone`/`milestone_name`
3275
+ // (`preserve-if-placeholder`), which are structured/paired fields no
3276
+ // caller ever names via a per-field body label and whose restoration is
3277
+ // the long-standing, silent #3242/#948 protection, not a caller-visible
3278
+ // "update". Folding them in unconditionally reported `progress` as
3279
+ // `updated` on every `state.patch`/`state.update` write that happened to
3280
+ // preserve it — even when the call never touched Current Phase's
3281
+ // curated-progress-preserving field at all (regression: #1264's
3282
+ // `state.patch` of `Current Phase` reporting `updated: ['Current Phase',
3283
+ // 'progress']` instead of `['Current Phase']`).
3284
+ for (const field of divergedFields) {
3285
+ const cls = stateTransitionMod.getFieldClassification(field);
3286
+ if (!cls || cls.preservation !== 'preserve-when-unchanged')
3287
+ continue;
3288
+ const label = bodyLabelFor(field);
3289
+ if (!reconciled.includes(label))
3290
+ reconciled.push(label);
3291
+ }
3292
+ return reconciled;
3293
+ }
2191
3294
  function cmdStateJson(cwd, raw) {
2192
3295
  const statePath = planningPaths(cwd).state;
2193
3296
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -2200,28 +3303,67 @@ function cmdStateJson(cwd, raw) {
2200
3303
  // Always rebuild from body + disk so progress counters reflect current state.
2201
3304
  // Returning cached frontmatter directly causes stale percent/completed_plans
2202
3305
  // 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'];
3306
+ // #3354: pass the stored total so the milestoned-but-unbounded withhold can
3307
+ // report the preserved value instead of omitting the key.
3308
+ // #3573: pass the STORED MILESTONE too (same parity reasoning) — otherwise the
3309
+ // roadmap-absent withhold never fires on this read surface and `state json`
3310
+ // reports the phase-directory count while the persisted file preserves the
3311
+ // stored total, exactly the write/read divergence #3354 closed for its shape.
3312
+ const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
3313
+ const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
3314
+ // ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
3315
+ // current_phase_name / current_plan through the SAME `preserve-when-unchanged`
3316
+ // executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
3317
+ // third private copy of the empty-only guards with no delta/staleness check
3318
+ // at all — the shape that let a stale-but-present body annotation always
3319
+ // beat a fresher curated frontmatter value in `state json` output (#3395's
3320
+ // shape outside the write seam).
3321
+ //
3322
+ // `cmdStateJson` never writes — it is one snapshot read, not a
3323
+ // before/after transform — so "did THIS write change the body source"
3324
+ // (the #1230 delta the executor consults) is definitionally "no": every
3325
+ // field's body source is passed as its own delta pre/post pair (the same
3326
+ // value twice). That is what makes the executor's rule resolve to
3327
+ // "restore the curated value whenever a real one exists" here — exactly
3328
+ // §8.5's "same terms as an empty derived value" extended to a present
3329
+ // one, i.e. the exact D3 fix. Deliberately scoped to only these six
3330
+ // fields (not the full `applyStatePreservation` dispatch loop): `progress`
3331
+ // (preserve-always) keeps its own `shouldPreserveExistingProgress`
3332
+ // cross-milestone rule below — a DIFFERENT policy that must survive this
3333
+ // change untouched — and `milestone`/`milestone_name`
3334
+ // (preserve-if-placeholder) are out of D3's scope entirely.
3335
+ if (existingFm) {
3336
+ const sessionScope = matchSessionSection(body) ?? body;
3337
+ const positionScope = matchCurrentPositionSection(body) ?? body;
3338
+ const bodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped at');
3339
+ const bodyPausedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
3340
+ const bodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(body, 'Phase');
3341
+ const bodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase')
3342
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
3343
+ const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
3344
+ const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
3345
+ const unchanged = (v) => ({ pre: v, post: v });
3346
+ const ctx = {
3347
+ preFm: null,
3348
+ postFm: built,
3349
+ preFmSnapshot: existingFm,
3350
+ resync: true,
3351
+ deriveProgressKeys: false,
3352
+ bodyDeltas: {
3353
+ status: unchanged(bodyStatus),
3354
+ stopped_at: unchanged(bodyStoppedAt),
3355
+ paused_at: unchanged(bodyPausedAt),
3356
+ current_phase: unchanged(bodyCurrentPhase),
3357
+ current_plan: unchanged(bodyCurrentPlan),
3358
+ current_phase_name: unchanged(bodyPhaseSource),
3359
+ },
3360
+ mutated: false,
3361
+ };
3362
+ for (const field of ['status', 'stopped_at', 'paused_at', 'current_phase', 'current_plan', 'current_phase_name']) {
3363
+ const cls = stateTransitionMod.getFieldClassification(field);
3364
+ if (cls)
3365
+ stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
3366
+ }
2225
3367
  }
2226
3368
  // Preserve curated cross-milestone aggregates when local disk scanning sees
2227
3369
  // only a narrower realized subset (#3242 Bug A). Stale lower counters still
@@ -2269,13 +3411,27 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
2269
3411
  // that itself contains a parenthetical. The #1695 delta-gate preservation
2270
3412
  // still runs after the sync; the override is re-asserted after it inside
2271
3413
  // readModifyWriteStateMd for layouts with no body `Phase:` line.
3414
+ const divergedFields = [];
2272
3415
  const rmwOptions = {
2273
3416
  authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
3417
+ divergedFields,
2274
3418
  };
2275
- let updated = [];
3419
+ let precomputedUpdated = [];
3420
+ let preSyncContent = '';
3421
+ // #3311: begin-phase is the claim point — it is the one Current Position
3422
+ // transition that explicitly names its phase, so it both records this
3423
+ // session's claim and detects a conflicting live claim for a different
3424
+ // phase. The check runs INSIDE the STATE.md lock so concurrent begin-phase
3425
+ // calls cannot both read "no claim" and both write.
3426
+ let milestoneConflict = null;
2276
3427
  readModifyWriteStateMd(statePath, (content) => {
3428
+ milestoneConflict = milestoneLockMod.claimMilestonePhase(cwd, String(phaseNumber));
3429
+ if (milestoneConflict) {
3430
+ milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
3431
+ }
2277
3432
  const result = transitionCore(content, intent, deps);
2278
- updated = result.updated;
3433
+ precomputedUpdated = result.updated;
3434
+ preSyncContent = result.content;
2279
3435
  // #3127 resume: the core preserved the mid-flight Current Phase Name, so
2280
3436
  // the intent-first override must not fire — it would drift frontmatter
2281
3437
  // away from the preserved body value. Dropping it here is safe because
@@ -2285,7 +3441,12 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
2285
3441
  }
2286
3442
  return result.content;
2287
3443
  }, cwd, rmwOptions);
2288
- output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
3444
+ // ADR-3408 §8.4 (D4): reconcile `beginPhaseCore`'s own success list against
3445
+ // the bytes actually persisted (fix(#3351) generalized) and fold in any
3446
+ // field preservation restored that this transform never touched (#3345's
3447
+ // direction).
3448
+ const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
3449
+ output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, milestone_conflict: milestoneConflict }, raw, updated.length > 0 ? 'true' : 'false');
2289
3450
  }
2290
3451
  /**
2291
3452
  * Write a WAITING.json signal file when GSD hits a decision point.
@@ -2392,7 +3553,7 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
2392
3553
  // direction (#1659): canonicalize a numeric phase to its integer form so a seeded
2393
3554
  // "| 05 |" row is upserted (not duplicated) by `phase complete 5`, and vice-versa.
2394
3555
  const phaseNumStr = String(phaseNum);
2395
- const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : escapeRegex(phaseNumStr);
3556
+ const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : (0, pattern_cjs_1.escapeRegex)(phaseNumStr);
2396
3557
  const phaseCellRe = new RegExp(`^${canonCell}$`, 'i');
2397
3558
  const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
2398
3559
  const before = content.slice(0, tableStart);
@@ -2508,7 +3669,7 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
2508
3669
  * Gate 3a: Record state after plan-phase completes.
2509
3670
  * Updates Status to "Ready to execute", Total Plans, Last Activity.
2510
3671
  */
2511
- function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
3672
+ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
2512
3673
  const statePath = planningPaths(cwd).state;
2513
3674
  if (!node_fs_1.default.existsSync(statePath)) {
2514
3675
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -2525,18 +3686,40 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
2525
3686
  const intent = {
2526
3687
  kind: 'plannedPhase',
2527
3688
  phaseNumber,
3689
+ phaseName: phaseName ?? null,
2528
3690
  planCount: planCount ?? null,
2529
3691
  };
2530
3692
  const deps = {
2531
3693
  clock: clock_cjs_1.realClock,
2532
3694
  sourcePath: statePath,
2533
3695
  };
2534
- let updated = [];
3696
+ // #3395 / #2736: the transition holds the exact display name. plannedPhaseCore
3697
+ // writes it into the Current Position `Phase: N (Name) — READY TO EXECUTE`
3698
+ // line, and the prose re-derivation of current_phase_name truncates names
3699
+ // that themselves contain a parenthetical — the authoritative override keeps
3700
+ // the exact value, exactly as cmdStateBeginPhase does for its EXECUTING line.
3701
+ const divergedFields = [];
3702
+ const rmwOptions = {
3703
+ resync: false,
3704
+ deriveProgressKeys: true,
3705
+ authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
3706
+ divergedFields,
3707
+ };
3708
+ let precomputedUpdated = [];
3709
+ let preSyncContent = '';
2535
3710
  readModifyWriteStateMd(statePath, (content) => {
2536
3711
  const result = transitionCore(content, intent, deps);
2537
- updated = result.updated;
3712
+ precomputedUpdated = result.updated;
3713
+ preSyncContent = result.content;
2538
3714
  return result.content;
2539
- }, cwd, { resync: false, deriveProgressKeys: true });
3715
+ }, cwd, rmwOptions);
3716
+ // ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
3717
+ // against the bytes actually persisted (fix(#3351) generalized) and fold
3718
+ // in any field preservation restored that this transform never touched
3719
+ // (#3345's direction) — traced for this phase (design doc: "not traced in
3720
+ // the analysis pass") and found to need exactly the same treatment as
3721
+ // `cmdStateBeginPhase`.
3722
+ const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
2540
3723
  const result = updated.length === 0
2541
3724
  ? { 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
3725
  : { updated, phase: phaseNumber, plan_count: planCount };
@@ -2576,8 +3759,98 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
2576
3759
  }
2577
3760
  /**
2578
3761
  * Gate 1: Validate STATE.md against filesystem.
2579
- * Returns { valid, warnings, drift } JSON.
3762
+ * Returns { valid, warnings, drift, scope } JSON.
3763
+ *
3764
+ * #3187 (ADR-3180 §7.7, Decisions 2-4): two defects fixed here.
3765
+ *
3766
+ * (1) #3162 THE HEADLINE. Every warning this function can emit used to be
3767
+ * gated behind `if (currentPhase && fs.existsSync(phasesDir))`, and
3768
+ * `currentPhase` came from a body-only `stateExtractField(content, 'Current
3769
+ * Phase')` call with no frontmatter fallback. A STATE.md whose phase lives
3770
+ * ONLY in frontmatter therefore resolved `currentPhase` to `null`, the whole
3771
+ * drift block was skipped, and the function returned
3772
+ * `{valid:true, warnings:[], drift:{}}` — "could not look" was
3773
+ * output-identical to "looked, all clean." Current Phase / Status / Total
3774
+ * Plans in Phase now route through `stateFieldValue` (the single owner of the
3775
+ * #1760 frontmatter-then-body fallback chain), so the frontmatter tier is
3776
+ * actually consulted.
3777
+ *
3778
+ * (2) #1255 FRONTMATTER SHADOWING. The old code passed UNSTRIPPED `content`
3779
+ * to the extractor. `stateExtractField`'s plain-format branch is
3780
+ * `^Field:` with the `i` flag, so a frontmatter `status:` key matched the
3781
+ * pattern for the body field `Status` and won, because the frontmatter block
3782
+ * precedes the body. Parsed once now — `extractFrontmatter` +
3783
+ * `stripFrontmatter` — and `fm`/`body` are handed to the chain owner, exactly
3784
+ * as `advancePlanCore`/`beginPhaseCore`/`completePhaseCore`/
3785
+ * `readModifyWriteStateMd` already guard against this class of defect.
3786
+ *
3787
+ * `scope` (ADR-3180 Decision 2) reports whether the derivation actually ran:
3788
+ * - `COMPLETE` — the phase-vs-disk derivation ran over usable input,
3789
+ * including when it legitimately finds no VERIFICATION.md / no matching
3790
+ * phase directory (a real answer, not a non-answer).
3791
+ * - `UNSCOPED` — Current Phase could not be resolved by ANY chain step (no
3792
+ * frontmatter scalar, no body field), so the drift derivation had no
3793
+ * phase to scope its disk lookup to and could not run at all. Reporting
3794
+ * this as COMPLETE would recreate the #3162 collapse this phase closes,
3795
+ * one layer out.
3796
+ * - `UNREADABLE` — the frontmatter parse or the phases-dir scan itself
3797
+ * could not be consulted (an existing `catch` block used to swallow this
3798
+ * silently; the degrade stays, but is now visible).
3799
+ *
3800
+ * ⛔ Rejected (ADR-3180 §7.7 Rejected #2): a non-`COMPLETE` scope is never
3801
+ * routed to `valid:false`. `valid` keeps meaning "no drift warnings were
3802
+ * found"; `scope` says whether the derivation could actually run. A caller
3803
+ * branches on both — folding them into one boolean recreates the exact
3804
+ * collapse this epic removes, in the opposite direction (a legacy STATE.md
3805
+ * with no resolvable phase is a supported degrade, not an invalid document).
3806
+ */
3807
+ /**
3808
+ * #1255/#3187: parse frontmatter and strip it from the body ONCE, shared by
3809
+ * `cmdStateValidate` and `cmdStateCompletePhase` so both consult the identical
3810
+ * fm/body precedence and degrade identically when the frontmatter half of the
3811
+ * chain cannot be consulted. Extracted (code-review finding, epic #3180): the
3812
+ * two call sites previously carried a byte-identical try/catch, comments
3813
+ * included — an epic whose own thesis is "one canonical owner per
3814
+ * derivation" must not ship a duplicated derivation in its own diff.
3815
+ *
3816
+ * Returns `scope: SCOPE.COMPLETE` unless the frontmatter parse itself threw,
3817
+ * in which case `fm` degrades to `{}` and `scope` becomes `SCOPE.UNREADABLE`
3818
+ * — callers that mutate `scope` further (e.g. `cmdStateValidate`'s later
3819
+ * UNSCOPED/disk-scan degrades) start from this returned value rather than a
3820
+ * fresh `SCOPE.COMPLETE`.
3821
+ */
3822
+ function readStateFrontmatterScoped(content, statePath) {
3823
+ let fm;
3824
+ let scope = SCOPE.COMPLETE;
3825
+ try {
3826
+ fm = extractFrontmatter(content, statePath);
3827
+ }
3828
+ catch {
3829
+ // extractFrontmatter is documented never to throw, but this mirrors the
3830
+ // defensive try/catch already used around it elsewhere in this file
3831
+ // (e.g. spliceFrontmatter) — a parse hiccup here means the frontmatter
3832
+ // half of the chain could not be consulted; degrade visibly.
3833
+ fm = {};
3834
+ scope = SCOPE.UNREADABLE;
3835
+ }
3836
+ const body = stripFrontmatter(content);
3837
+ return { fm, body, scope };
3838
+ }
3839
+ /**
3840
+ * Builds an S0NN `Diagnostic` for `cmdStateValidate` (§8.4 rule 3 —
3841
+ * `cmdStateValidate` is a plain imperative function, not a `Rule.check`, so
3842
+ * it builds `Diagnostic[]` directly rather than going through
3843
+ * `evaluateRuleTable`/the `RULES` array machinery). Every S0NN subject is
3844
+ * advisory-only today (`cmdStateValidate` has never had a repair path), so
3845
+ * every remedy is `adviseRemedy` — `advice` is the short imperative command
3846
+ * text shown to the operator, matching the style Phase 11's rule-group files
3847
+ * already use for their own ADVISE-only findings (e.g.
3848
+ * `roadmap-disk-consistency.cts`'s `adviseRemedy('Create phase directory or
3849
+ * remove from roadmap')`).
2580
3850
  */
3851
+ function stateDiagnostic(code, severity, message, advice) {
3852
+ return { code, severity, message, remedy: adviseRemedy(advice) };
3853
+ }
2581
3854
  function cmdStateValidate(cwd, raw) {
2582
3855
  const statePath = planningPaths(cwd).state;
2583
3856
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -2590,67 +3863,126 @@ function cmdStateValidate(cwd, raw) {
2590
3863
  // searchers downstream, reading as "absent" rather than "corrupt."
2591
3864
  const encErr = (0, validate_cjs_1.textEncodingError)(content, 'STATE.md');
2592
3865
  if (encErr) {
2593
- output({ valid: false, warnings: [encErr], drift: {} }, raw, undefined);
3866
+ // S001 — error-class severity (this branch has always set `valid: false`
3867
+ // unconditionally and returned immediately, matching every other
3868
+ // error-class code, not a mere warning). Message reused verbatim from
3869
+ // `textEncodingError`, not paraphrased.
3870
+ output({
3871
+ valid: false,
3872
+ warnings: [stateDiagnostic('S001', SEVERITY.ERROR, encErr, 'Re-save STATE.md as UTF-8 text with the embedded NUL byte(s) removed')],
3873
+ }, raw, undefined);
2594
3874
  return;
2595
3875
  }
2596
3876
  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');
3877
+ // #1255/#3187: parse frontmatter and strip it from the body ONCE, so the
3878
+ // chain owner sees the same fm/body precedence every other migrated call
3879
+ // site sees. Pass statePath so a truncated STATE.md is named in the #1882
3880
+ // diagnostic rather than reported under a content digest.
3881
+ const { fm, body, scope: initialScope } = readStateFrontmatterScoped(content, statePath);
3882
+ const scope = initialScope;
3883
+ const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value || '';
3884
+ const resolvedPhase = resolveStatePhase(fm, body);
3885
+ const currentPhase = resolvedPhase.phase;
3886
+ const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
2601
3887
  const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
2602
3888
  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
- }
3889
+ if (currentPhase === null) {
3890
+ warnings.push(stateDiagnostic('S002', SEVERITY.WARNING, 'Cannot validate phase drift: STATE.md has no usable current_phase, Current Phase, or Current Position Phase value', 'Set current_phase (frontmatter) or Current Phase / Current Position Phase (body) in STATE.md'));
3891
+ output({ valid: false, warnings, scope }, raw, undefined);
3892
+ return;
3893
+ }
3894
+ const selectedPhaseKey = phaseKeyFromToken(currentPhase);
3895
+ if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source) !== selectedPhaseKey)) {
3896
+ 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'));
3897
+ }
3898
+ if (!node_fs_1.default.existsSync(phasesDir)) {
3899
+ warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phases directory is missing for phase ${currentPhase}`, 'Create the phases directory or correct current_phase to a phase that exists on disk'));
3900
+ output({ valid: false, warnings, scope }, raw, undefined);
3901
+ return;
3902
+ }
3903
+ let phaseDirPath;
3904
+ try {
3905
+ const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
3906
+ const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
3907
+ if (!phaseDir) {
3908
+ warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: no phase directory matches phase ${currentPhase}`, 'Create a phase directory matching the current phase or correct current_phase'));
3909
+ output({ valid: false, warnings, scope }, raw, undefined);
3910
+ return;
3911
+ }
3912
+ phaseDirPath = node_path_1.default.join(phasesDir, phaseDir.name);
3913
+ }
3914
+ catch {
3915
+ warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phases directory is unreadable for phase ${currentPhase}`, 'Check phases directory permissions and re-run validate'));
3916
+ output({ valid: false, warnings, scope }, raw, undefined);
3917
+ return;
3918
+ }
3919
+ try {
3920
+ const scan = scanPhasePlans(phaseDirPath);
3921
+ if (scan.scope !== SCOPE.COMPLETE) {
3922
+ throw new Error('phase plan scan is incomplete');
3923
+ }
3924
+ const { planCount: diskPlans, summaryCount: diskSummaries } = scan;
3925
+ // Check plan count mismatch
3926
+ if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
3927
+ 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'));
3928
+ }
3929
+ // Check for VERIFICATION.md — scoped to THIS phase's own token (#3511)
3930
+ // so a stray, cross-phase, or ad-hoc VERIFICATION file cannot claim
3931
+ // this phase's status has drifted.
3932
+ //
3933
+ // WARNING-4 (#3511 review): the pre-filter grammar here is
3934
+ // deliberately BROADER than the `-VERIFICATION.md` suffix every
3935
+ // other site in the codebase uses — `.includes('VERIFICATION')`
3936
+ // admits names like `03_VERIFICATION.md` (underscore, no dash) that
3937
+ // the dashed grammar would reject outright. That breadth predates
3938
+ // #3511 and is intentional here (this is a best-effort drift
3939
+ // WARNING scan, not an authoritative single-pick resolver), so it is
3940
+ // left as-is rather than narrowed to match the dashed sites — doing
3941
+ // so would be a separate, un-asked-for behavior change (S006/S007).
3942
+ // What #3511 DOES change is that a name this broader grammar admits
3943
+ // is now ALSO subject to the same `scopeToPhase` membership check as
3944
+ // every dashed-grammar site, so a stray `04_VERIFICATION.md`-shaped
3945
+ // file in phase 03's directory is excluded exactly like a stray
3946
+ // `04-VERIFICATION.md` would be — while `03_VERIFICATION.md` (own
3947
+ // phase, underscore separator) is NOT excluded: `isPhaseArtifact`
3948
+ // (`phase-id.cts`) accepts `_` as a candidate-boundary separator
3949
+ // alongside `-` and `.` for exactly this reason, so an S006/S007
3950
+ // scan of `03-alpha/03_VERIFICATION.md` still resolves to S006
3951
+ // ("verification passed" drift), not a false S007.
3952
+ const files = node_fs_1.default.readdirSync(phaseDirPath);
3953
+ const phaseDirBaseName = node_path_1.default.basename(phaseDirPath);
3954
+ const verificationFiles = scopeToPhase(files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')), phaseDirBaseName);
3955
+ for (const vf of verificationFiles) {
3956
+ try {
3957
+ const vContent = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDirPath, vf), 'utf-8');
3958
+ if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
3959
+ 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
3960
  }
2641
3961
  }
3962
+ catch { /* best-effort (#2245 audit): cmdStateValidate is a diagnostic
3963
+ * warnings scan across N VERIFICATION.md files — one unreadable file
3964
+ * (permission/race) must not abort the scan of the rest; it's simply
3965
+ * excluded from drift detection. Does not degrade `scope` — the other
3966
+ * N-1 files were consulted fine. */
3967
+ }
2642
3968
  }
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. */
3969
+ // Check if all plans have summaries but status still says executing
3970
+ if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
3971
+ // Only warn if no verification exists (if verification passed, the above warning covers it)
3972
+ if (verificationFiles.length === 0) {
3973
+ // S007 stays WARNING (not INFO): closely related to S006 (both
3974
+ // signal "phase may be ready to advance"), and S006 is WARNING —
3975
+ // giving the sibling condition a different severity for the same
3976
+ // underlying signal would be a false distinction.
3977
+ 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"'));
3978
+ }
2650
3979
  }
2651
3980
  }
3981
+ catch {
3982
+ warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phase directory is unreadable for phase ${currentPhase}`, 'Check phase directory permissions and re-run validate'));
3983
+ }
2652
3984
  const valid = warnings.length === 0;
2653
- output({ valid, warnings, drift }, raw, undefined);
3985
+ output({ valid, warnings, scope }, raw, undefined);
2654
3986
  }
2655
3987
  /**
2656
3988
  * Gate 2: Sync STATE.md from filesystem ground truth.
@@ -2710,10 +4042,17 @@ function cmdStateSync(cwd, options, raw) {
2710
4042
  let _highestIncompletePhaseSummaryCount = 0;
2711
4043
  for (const dir of entries) {
2712
4044
  const dirPath = node_path_1.default.join(phasesDir, dir);
2713
- const { planCount: plans, summaryCount: summaries, completed } = scanPhasePlans(dirPath);
4045
+ const { planCount: plans, summaryCount: summaries } = scanPhasePlans(dirPath);
2714
4046
  totalDiskPlans += plans;
2715
4047
  totalDiskSummaries += summaries;
2716
- if (completed)
4048
+ // ADR-3180 §7.4 (#3186, #2957 disk-strict): route through the single
4049
+ // canonical owner (isPhaseComplete), not scanPhasePlans's own `completed`
4050
+ // field ("are all plans summarized?" — a different question). This is the
4051
+ // same fix buildStateFrontmatter got above; cmdStateSync (`state sync`)
4052
+ // was a second, independent consumer of the same raw field the initial
4053
+ // migration missed — without it, `state sync` and `state json` disagreed
4054
+ // on completed_phases for the identical disk state.
4055
+ if (isPhaseComplete(dirPath).value.complete)
2717
4056
  diskCompletedPhases++;
2718
4057
  // Track the highest phase with incomplete plans (or any plans)
2719
4058
  const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
@@ -2774,16 +4113,35 @@ function cmdStateSync(cwd, options, raw) {
2774
4113
  const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
2775
4114
  let milestoneBounded = true;
2776
4115
  if (versionStr !== null && syncRoadmapRaw !== null) {
2777
- const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(versionStr)}`, 'mi');
2778
- milestoneBounded = versionedHeading.test(syncRoadmapRaw);
4116
+ // #3184: routed through the single owner (roadmap-parser.cjs) instead of
4117
+ // a hand-rolled, unbounded-substring re-derivation — see the identical
4118
+ // fix in buildStateFrontmatter above.
4119
+ milestoneBounded = isMilestoneBoundedInRoadmap(syncRoadmapRaw, versionStr);
2779
4120
  }
2780
4121
  let percent = null;
2781
4122
  if (!milestoneBounded) {
2782
4123
  changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`);
2783
4124
  }
2784
4125
  else {
2785
- const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
2786
- percent = p !== null ? p : 0;
4126
+ // #3217 (ADR-3180 §7.6 rule 4) BLOCKER fix: the prior comment here claimed
4127
+ // `entries` (the raw fs.readdirSync listing above) was "never routed
4128
+ // through listMilestonePhaseDirs, so there is no real Scope to pass" —
4129
+ // that was factually wrong. The same `syncRoadmapRaw`/`syncRoadmapScope`
4130
+ // already parsed above (~3104) is precisely what
4131
+ // `listMilestonePhaseDirs` (via `getMilestonePhaseFilter`) re-derives
4132
+ // from `cwd` to produce a real `Scope` — the identical shape already
4133
+ // threaded through `buildStateFrontmatter`'s `diskScope` above. Calling
4134
+ // it here (discarding `.value`, which duplicates `entries`'s own
4135
+ // retired-phase-filtered listing) gets the real scope without changing
4136
+ // the disk-scan totals computed above.
4137
+ const syncScope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
4138
+ if (syncScope !== SCOPE.COMPLETE) {
4139
+ changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);
4140
+ }
4141
+ else {
4142
+ const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases, syncScope);
4143
+ percent = p !== null ? p : 0;
4144
+ }
2787
4145
  }
2788
4146
  const syncResult = transitionCore(modified, { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, { clock: clock_cjs_1.realClock });
2789
4147
  modified = syncResult.content;
@@ -2817,30 +4175,18 @@ function cmdStatePrune(cwd, options, raw) {
2817
4175
  }
2818
4176
  const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
2819
4177
  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.
4178
+ // Resolve the current phase via `resolveCurrentPhaseId` — the shared owner of
4179
+ // the canonical frontmatter → `Current Phase` field → scoped prose ladder
4180
+ // (#1760 origin, #1776 scoping, #3187 ownership; see its doc comment). Prune
4181
+ // engages on a template-conformant STATE.md instead of bailing "Only 0
4182
+ // phases" (#1760). #3231/#3481 routed the phase-labeled write commands
4183
+ // through the same helper rather than leaving a second copy of the ladder here.
2830
4184
  const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
2831
4185
  const fm = extractFrontmatter(rawState, statePath);
2832
4186
  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;
4187
+ // Prune needs an integer cutoff, so it parses the resolved id itself; a
4188
+ // non-numeric or absent id lands on 0 and prune bails, as before.
4189
+ const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body)), 10) || 0;
2844
4190
  const cutoff = currentPhase - keepRecent;
2845
4191
  if (cutoff <= 0) {
2846
4192
  emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
@@ -2946,6 +4292,11 @@ function cmdStateRebuild(cwd, options, raw) {
2946
4292
  const phasesDir = node_path_1.default.join(planningPaths(cwd).planning, 'phases');
2947
4293
  if (!node_fs_1.default.existsSync(phasesDir) || !node_fs_1.default.statSync(phasesDir).isDirectory())
2948
4294
  return { ok: true, phases: [] };
4295
+ // #3185: deliberately NOT listMilestonePhaseDirs. `state rebuild` is a
4296
+ // RECONCILIATION pass against ground truth -- it must see every phase
4297
+ // directory on disk so an orphan STATE.md row for a phase that no longer
4298
+ // exists (or sits outside the current window) is dropped. Scoping this
4299
+ // would make the rebuild silently preserve stale rows.
2949
4300
  const entries = node_fs_1.default.readdirSync(phasesDir);
2950
4301
  const records = [];
2951
4302
  for (const entry of entries) {
@@ -2963,9 +4314,21 @@ function cmdStateRebuild(cwd, options, raw) {
2963
4314
  const m = entry.match(/^(\d+)-(.+)$/);
2964
4315
  if (!m)
2965
4316
  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;
4317
+ // #3183 (lint-plan-count-drift / ADR-3180 Decision 2): source
4318
+ // planCount/summaryCount from the single owner (scanPhasePlans)
4319
+ // instead of a local root-only `-PLAN.md`/`-SUMMARY.md` readdirSync
4320
+ // filter — picks up bare PLAN.md/SUMMARY.md and nested plans/. A
4321
+ // non-COMPLETE scope (TRUNCATED: nested plans/ unreadable;
4322
+ // UNREADABLE: `full` itself unreadable) is not a trustworthy count —
4323
+ // throw so it surfaces via the outer catch as a real scan failure
4324
+ // (`ok:false`), mirroring the #3057 B1 contract documented above for
4325
+ // the sibling `fs.readdirSync(phasesDir)` failure mode, rather than
4326
+ // silently reporting an undercount.
4327
+ const scan = scanPhasePlans(full);
4328
+ if (scan.scope !== SCOPE.COMPLETE) {
4329
+ throw new Error(`could not fully scan plan directory (scope ${scan.scope}): ${full}`);
4330
+ }
4331
+ const { planCount, summaryCount } = scan;
2969
4332
  records.push({ number: m[1], name: m[2], planCount, summaryCount });
2970
4333
  }
2971
4334
  return { ok: true, phases: records };
@@ -3048,10 +4411,17 @@ function cmdStateRebuild(cwd, options, raw) {
3048
4411
  * that the phase execution is finished and the project is ready for the next phase.
3049
4412
  * Implements the `gsd state complete-phase` subcommand (issue #2735).
3050
4413
  */
3051
- function resolvePhaseIdForCompletePhase(content, overridePhase) {
4414
+ function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
4415
+ // #3187: route through the single #1760 fallback-chain owner (fm scalar
4416
+ // then body field) instead of two raw stateExtractField calls on
4417
+ // frontmatter-blind content — a STATE.md whose phase lives only in
4418
+ // frontmatter no longer resolves to null here. `Phase` (the historical
4419
+ // second-choice field name) has no frontmatter counterpart, so its fmKey
4420
+ // is null — same shape as cmdStateSnapshot's `stateFieldValue(fm,
4421
+ // currentPositionScope, null, 'Phase')` fallback.
3052
4422
  const candidate = overridePhase ||
3053
- (0, state_document_cjs_1.stateExtractField)(content, 'Current Phase') ||
3054
- (0, state_document_cjs_1.stateExtractField)(content, 'Phase') ||
4423
+ (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ||
4424
+ (0, state_document_cjs_1.stateFieldValue)(fm, body, null, 'Phase').value ||
3055
4425
  '';
3056
4426
  // #2125: parse via the canonical anchored parser so a narrative `Phase:`
3057
4427
  // body line (e.g. "Milestone v0.5 complete") does not mine a bogus token —
@@ -3068,7 +4438,30 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
3068
4438
  return;
3069
4439
  }
3070
4440
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
3071
- const resolvedPhase = resolvePhaseIdForCompletePhase(content, overridePhase);
4441
+ // #1255/#3187: parse frontmatter and strip it from the body ONCE, mirroring
4442
+ // cmdStateValidate/cmdStateSnapshot, so resolvePhaseIdForCompletePhase and
4443
+ // the idempotency guard below consult the identical fm/body precedence —
4444
+ // the two sites cannot drift onto different chains, extending the #2125
4445
+ // "same canonical parser" guarantee one layer earlier.
4446
+ const { fm, body, scope } = readStateFrontmatterScoped(content, statePath);
4447
+ // #3187 Postel/visibility (design doc's sharpest case): this whole handler
4448
+ // is the DESTRUCTIVE path the #3489 idempotency guard below protects — it
4449
+ // decides whether a re-run of `state complete-phase --phase N` is allowed
4450
+ // to roll STATE.md back to N's moment-of-completion. If the frontmatter
4451
+ // half of the chain could not be consulted (`scope` UNREADABLE),
4452
+ // `existingCurrentPhase` below could read as null even though the
4453
+ // project's true current phase lives only in that unreadable frontmatter —
4454
+ // silently treating a non-COMPLETE scope as "not complete" would let the
4455
+ // guard's `existingCurrentPhase &&` check fail OPEN and re-run an
4456
+ // already-completed phase. Refuse outright instead of guessing; this
4457
+ // applies even when `--phase` is explicit, because the guard's job is to
4458
+ // protect against exactly that already-completed-phase case regardless of
4459
+ // how the target phase was named.
4460
+ if (scope !== SCOPE.COMPLETE) {
4461
+ 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);
4462
+ return;
4463
+ }
4464
+ const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase);
3072
4465
  if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
3073
4466
  output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw, undefined);
3074
4467
  return;
@@ -3082,7 +4475,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
3082
4475
  // Last Activity, Last Activity Description, and the Current Position body.
3083
4476
  // The handler is now a no-op in that case so re-invocation from downstream
3084
4477
  // workflows cannot regress the project state.
3085
- const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateExtractField)(content, 'Current Phase') || '';
4478
+ const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value || '';
3086
4479
  // #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two
3087
4480
  // sites cannot diverge on the token they extract.
3088
4481
  const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase;
@@ -3091,7 +4484,18 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
3091
4484
  return;
3092
4485
  }
3093
4486
  const today = clock_cjs_1.realClock.localToday();
4487
+ // #3408 review (close-known-limits): `updated` mixes two different kinds of
4488
+ // thing — FIELD names (Status, Last Activity, ...), each reconcilable
4489
+ // against the persisted bytes via `reconcileReportedFields`, and the
4490
+ // SECTION name `Current Position` (the whole Current-Position block, not a
4491
+ // single field `stateExtractField` can look up). Rather than re-deriving
4492
+ // the distinction downstream by string-matching against a Set, each entry
4493
+ // now carries its kind at the point it is PRODUCED; the flattening to a
4494
+ // flat `string[]` (the command's OUTPUT CONTRACT — unchanged) happens once
4495
+ // below, right before `output()`.
3094
4496
  const updated = [];
4497
+ let preSyncContent = '';
4498
+ const divergedFields = [];
3095
4499
  readModifyWriteStateMd(statePath, (content) => {
3096
4500
  const currentPhase = resolvedPhase;
3097
4501
  // Bug #1255: operate on body only so the YAML frontmatter `status:` key
@@ -3105,20 +4509,20 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
3105
4509
  let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
3106
4510
  if (result) {
3107
4511
  body = result;
3108
- updated.push('Status');
4512
+ updated.push({ kind: 'field', name: 'Status' });
3109
4513
  }
3110
4514
  // Update Last Activity date
3111
4515
  result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
3112
4516
  if (result) {
3113
4517
  body = result;
3114
- updated.push('Last Activity');
4518
+ updated.push({ kind: 'field', name: 'Last Activity' });
3115
4519
  }
3116
4520
  // Update Last Activity Description
3117
4521
  const activityDesc = `Phase ${currentPhase} marked complete`;
3118
4522
  result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
3119
4523
  if (result) {
3120
4524
  body = result;
3121
- updated.push('Last Activity Description');
4525
+ updated.push({ kind: 'field', name: 'Last Activity Description' });
3122
4526
  }
3123
4527
  // Update ## Current Position section
3124
4528
  // ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2.
@@ -3177,12 +4581,30 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
3177
4581
  posBody = replaced;
3178
4582
  }
3179
4583
  body = body.slice(0, cpBodyStart) + posBody + body.slice(cpBodyEnd);
3180
- updated.push('Current Position');
4584
+ updated.push({ kind: 'section', name: 'Current Position' });
3181
4585
  }
3182
4586
  }
3183
- return reassemble(body);
3184
- }, cwd);
3185
- output({ updated, phase: resolvedPhase }, raw, updated.length > 0 ? 'true' : 'false');
4587
+ const out = reassemble(body);
4588
+ preSyncContent = out;
4589
+ return out;
4590
+ }, cwd, { divergedFields });
4591
+ // ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
4592
+ // the analysis pass"). Unlike the transitionCore-based commands, this
4593
+ // adapter's `updated` mixes FIELD entries (Status, Last Activity, Last
4594
+ // Activity Description — each reconcilable against the persisted bytes,
4595
+ // same as every other command in this phase) with the SECTION entry
4596
+ // `Current Position` (the whole Current-Position block, not a single
4597
+ // field `stateExtractField` can look up — reconciling it the same way as
4598
+ // a field would always drop it as a false negative). Reconcile only the
4599
+ // field-shaped entries (#3351's direction), pass the section entry
4600
+ // through unconditionally, and fold in any field preservation restored
4601
+ // that this transform never touched (#3345's direction). The kind was
4602
+ // decided at PUSH time above (typed producer), not re-derived here by
4603
+ // string-matching a name against a Set.
4604
+ const sectionEntries = updated.filter((e) => e.kind === 'section').map((e) => e.name);
4605
+ const fieldEntries = updated.filter((e) => e.kind === 'field').map((e) => e.name);
4606
+ const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preSyncContent, fieldEntries, divergedFields)];
4607
+ output({ updated: reconciled, phase: resolvedPhase }, raw, reconciled.length > 0 ? 'true' : 'false');
3186
4608
  }
3187
4609
  module.exports = {
3188
4610
  stateExtractField: state_document_cjs_1.stateExtractField,
@@ -3193,6 +4615,17 @@ module.exports = {
3193
4615
  writeStateMd,
3194
4616
  readModifyWriteStateMd,
3195
4617
  syncStateFrontmatter,
4618
+ // #3374: the shared post-sync preservation pass (snapshots + table-driven
4619
+ // applyStatePreservation + #2736 re-assert).
4620
+ applyPostSyncPreservation,
4621
+ // #3469 (ADR-3408 §8.3): the ONE write-seam composition (sync +
4622
+ // preservation) as content -> content. Exported for cmdPhaseComplete's
4623
+ // atomic-commit adapter (phase.cts, syncs STATE.md directly because it is
4624
+ // committed atomically with ROADMAP/REQUIREMENTS) and for
4625
+ // cmdMilestoneComplete (milestone.cts) — both need the composition's
4626
+ // output but supply their own I/O envelope around it.
4627
+ syncAndPreserveStateMd,
4628
+ readStateHeadFreshness,
3196
4629
  withStateLock,
3197
4630
  updatePerformanceMetricsSection,
3198
4631
  cmdStateLoad,
@@ -3222,6 +4655,10 @@ module.exports = {
3222
4655
  // Test seam (#1514): the pure retired/folded-phase parser, exposed so its
3223
4656
  // strikethrough-detection logic can be property-tested directly.
3224
4657
  _extractRetiredPhaseNumbers: extractRetiredPhaseNumbers,
4658
+ // Test seam (#3471 review): the second hand-maintained table beside
4659
+ // FIELD_CLASSIFICATION, exposed so a parity test can pin that every
4660
+ // `preserve-when-unchanged` row has a label here.
4661
+ _FRONTMATTER_KEY_TO_BODY_LABEL: FRONTMATTER_KEY_TO_BODY_LABEL,
3225
4662
  // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
3226
4663
  // steal decision is exercised without real pids. Mirrors capability-lock.cts.
3227
4664
  _setLockProbes(probes) {