@opengsd/gsd-core 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +1 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-dom-verifier.md +169 -0
  7. package/agents/gsd-eval-auditor.md +1 -1
  8. package/agents/gsd-executor.md +17 -9
  9. package/agents/gsd-framework-selector.md +1 -3
  10. package/agents/gsd-intel-updater.md +1 -1
  11. package/agents/gsd-mempalace-curator.md +0 -1
  12. package/agents/gsd-pattern-mapper.md +11 -0
  13. package/agents/gsd-phase-researcher.md +3 -1
  14. package/agents/gsd-plan-checker.md +15 -55
  15. package/agents/gsd-planner.md +6 -4
  16. package/agents/gsd-project-researcher.md +1 -1
  17. package/agents/gsd-research-synthesizer.md +2 -2
  18. package/agents/gsd-roadmapper.md +15 -11
  19. package/agents/gsd-ui-checker.md +63 -4
  20. package/agents/gsd-ui-researcher.md +41 -3
  21. package/agents/gsd-verifier.md +1 -1
  22. package/bin/install.js +609 -134
  23. package/commands/gsd/discuss-phase.md +1 -1
  24. package/commands/gsd/import.md +1 -1
  25. package/commands/gsd/quick.md +8 -4
  26. package/gsd-core/bin/gsd-tools.cjs +567 -51
  27. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  28. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  29. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  30. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  31. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  32. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  33. package/gsd-core/bin/lib/audit.cjs +163 -41
  34. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  35. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  36. package/gsd-core/bin/lib/capability-registry.cjs +336 -95
  37. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  38. package/gsd-core/bin/lib/capability-validator.cjs +205 -18
  39. package/gsd-core/bin/lib/check-command-router.cjs +145 -5
  40. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  41. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  43. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  44. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  45. package/gsd-core/bin/lib/commands.cjs +543 -44
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  47. package/gsd-core/bin/lib/config-loader.cjs +118 -29
  48. package/gsd-core/bin/lib/config.cjs +92 -2
  49. package/gsd-core/bin/lib/configuration.cjs +129 -37
  50. package/gsd-core/bin/lib/core-utils.cjs +84 -7
  51. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  52. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  53. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  54. package/gsd-core/bin/lib/frontmatter.cjs +840 -305
  55. package/gsd-core/bin/lib/gap-checker.cjs +27 -3
  56. package/gsd-core/bin/lib/git-base-branch.cjs +174 -39
  57. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  58. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +6 -3
  59. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  60. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  61. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  62. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  63. package/gsd-core/bin/lib/init.cjs +120 -41
  64. package/gsd-core/bin/lib/install-engine.cjs +68 -3
  65. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -1
  66. package/gsd-core/bin/lib/install-profiles.cjs +78 -4
  67. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  68. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  69. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  70. package/gsd-core/bin/lib/intel.cjs +101 -26
  71. package/gsd-core/bin/lib/io.cjs +160 -15
  72. package/gsd-core/bin/lib/learnings.cjs +85 -14
  73. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  74. package/gsd-core/bin/lib/markdown-table.cjs +52 -4
  75. package/gsd-core/bin/lib/milestone.cjs +90 -5
  76. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  77. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  78. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  79. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  80. package/gsd-core/bin/lib/phase-id.cjs +70 -4
  81. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  82. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  83. package/gsd-core/bin/lib/phase.cjs +405 -84
  84. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  85. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  86. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  87. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  88. package/gsd-core/bin/lib/planning-snapshot.cjs +18 -14
  89. package/gsd-core/bin/lib/planning-workspace.cjs +56 -0
  90. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  91. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  92. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  93. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  94. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  95. package/gsd-core/bin/lib/review-lane-descriptor.cjs +9 -9
  96. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  97. package/gsd-core/bin/lib/roadmap-parser.cjs +79 -16
  98. package/gsd-core/bin/lib/roadmap.cjs +74 -19
  99. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +96 -8
  100. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -1
  101. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +287 -55
  102. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  103. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  104. package/gsd-core/bin/lib/shell-command-projection.cjs +71 -8
  105. package/gsd-core/bin/lib/smart-entry.cjs +12 -22
  106. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  107. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  108. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  109. package/gsd-core/bin/lib/state-document.cjs +186 -0
  110. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  111. package/gsd-core/bin/lib/state-transition.cjs +517 -101
  112. package/gsd-core/bin/lib/state.cjs +946 -163
  113. package/gsd-core/bin/lib/surface.cjs +10 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  115. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  116. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  117. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  118. package/gsd-core/bin/lib/uat.cjs +1376 -125
  119. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  120. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  121. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  122. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  123. package/gsd-core/bin/lib/vendor/README.md +43 -5
  124. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  125. package/gsd-core/bin/lib/verification.cjs +14 -1
  126. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  127. package/gsd-core/bin/lib/verify.cjs +95 -40
  128. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  129. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  130. package/gsd-core/bin/lib/worktree-safety.cjs +177 -21
  131. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  132. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  133. package/gsd-core/bin/shared/exit-codes.json +8 -0
  134. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  135. package/gsd-core/bin/shared/model-catalog.json +8 -1
  136. package/gsd-core/references/agent-contracts.md +3 -2
  137. package/gsd-core/references/api-coverage.md +24 -2
  138. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  139. package/gsd-core/references/checkpoints.md +37 -19
  140. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  141. package/gsd-core/references/edge-probe.md +8 -0
  142. package/gsd-core/references/execute-mvp-tdd.md +1 -3
  143. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  144. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  145. package/gsd-core/references/failing-direction.md +78 -0
  146. package/gsd-core/references/gate-prompts.md +1 -1
  147. package/gsd-core/references/git-integration.md +5 -5
  148. package/gsd-core/references/git-planning-commit.md +3 -3
  149. package/gsd-core/references/gsd-run-resolver.md +1 -1
  150. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  151. package/gsd-core/references/model-profiles.md +1 -1
  152. package/gsd-core/references/nyquist-compliance.md +74 -0
  153. package/gsd-core/references/offer-next.md +3 -5
  154. package/gsd-core/references/phase-argument-parsing.md +3 -3
  155. package/gsd-core/references/planner-failing-direction.md +53 -0
  156. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  157. package/gsd-core/references/planner-revision.md +1 -1
  158. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  159. package/gsd-core/references/planning-config.md +37 -8
  160. package/gsd-core/references/reviewer-instances.md +31 -0
  161. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  162. package/gsd-core/references/tdd.md +1 -3
  163. package/gsd-core/references/ui-brand.md +65 -21
  164. package/gsd-core/references/ui-consideration-probe.md +1 -1
  165. package/gsd-core/references/universal-anti-patterns.md +2 -2
  166. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  167. package/gsd-core/references/verify-mvp-mode.md +1 -1
  168. package/gsd-core/references/workstream-flag.md +11 -11
  169. package/gsd-core/templates/README.md +1 -1
  170. package/gsd-core/templates/SECURITY.md +3 -3
  171. package/gsd-core/templates/UI-SPEC.md +25 -3
  172. package/gsd-core/templates/VALIDATION.md +3 -3
  173. package/gsd-core/templates/phase-prompt.md +3 -0
  174. package/gsd-core/templates/state.md +7 -0
  175. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  176. package/gsd-core/workflows/add-backlog.md +1 -1
  177. package/gsd-core/workflows/add-phase.md +3 -3
  178. package/gsd-core/workflows/add-tests.md +3 -8
  179. package/gsd-core/workflows/add-todo.md +1 -1
  180. package/gsd-core/workflows/ai-integration-phase.md +4 -9
  181. package/gsd-core/workflows/audit-fix.md +12 -3
  182. package/gsd-core/workflows/audit-milestone.md +9 -9
  183. package/gsd-core/workflows/audit-uat.md +17 -2
  184. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  185. package/gsd-core/workflows/autonomous.md +10 -26
  186. package/gsd-core/workflows/check-todos.md +1 -1
  187. package/gsd-core/workflows/cleanup.md +2 -2
  188. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  189. package/gsd-core/workflows/code-review-fix.md +1 -1
  190. package/gsd-core/workflows/code-review.md +121 -40
  191. package/gsd-core/workflows/complete-milestone.md +15 -10
  192. package/gsd-core/workflows/debug.md +5 -3
  193. package/gsd-core/workflows/diagnose-issues.md +12 -6
  194. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  195. package/gsd-core/workflows/discuss-phase/modes/chain.md +3 -7
  196. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  197. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  198. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -2
  199. package/gsd-core/workflows/discuss-phase.md +1 -1
  200. package/gsd-core/workflows/do.md +3 -6
  201. package/gsd-core/workflows/docs-update.md +5 -4
  202. package/gsd-core/workflows/edit-phase.md +1 -1
  203. package/gsd-core/workflows/eval-review.md +4 -9
  204. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  205. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +113 -11
  206. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  207. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  208. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  209. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +22 -4
  210. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  211. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  212. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  213. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  214. package/gsd-core/workflows/execute-phase.md +38 -54
  215. package/gsd-core/workflows/execute-plan.md +17 -12
  216. package/gsd-core/workflows/explore.md +1 -1
  217. package/gsd-core/workflows/extract-learnings.md +1 -1
  218. package/gsd-core/workflows/fast.md +2 -2
  219. package/gsd-core/workflows/forensics.md +1 -1
  220. package/gsd-core/workflows/graduation.md +5 -5
  221. package/gsd-core/workflows/health.md +3 -6
  222. package/gsd-core/workflows/import.md +14 -11
  223. package/gsd-core/workflows/inbox.md +4 -5
  224. package/gsd-core/workflows/ingest-docs.md +44 -11
  225. package/gsd-core/workflows/insert-phase.md +5 -5
  226. package/gsd-core/workflows/list-seeds.md +5 -3
  227. package/gsd-core/workflows/list-workspaces.md +1 -1
  228. package/gsd-core/workflows/manager.md +12 -23
  229. package/gsd-core/workflows/map-codebase.md +1 -1
  230. package/gsd-core/workflows/milestone-summary.md +1 -1
  231. package/gsd-core/workflows/mvp-phase.md +2 -2
  232. package/gsd-core/workflows/new-milestone.md +9 -21
  233. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  234. package/gsd-core/workflows/new-project.md +12 -26
  235. package/gsd-core/workflows/new-workspace.md +1 -1
  236. package/gsd-core/workflows/next.md +2 -2
  237. package/gsd-core/workflows/pause-work.md +1 -1
  238. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  239. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  240. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  241. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  242. package/gsd-core/workflows/plan-phase.md +121 -42
  243. package/gsd-core/workflows/plan-review-convergence.md +46 -9
  244. package/gsd-core/workflows/plant-seed.md +2 -2
  245. package/gsd-core/workflows/pr-branch.md +187 -51
  246. package/gsd-core/workflows/profile-user.md +16 -14
  247. package/gsd-core/workflows/progress.md +27 -12
  248. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  249. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -3
  250. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  251. package/gsd-core/workflows/quick/steps/research-phase.md +2 -4
  252. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  253. package/gsd-core/workflows/quick.md +20 -29
  254. package/gsd-core/workflows/remove-phase.md +4 -4
  255. package/gsd-core/workflows/remove-workspace.md +2 -2
  256. package/gsd-core/workflows/resume-project.md +8 -12
  257. package/gsd-core/workflows/review.md +193 -15
  258. package/gsd-core/workflows/scan.md +1 -1
  259. package/gsd-core/workflows/secure-phase.md +2 -2
  260. package/gsd-core/workflows/settings-advanced.md +7 -9
  261. package/gsd-core/workflows/settings-integrations.md +64 -31
  262. package/gsd-core/workflows/settings.md +3 -5
  263. package/gsd-core/workflows/ship.md +12 -6
  264. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  265. package/gsd-core/workflows/sketch.md +12 -18
  266. package/gsd-core/workflows/smart-entry.md +3 -5
  267. package/gsd-core/workflows/spec-phase.md +23 -1
  268. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  269. package/gsd-core/workflows/spike.md +20 -31
  270. package/gsd-core/workflows/stats.md +2 -2
  271. package/gsd-core/workflows/sync-skills.md +1 -1
  272. package/gsd-core/workflows/thread.md +11 -7
  273. package/gsd-core/workflows/transition.md +5 -5
  274. package/gsd-core/workflows/ui-phase.md +10 -16
  275. package/gsd-core/workflows/ui-review.md +6 -10
  276. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  277. package/gsd-core/workflows/undo.md +8 -16
  278. package/gsd-core/workflows/update.md +6 -10
  279. package/gsd-core/workflows/validate-phase.md +2 -2
  280. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  281. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  282. package/gsd-core/workflows/verify-work.md +57 -18
  283. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  284. package/hooks/dist/gsd-config-reload.js +18 -12
  285. package/hooks/dist/gsd-context-monitor.js +19 -10
  286. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  287. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  288. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  289. package/hooks/dist/gsd-cursor-stop.js +2 -1
  290. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  291. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  292. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  293. package/hooks/dist/gsd-graphify-update.sh +22 -18
  294. package/hooks/dist/gsd-node-runner.sh +76 -0
  295. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  296. package/hooks/dist/gsd-prompt-guard.js +16 -7
  297. package/hooks/dist/gsd-read-guard.js +16 -7
  298. package/hooks/dist/gsd-read-injection-scanner.js +17 -8
  299. package/hooks/dist/gsd-session-state.sh +1 -0
  300. package/hooks/dist/gsd-statusline.js +215 -26
  301. package/hooks/dist/gsd-validate-commit.sh +80 -6
  302. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  303. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  304. package/hooks/dist/gsd-workflow-guard.js +34 -16
  305. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  306. package/hooks/dist/gsd-write-guard.js +35 -25
  307. package/hooks/dist/lib/cli-exit.js +560 -0
  308. package/hooks/dist/lib/exit-code-registry.js +98 -0
  309. package/hooks/dist/lib/git-probe.js +84 -0
  310. package/hooks/dist/lib/hook-exit.js +81 -0
  311. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  312. package/hooks/gsd-agent-isolation-guard.js +77 -38
  313. package/hooks/gsd-config-reload.js +18 -12
  314. package/hooks/gsd-context-monitor.js +19 -10
  315. package/hooks/gsd-cursor-post-tool.js +3 -1
  316. package/hooks/gsd-cursor-pre-tool.js +3 -1
  317. package/hooks/gsd-cursor-session-start.js +2 -1
  318. package/hooks/gsd-cursor-stop.js +2 -1
  319. package/hooks/gsd-cursor-subagent-start.js +28 -23
  320. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  321. package/hooks/gsd-ensure-canonical-path.js +2 -1
  322. package/hooks/gsd-graphify-update.sh +22 -18
  323. package/hooks/gsd-node-runner.sh +76 -0
  324. package/hooks/gsd-phase-boundary.sh +1 -0
  325. package/hooks/gsd-prompt-guard.js +16 -7
  326. package/hooks/gsd-read-guard.js +16 -7
  327. package/hooks/gsd-read-injection-scanner.js +17 -8
  328. package/hooks/gsd-session-state.sh +1 -0
  329. package/hooks/gsd-statusline.js +215 -26
  330. package/hooks/gsd-validate-commit.sh +80 -6
  331. package/hooks/gsd-windsurf-pre-command.js +16 -11
  332. package/hooks/gsd-windsurf-pre-write.js +22 -13
  333. package/hooks/gsd-workflow-guard.js +34 -16
  334. package/hooks/gsd-worktree-path-guard.js +36 -21
  335. package/hooks/gsd-write-guard.js +35 -25
  336. package/hooks/lib/cli-exit.js +560 -0
  337. package/hooks/lib/exit-code-registry.js +98 -0
  338. package/hooks/lib/git-probe.js +84 -0
  339. package/hooks/lib/hook-exit.js +81 -0
  340. package/hooks/managed-hooks-registry.cjs +3 -0
  341. package/package.json +12 -7
  342. package/scripts/base64-scan.sh +74 -12
  343. package/scripts/build-hooks.js +5 -0
  344. package/scripts/check-glossary-refs.cjs +77 -15
  345. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  346. package/scripts/ci-check-job-near-cap.cjs +49 -0
  347. package/scripts/ci-pr-mergeability.cjs +262 -0
  348. package/scripts/ci-test-scope.cjs +45 -12
  349. package/scripts/ci-timeout-report.cjs +230 -0
  350. package/scripts/docs-guard-registry.cjs +396 -0
  351. package/scripts/gen-capability-registry.cjs +8 -6
  352. package/scripts/gen-exit-code-docs.cjs +318 -0
  353. package/scripts/gen-exit-code-registry.cjs +891 -0
  354. package/scripts/gen-features.cjs +836 -0
  355. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  356. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  357. package/scripts/gen-loop-host-contract.cjs +134 -1
  358. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  359. package/scripts/gen-state-md-docs.cjs +727 -0
  360. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  361. package/scripts/lib/ci-job-timing.cjs +72 -0
  362. package/scripts/lib/cli-exit.cjs +546 -44
  363. package/scripts/lib/drift-scan.cjs +32 -2
  364. package/scripts/lib/exit-code-registry.cjs +98 -0
  365. package/scripts/lib/ndjson-reporter.cjs +119 -0
  366. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  367. package/scripts/lint-docs-guard-registration.cjs +495 -0
  368. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  369. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  370. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  371. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  372. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  373. package/scripts/lint-phase-enumeration-drift.cjs +21 -8
  374. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  375. package/scripts/lint-removed-but-needed.cjs +184 -16
  376. package/scripts/lint-seam-enforcement.cjs +182 -0
  377. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  378. package/scripts/lint-source-test-name-collision.cjs +241 -0
  379. package/scripts/lint-state-write-path-drift.cjs +337 -432
  380. package/scripts/lint-test-file-count.allowlist.json +122 -4
  381. package/scripts/lint-test-file-count.cjs +25 -3
  382. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  383. package/scripts/lint-vendored-deps.cjs +208 -35
  384. package/scripts/mutation-matrix.cjs +599 -50
  385. package/scripts/prompt-injection-scan.sh +75 -14
  386. package/scripts/secret-scan.sh +75 -13
  387. package/scripts/select-docs-guards.cjs +56 -0
  388. package/scripts/sync-runtime-launcher.cjs +22 -3
  389. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  390. package/skills/gsd-import/SKILL.md +1 -1
  391. package/skills/gsd-quick/SKILL.md +8 -4
  392. package/vscode/package.json +1 -1
  393. package/bin/lib/ui-safety-gate.cjs +0 -109
  394. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  395. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -16,6 +16,12 @@ const pattern_cjs_1 = require("./pattern.cjs");
16
16
  const ioMod = require("./io.cjs");
17
17
  const { output, error } = ioMod;
18
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ const cliExitModule = require("./cli-exit.cjs");
20
+ const { ExitError } = cliExitModule;
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ const stateContract = require("./state-contract.cjs");
23
+ const { publishStateContract } = stateContract;
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
25
  const configLoaderMod = require("./config-loader.cjs");
20
26
  const { loadConfig } = configLoaderMod;
21
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -23,7 +29,8 @@ const phaseIdMod = require("./phase-id.cjs");
23
29
  const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId, scopeToPhase, } = phaseIdMod;
24
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
31
  const roadmapParserMod = require("./roadmap-parser.cjs");
26
- const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasMilestoneSectioning } = roadmapParserMod;
32
+ // #3642: hasMilestoneSectioning no longer consumed here — its >=2 semantics answered sibling conflation, but this branch asks asserted-vs-section (>=1). It stays exported from roadmap-parser.cjs for its unit pins.
33
+ const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasAnyMilestoneSection } = roadmapParserMod;
27
34
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
28
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
36
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -31,7 +38,18 @@ const { planningDir, planningPaths } = planningWorkspace;
31
38
  const clock_cjs_1 = require("./clock.cjs");
32
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports
33
40
  const frontmatter = require("./frontmatter.cjs");
34
- const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel } = frontmatter;
41
+ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel, FRONTMATTER_UNPARSEABLE } = frontmatter;
42
+ /**
43
+ * ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
44
+ * `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a frontmatter-fenced region
45
+ * exists but failed to parse (malformed YAML, or a refused anchor/alias/merge key)? Mirrors
46
+ * `state-transition.cts`'s private helper of the same name/shape — kept local rather than
47
+ * exported+imported because the two modules' `existingFm` values come from independent
48
+ * `extractFrontmatter` calls and this predicate is a two-line symbol read, not shared state.
49
+ */
50
+ function isUnparseableFrontmatter(existingFm) {
51
+ return existingFm[FRONTMATTER_UNPARSEABLE] === true;
52
+ }
35
53
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
54
  const scanPhasePlans = require("./plan-scan.cjs");
37
55
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -45,6 +63,10 @@ const phaseLocatorMod = require("./phase-locator.cjs");
45
63
  const { listMilestonePhaseDirs } = phaseLocatorMod;
46
64
  // eslint-disable-next-line @typescript-eslint/no-require-imports
47
65
  const stateTransitionMod = require("./state-transition.cjs");
66
+ // #3873 (ADR-3473 §8.8): FRONTMATTER_KEY_TO_BODY_LABEL below is now a
67
+ // projection of this leaf schema rather than a hand-maintained literal.
68
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
69
+ const stateMdSchemaMod = require("./state-md-schema.cjs");
48
70
  // #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only
49
71
  // node builtins, so it introduces no cycle on this path.
50
72
  const project_root_cjs_1 = require("./project-root.cjs");
@@ -54,6 +76,12 @@ const project_root_cjs_1 = require("./project-root.cjs");
54
76
  // eslint-disable-next-line @typescript-eslint/no-require-imports
55
77
  const milestoneLockMod = require("./milestone-lock.cjs");
56
78
  const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
79
+ // #3699: the frontmatter-key <-> body-field routing behind `state update`'s
80
+ // failure explanation, and the classification table it falls back to.
81
+ const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
82
+ // ADR-3473 §8.7 (#3872): the declared dotted-leaf enumeration `reconcileReportedFields`
83
+ // diffs against — see `declaredLeavesOf` below.
84
+ const { FIELD_CLASSIFICATION } = stateTransitionMod;
57
85
  const state_document_cjs_1 = require("./state-document.cjs");
58
86
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
59
87
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
@@ -233,7 +261,7 @@ function cmdStateLoad(cwd, raw) {
233
261
  `state_exists=${stateExists}`,
234
262
  ];
235
263
  process.stdout.write(lines.join('\n'));
236
- process.exit(0);
264
+ throw new ExitError(0);
237
265
  }
238
266
  output(result, false, undefined);
239
267
  }
@@ -312,14 +340,15 @@ function cmdStatePatch(cwd, patches, raw) {
312
340
  // delta (table-driven) that this phase adds. Field-name validation (security)
313
341
  // and the resync-progress decision stay in this adapter.
314
342
  let precomputed = { updated: [], failed: [] };
315
- let preSyncContent = '';
316
343
  const divergedFields = [];
344
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
345
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
346
+ const preWriteState = {};
317
347
  readModifyWriteStateMd(statePath, (content) => {
318
348
  const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock });
319
349
  precomputed = result.data ?? precomputed;
320
- preSyncContent = result.content;
321
350
  return result.content;
322
- }, cwd, { resync: shouldResync, divergedFields });
351
+ }, cwd, { resync: shouldResync, divergedFields, explicitProgressField: shouldResync, preWriteState });
323
352
  // ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
324
353
  // patchCore's bookkeeping says whether the stateReplaceField text-replace
325
354
  // MATCHED — but its plain-line pattern (`m` flag over the full document)
@@ -334,7 +363,7 @@ function cmdStatePatch(cwd, patches, raw) {
334
363
  // `applyStatePreservation` restored that this patch never named at all
335
364
  // (#3345's direction), a case the pre-#3471 version of this command never
336
365
  // covered.
337
- const updated = reconcileReportedFields(statePath, preSyncContent, precomputed.updated, divergedFields);
366
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields);
338
367
  const updatedSet = new Set(updated);
339
368
  const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
340
369
  const results = { updated, failed };
@@ -344,6 +373,53 @@ function cmdStatePatch(cwd, patches, raw) {
344
373
  error('STATE.md not found');
345
374
  }
346
375
  }
376
+ /**
377
+ * Why did `state update <field>` not write anything?
378
+ *
379
+ * #3699: this used to be one sentence — `Field "X" not found in STATE.md` — for
380
+ * every falsy outcome, so a PRESENT-but-derived frontmatter key and a genuinely
381
+ * absent field produced byte-identical output apart from the name. The classifier
382
+ * already knew the difference; the message threw it away, and worse, pointed away
383
+ * from the route that works.
384
+ *
385
+ * Four distinct answers, because there are four distinct situations:
386
+ * 1. a body-derived frontmatter key whose body source EXISTS → name that source
387
+ * 2. a frontmatter key with no body source at all (disk/external/clock-derived)
388
+ * → say what derives it, and do not invent a body field to blame
389
+ * 3. a body field that feeds a frontmatter key still carrying a value
390
+ * → name the key, so case D is diagnosable rather than a bare absence
391
+ * 4. genuinely unknown → unchanged
392
+ */
393
+ function explainUpdateFailure(field) {
394
+ const bodySource = getFrontmatterBodySource(field);
395
+ if (bodySource) {
396
+ // (1) The fallback in `updateCore` did not fire, so a body source line
397
+ // exists — that is the writable route.
398
+ const [primary] = bodySource;
399
+ return `Field "${field}" is a body-derived frontmatter key and is not directly writable. `
400
+ + `Update its body source instead: state update "${primary}" <value>.`;
401
+ }
402
+ const classification = getFieldClassification(field);
403
+ if (classification) {
404
+ // (2) Known key, no body source: disk/external/free-derived.
405
+ const derivedFrom = {
406
+ disk: 'derived from a scan of .planning/phases/ and is not directly writable',
407
+ external: 'derived from ROADMAP.md and is not directly writable',
408
+ free: 'recomputed on every write and is not directly writable',
409
+ curated: 'maintained by the write path and is not directly writable through this command',
410
+ body: 'body-derived and is not directly writable',
411
+ };
412
+ return `Field "${field}" is a frontmatter key that is ${derivedFrom[classification.source]}.`;
413
+ }
414
+ const owningKey = frontmatterKeyForBodyField(field);
415
+ if (owningKey) {
416
+ // (3) Case D from the body-field side.
417
+ return `Field "${field}" not found in STATE.md. It is the body source for frontmatter key `
418
+ + `"${owningKey}" — add the "${field}:" line to the body, or update "${owningKey}" directly `
419
+ + `to repair a document whose body source is missing.`;
420
+ }
421
+ return `Field "${field}" not found in STATE.md`; // (4) genuinely unknown
422
+ }
347
423
  function cmdStateUpdate(cwd, field, value) {
348
424
  if (!field || value === undefined) {
349
425
  error('field and value required for state update');
@@ -358,7 +434,30 @@ function cmdStateUpdate(cwd, field, value) {
358
434
  const statePath = planningPaths(cwd).state;
359
435
  try {
360
436
  let updated = false;
361
- let preSyncContent = '';
437
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
438
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
439
+ const preWriteState = {};
440
+ let transitionData;
441
+ // #3699 case D: when `updateCore` falls back to writing the frontmatter key
442
+ // directly, the value must survive the post-sync pass — otherwise the write
443
+ // is silently undone. `buildStateFrontmatter` re-derives `stopped_at` from
444
+ // the body, finds no source, and emits nothing; `applyPreserveWhenUnchanged`
445
+ // then sees an unchanged (absent) body source and restores the PRE-write
446
+ // snapshot over the value just written. Verified: without this the command
447
+ // reported `updated:false` with `preserved:["Stopped At"]` and the old value
448
+ // stood.
449
+ //
450
+ // `authoritativeFm` is the seam built for exactly this (#2736 — "intent-first
451
+ // frontmatter values … so the lossy body-prose re-derivation can never
452
+ // destroy information the transition just resolved"), and it is re-applied
453
+ // AFTER preservation (`applyPostSyncPreservation`), so it wins the restore.
454
+ //
455
+ // Populated by the transform below rather than up front, because only the
456
+ // transition knows whether the fallback fired. Safe: `readModifyWriteStateMd`
457
+ // dereferences `options.authoritativeFm` after running the transform. Left
458
+ // empty when the fallback does not fire — an empty object iterates zero
459
+ // entries and is a no-op at both application sites.
460
+ const authoritativeFm = {};
362
461
  const divergedFields = [];
363
462
  const shouldResync = shouldResyncStateProgress([field]);
364
463
  // ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
@@ -370,9 +469,12 @@ function cmdStateUpdate(cwd, field, value) {
370
469
  readModifyWriteStateMd(statePath, (content) => {
371
470
  const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock });
372
471
  updated = result.data?.updated === true;
373
- preSyncContent = result.content;
472
+ transitionData = result.data;
473
+ if (transitionData?.wroteFrontmatter === true) {
474
+ authoritativeFm[field] = value;
475
+ }
374
476
  return result.content;
375
- }, cwd, { resync: shouldResync, divergedFields });
477
+ }, cwd, { resync: shouldResync, divergedFields, authoritativeFm, explicitProgressField: shouldResync, preWriteState });
376
478
  // ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
377
479
  // `updateCore`'s own match does not know whether sync/preservation later
378
480
  // discarded the value it wrote (#3351's direction, generalized from
@@ -380,14 +482,37 @@ function cmdStateUpdate(cwd, field, value) {
380
482
  // restored during this write that this command never touched at all
381
483
  // (#3345's direction) — reported separately from `updated` because this
382
484
  // command's contract is a single-field boolean, not a per-field array.
383
- const reconciled = reconcileReportedFields(statePath, preSyncContent, updated ? [field] : [], divergedFields);
485
+ const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
384
486
  updated = reconciled.includes(field);
385
487
  const preserved = reconciled.filter((f) => f !== field);
386
488
  if (updated) {
387
- output({ updated: true, preserved }, false, undefined);
489
+ // #3699 case D: surfaced so a caller can tell "wrote the body source" from
490
+ // "wrote the frontmatter key because no body source existed" — the second
491
+ // is a repair, and silently reporting it as an ordinary update is the same
492
+ // class of unfalsifiable success this issue is about.
493
+ const wroteFrontmatter = transitionData?.wroteFrontmatter === true;
494
+ if (!wroteFrontmatter) {
495
+ output({ updated: true, preserved }, false, undefined);
496
+ }
497
+ else {
498
+ // `preserved` reports the BODY LABEL of each field preservation restored
499
+ // (`bodyLabelFor`, the #3345 direction). In the case-D fallback that
500
+ // reading is stale by one step: preservation DID restore this field's
501
+ // snapshot, and `authoritativeFm` then overrode it, so the value on disk
502
+ // is the one just written. Reporting it as preserved would claim a
503
+ // restore that did not survive — the same unfalsifiable-success shape
504
+ // #3699 is about, one field over. Drop this field's own labels; other
505
+ // fields' preservation is untouched and still reported.
506
+ const ownLabels = new Set((getFrontmatterBodySource(field) ?? []).map((l) => l.toLowerCase()));
507
+ output({
508
+ updated: true,
509
+ wrote: 'frontmatter',
510
+ preserved: preserved.filter((p) => !ownLabels.has(p.toLowerCase())),
511
+ }, false, undefined);
512
+ }
388
513
  }
389
514
  else {
390
- output({ updated: false, reason: `Field "${field}" not found in STATE.md`, preserved }, false, undefined);
515
+ output({ updated: false, reason: explainUpdateFailure(field), preserved }, false, undefined);
391
516
  }
392
517
  }
393
518
  catch {
@@ -434,13 +559,15 @@ function cmdStateAdvancePlan(cwd, raw) {
434
559
  };
435
560
  let resultData;
436
561
  let precomputedUpdated = [];
437
- let preSyncContent = '';
438
562
  const divergedFields = [];
563
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
564
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
565
+ const preWriteState = {};
439
566
  // #3311: the milestone (phase + session) claim is consulted INSIDE the
440
567
  // STATE.md lock, so the position read and the claim read cannot interleave
441
568
  // with another session's Current Position write.
442
569
  let milestoneConflict = null;
443
- readModifyWriteStateMd(statePath, (content) => {
570
+ const wrote = readModifyWriteStateMd(statePath, (content) => {
444
571
  // advance-plan has no phase argument of its own — the phase it advances is
445
572
  // whatever ## Current Position names. Compare that against the milestone
446
573
  // claim: a mismatch means another session moved the single-slot position
@@ -458,10 +585,19 @@ function cmdStateAdvancePlan(cwd, raw) {
458
585
  const result = transitionCore(content, intent, deps);
459
586
  resultData = result.data;
460
587
  precomputedUpdated = result.updated;
461
- preSyncContent = result.content;
462
588
  return result.content;
463
- }, cwd, { divergedFields });
589
+ }, cwd, { divergedFields, preWriteState });
464
590
  if (!resultData || resultData['error']) {
591
+ // #3807: a multi-`Phase:` Current Position section carries its own cause
592
+ // and its own remedy (name the candidates; the caller resolves them).
593
+ if (resultData && resultData['reason'] === 'ambiguous_position_phase') {
594
+ output({
595
+ error: 'Current Position section contains more than one Phase: entry — refusing to silently advance the first. Resolve the section to a single current entry and re-run.',
596
+ reason: resultData['reason'],
597
+ phase_candidates: resultData['phase_candidates'],
598
+ }, raw, undefined);
599
+ return;
600
+ }
465
601
  output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
466
602
  return;
467
603
  }
@@ -471,13 +607,25 @@ function cmdStateAdvancePlan(cwd, raw) {
471
607
  // Generalizes fix(#3351) (closes #3351's direction) and folds in any field
472
608
  // preservation restored that this transform never touched (#3345's
473
609
  // direction).
474
- const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
610
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
475
611
  if (resultData['advanced'] === false) {
476
612
  output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'false');
477
613
  }
478
614
  else {
479
615
  output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
480
616
  }
617
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): a refreshed
618
+ // state.json `updated_at` must always mean something on disk actually
619
+ // moved, in EITHER branch above — so gate on `wrote`
620
+ // (readModifyWriteStateMd's own return value) rather than assuming both
621
+ // branches are unconditional mutations. They are not: re-running
622
+ // advance-plan on a phase already parked in its post-advance state (e.g.
623
+ // two same-day calls once a phase is "ready for verification") reproduces
624
+ // byte-identical content, the #948 no-op guard skips the write, and
625
+ // `resultData`/`updated` still populate normally from the transform's OWN
626
+ // (unwritten) output — so those are not safe publish signals here either.
627
+ if (wrote)
628
+ publishStateContract(cwd);
481
629
  }
482
630
  function cmdStateRecordMetric(cwd, options, raw) {
483
631
  const statePath = planningPaths(cwd).state;
@@ -1138,8 +1286,10 @@ function cmdStateRecordSession(cwd, options, raw) {
1138
1286
  const now = clock_cjs_1.realClock.nowIso();
1139
1287
  const updated = [];
1140
1288
  let sessionCreated = false;
1141
- let preSyncContent = '';
1142
1289
  const divergedFields = [];
1290
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
1291
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
1292
+ const preWriteState = {};
1143
1293
  readModifyWriteStateMd(statePath, (content) => {
1144
1294
  // Update Last session / Last Date
1145
1295
  let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
@@ -1353,14 +1503,13 @@ function cmdStateRecordSession(cwd, options, raw) {
1353
1503
  updated.push('Resume File');
1354
1504
  }
1355
1505
  }
1356
- preSyncContent = content;
1357
1506
  return content;
1358
- }, cwd, { divergedFields });
1507
+ }, cwd, { divergedFields, preWriteState });
1359
1508
  // ADR-3408 §8.4 (D4): reconcile this command's own success list against the
1360
1509
  // bytes actually persisted (fix(#3351) generalized) and fold in any field
1361
1510
  // preservation restored that this transform never touched (#3345's
1362
1511
  // direction).
1363
- const reconciledUpdated = reconcileReportedFields(statePath, preSyncContent, updated, divergedFields);
1512
+ const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields);
1364
1513
  if (reconciledUpdated.length > 0) {
1365
1514
  const result = { recorded: true, updated: reconciledUpdated };
1366
1515
  if (sessionCreated)
@@ -1961,10 +2110,19 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1961
2110
  // deliberately weaker than isMilestoneBoundedInRoadmap above (no
1962
2111
  // version-token requirement); see hasMilestoneSectioning's own
1963
2112
  // doc comment for why that distinction is load-bearing.
1964
- const roadmapHasMilestoneSectioning = roadmapRaw !== null
1965
- && hasMilestoneSectioning(roadmapRaw);
2113
+ // #3642: the flat test uses the >=1 sibling (hasAnyMilestoneSection),
2114
+ // not the >=2 predicate. >=2 under-answers the question this branch
2115
+ // asks: with EXACTLY ONE milestone section and an asserted milestone
2116
+ // absent from the ROADMAP, >=2 read "flat" and the whole-document
2117
+ // count — which IS that single section's phases — was written as the
2118
+ // asserted milestone's total, silently clobbering the stored value.
2119
+ // The >=2 threshold governs SIBLING conflation; asserted-vs-section
2120
+ // needs only one section to go wrong. Zero sections (genuinely flat)
2121
+ // keeps the whole-document count, per #2828.
2122
+ const roadmapHasAnyMilestoneSection = roadmapRaw !== null
2123
+ && hasAnyMilestoneSection(roadmapRaw);
1966
2124
  const safeToUseRoadmapCount = milestoneBounded
1967
- || (roadmapPhaseCount > 0 && !roadmapHasMilestoneSectioning);
2125
+ || (roadmapPhaseCount > 0 && !roadmapHasAnyMilestoneSection);
1968
2126
  // #3354: the milestoned-but-unbounded sibling of the #2828/#3204
1969
2127
  // shapes. The whole-document roadmapPhaseCount is rightly rejected
1970
2128
  // above (it would conflate sibling milestones, #1761), but the
@@ -1980,9 +2138,9 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
1980
2138
  // The degenerate un-sectioned zero-heading case keeps the
1981
2139
  // phaseDirs.length fallback — with nothing declared anywhere else,
1982
2140
  // the disk count is the only source and remains correct.
1983
- const milestonedButUnbounded = !milestoneBounded && roadmapHasMilestoneSectioning;
2141
+ const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
1984
2142
  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`);
2143
+ process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3354/#3642)\n`);
1986
2144
  }
1987
2145
  // #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
1988
2146
  // absent/unreadable the #549 heading counter never ran (roadmapScope
@@ -2344,6 +2502,28 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
2344
2502
  // `cwd` already identifies the workspace this content came from, so the STATE.md path is
2345
2503
  // derivable here without widening the signature (#1882).
2346
2504
  const existingFm = extractFrontmatter(content, cwd ? planningPaths(cwd).state : undefined);
2505
+ // #3881 review, second round: an UNPARSEABLE frontmatter block (malformed YAML, a git
2506
+ // merge-conflict marker, a refused anchor) must never be silently REPLACED by a freshly
2507
+ // re-derived one — that destroys the only copy of what the block actually contained, with
2508
+ // no signal to the human that their document was in conflict. `beginFrontmatterReassembly`
2509
+ // (state-transition.cts) already preserves the raw fmPrefix through the pure transform
2510
+ // layer for every `transitionCore` kind; this was the gap — this function re-parses the
2511
+ // ALREADY-preserved `content` and, finding {} + the marker, rebuilt a fresh block anyway,
2512
+ // discarding the raw prefix the transform layer had just protected. Confirmed by execution
2513
+ // against `state complete-phase`/`update`/`patch`/`begin-phase`: each returned success with
2514
+ // the conflict markers gone and a freshly-derived, well-formed frontmatter block in their
2515
+ // place (re-derivation, not deletion — the document never lost its frontmatter FENCE).
2516
+ //
2517
+ // `sanctionedPermanentEmptyFallback` is threaded ONLY from `writeStateMd`, itself consumed
2518
+ // ONLY by `cmdStateSync` (#905) and `/gsd-health --repair`'s `REGENERATE_STATE` — ADR-3408
2519
+ // §8.3's CLOSED list of commands whose documented contract is "body wins, re-derive
2520
+ // unconditionally" (a factory reset / explicit resync). Those two are untouched here: this
2521
+ // guard fires only on the OTHER call path (`syncAndPreserveStateMd`, i.e. every
2522
+ // `readModifyWriteStateMd`-based command), where re-deriving over unparseable content was
2523
+ // never the intended contract in the first place — it was an unhandled gap, not a decision.
2524
+ if (!sanctionedPermanentEmptyFallback && isUnparseableFrontmatter(existingFm)) {
2525
+ return content;
2526
+ }
2347
2527
  const body = stripFrontmatter(content);
2348
2528
  // #3017: pass the stored milestone from the existing frontmatter so
2349
2529
  // buildStateFrontmatter scopes its disk scan to the correct milestone
@@ -2531,7 +2711,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
2531
2711
  // #3257: propagate full-line frontmatter comments from the extracted source onto the
2532
2712
  // rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both
2533
2713
  // skip the Symbol-keyed channel, so without this the comments would be lost here even
2534
- // though parseYamlRegion/reconstructFrontmatter preserve them in isolation).
2714
+ // though parseGuardedYamlRegion/reconstructFrontmatter preserve them in isolation).
2535
2715
  propagateCommentChannel(existingFm, derivedFm);
2536
2716
  const yamlStr = reconstructFrontmatter(derivedFm);
2537
2717
  return `---\n${yamlStr}\n---\n\n${body}`;
@@ -2783,7 +2963,23 @@ function withStateLock(statePath, fn) {
2783
2963
  * @param clock
2784
2964
  * Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
2785
2965
  */
2786
- function writeStateMd(statePath, content, cwd, clock) {
2966
+ /**
2967
+ * ADR-3473 §8.6: `writeStateMd` is ADR-3408 §8.3's sanctioned-exception write
2968
+ * path — only a `rebuildStateTransaction` may travel it. Enforced here rather
2969
+ * than left to caller discipline: the transaction TYPE is what makes the two
2970
+ * sanctioned exceptions (`cmdStateSync`, `REGENERATE_STATE`) greppable and
2971
+ * closed, and an `open()` transaction reaching this function would mean a
2972
+ * preservation-governed write silently skipped preservation.
2973
+ */
2974
+ function writeStateMd(statePath, content, transaction, cwd, clock) {
2975
+ if (transaction.kind !== 'rebuild') {
2976
+ const err = new Error(`writeStateMd: expected a 'rebuild' transaction, got '${transaction.kind}'. writeStateMd is ` +
2977
+ 'ADR-3408 §8.3\'s sanctioned-exception write path (cmdStateSync / REGENERATE_STATE only) — ' +
2978
+ 'only rebuildStateTransaction() may travel it (ADR-3473 §8.6). An open() transaction here ' +
2979
+ 'would silently skip preservation for a write that was supposed to run it.');
2980
+ err.code = 'STATE_TRANSACTION_KIND_INVALID';
2981
+ throw err;
2982
+ }
2787
2983
  const lockPath = acquireStateLock(statePath, clock);
2788
2984
  // Test seam (audit M8): fire AFTER the lock is taken so a test can simulate a
2789
2985
  // concurrent writer landing in the (now-closed) scan→lock window.
@@ -2805,10 +3001,14 @@ function writeStateMd(statePath, content, cwd, clock) {
2805
3001
  _diskScanCache.delete(cwd);
2806
3002
  // ADR-3408 §8.3: `writeStateMd` is the sole write path for the two
2807
3003
  // 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);
3004
+ // the sanctioned-permanent empty-field fallback is now DERIVED FROM THE
3005
+ // TRANSACTION KIND (ADR-3473 §8.6) rather than asserted by a literal
3006
+ // `true` at this call site: only a `rebuild` transaction can reach this
3007
+ // function (enforced above), so `transaction.kind === 'rebuild'` is
3008
+ // always `true` here today, but the derivation is what keeps the
3009
+ // fallback's scope tied to the transaction type rather than a
3010
+ // hard-coded constant that could silently drift from it.
3011
+ const synced = syncStateFrontmatter(content, cwd, undefined, transaction.kind === 'rebuild');
2812
3012
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
2813
3013
  }
2814
3014
  finally {
@@ -2863,10 +3063,7 @@ function assertStatePreservationOptions(options, caller) {
2863
3063
  }
2864
3064
  function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options) {
2865
3065
  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);
3066
+ const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField, preWriteState } = options;
2870
3067
  // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
2871
3068
  // we can detect whether THIS write changed them. syncStateFrontmatter
2872
3069
  // re-derives frontmatter status/stopped_at from the body on every write;
@@ -2877,8 +3074,24 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
2877
3074
  // above (null when resync:true) — these are independent snapshots.
2878
3075
  // Strip frontmatter before calling stateExtractField so the YAML `status:`
2879
3076
  // key in the frontmatter block cannot shadow the body field we are tracking.
2880
- const preBody = stripFrontmatter(originalContent);
2881
3077
  const preFmSnapshot = extractFrontmatter(originalContent, statePath);
3078
+ // #3881 review, second round: `syncStateFrontmatter` above already declines to re-derive
3079
+ // over an UNPARSEABLE original frontmatter block (its own matching guard), so `syncedContent`
3080
+ // here is `transformedContent` verbatim. But this function's own downstream preservation
3081
+ // machinery (`applyStatePreservation` + the `authoritativeFm` reassertion below) reads
3082
+ // `postFm = extractFrontmatter(syncedContent, ...)` — {} + the marker, since the block still
3083
+ // doesn't parse — restores curated fields from `transaction.snapshot`, and reconstructs a
3084
+ // FRESH frontmatter block from the result, destroying the raw block a second time even
3085
+ // though `syncStateFrontmatter` just finished protecting it. `applyPostSyncPreservation` is
3086
+ // reached ONLY via the non-sanctioned path (`syncAndPreserveStateMd`; `writeStateMd`'s two
3087
+ // ADR-3408 §8.3 closed-list callers — `cmdStateSync` #905 and `/gsd-health --repair`'s
3088
+ // `REGENERATE_STATE` — never call it at all), so this guard needs no extra parameter to stay
3089
+ // scoped off that list. Confirmed by execution: `state begin-phase` on a conflict-marked
3090
+ // STATE.md reached exactly this second clobber even after the `syncStateFrontmatter` fix.
3091
+ if (isUnparseableFrontmatter(preFmSnapshot)) {
3092
+ return transformedContent;
3093
+ }
3094
+ const preBody = stripFrontmatter(originalContent);
2882
3095
  const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
2883
3096
  // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
2884
3097
  // mirroring buildStateFrontmatter's sessionBodyScope logic.
@@ -2958,6 +3171,16 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
2958
3171
  status: { pre: preBodyStatus, post: postBodyStatus },
2959
3172
  stopped_at: { pre: preBodyStoppedAt, post: postBodyStoppedAt },
2960
3173
  current_phase_name: { pre: preBodyPhaseSource, post: postBodyPhaseSource },
3174
+ // ADR-3473 §8.7 (#3872): `last_activity` is the one `FRONTMATTER_BODY_SOURCE`
3175
+ // key that is NOT `preserve-when-unchanged` (it is `derive` — always
3176
+ // re-stamped from the body) and so was never part of this map before.
3177
+ // Added ONLY for `reconcileReportedFields`'s consumption below (via
3178
+ // `preWriteState.bodyDeltas`) — harmless here, since
3179
+ // `applyPreserveWhenUnchanged` is dispatched by
3180
+ // `getPreserveWhenUnchangedFields()`, never by iterating this object's
3181
+ // keys, so an extra non-preserve-when-unchanged entry changes no
3182
+ // preservation behavior.
3183
+ last_activity: { pre: preBodyLastActivityRaw, post: postBodyLastActivityRaw },
2961
3184
  };
2962
3185
  // ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
2963
3186
  // preservation block is now the pure, table-driven `applyStatePreservation`
@@ -2977,11 +3200,34 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
2977
3200
  // callers that omit it (readModifyWriteStateMd, cmdPhaseComplete) pay
2978
3201
  // nothing extra and see no change to `synced`/the returned content.
2979
3202
  const preservationInputSnapshot = divergedFields ? { ...postFm } : null;
2980
- const preservation = applyStatePreservation({
2981
- preFm, postFm, preFmSnapshot, resync,
3203
+ // ADR-3473 §8.6: the pre-write snapshot + policy flags now travel as ONE
3204
+ // transaction rather than as a nullable `preFm` alongside the always-present
3205
+ // `preFmSnapshot` (same source, same extractFrontmatter call — `preFm` was
3206
+ // `preFmSnapshot` with the `resync` policy baked in by nulling it, which is
3207
+ // what made `applyPreserveAlways` inert on the default resyncing write
3208
+ // path — #3756).
3209
+ const transaction = stateTransitionMod.openStateTransaction({
3210
+ snapshot: preFmSnapshot,
3211
+ resync,
2982
3212
  deriveProgressKeys: deriveProgressKeys === true,
2983
3213
  bodyDeltas,
3214
+ explicitProgressField: explicitProgressField === true,
2984
3215
  });
3216
+ // ADR-3473 §8.7 (#3872): fill the caller's out-param with the TRANSACTION'S
3217
+ // OWN snapshot object (not a second `extractFrontmatter(originalContent)`
3218
+ // derivation — `transaction.snapshot === preFmSnapshot`, reusing it is the
3219
+ // whole point) plus the pre-write body, so `reconcileReportedFields` can
3220
+ // diff persisted-vs-pre-write instead of re-deriving either side itself.
3221
+ if (preWriteState) {
3222
+ preWriteState.fm = transaction.snapshot;
3223
+ preWriteState.body = preBody;
3224
+ // ADR-3473 §8.7 (#3872): the pre/post body-source delta for every
3225
+ // FRONTMATTER_BODY_SOURCE key — see `StatePreWriteSnapshot`'s docstring
3226
+ // for why `reconcileReportedFields` needs this instead of a raw
3227
+ // frontmatter diff for these specific keys.
3228
+ preWriteState.bodyDeltas = bodyDeltas;
3229
+ }
3230
+ const preservation = applyStatePreservation({ transaction, postFm });
2985
3231
  if (divergedFields && preservationInputSnapshot) {
2986
3232
  // §8.5's "liberal but visible": every field whose value actually
2987
3233
  // differs before vs after `applyStatePreservation` is a field where the
@@ -2993,10 +3239,13 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
2993
3239
  for (const key of Object.keys(preservation.postFm)) {
2994
3240
  const before = preservationInputSnapshot[key];
2995
3241
  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)
3242
+ // ADR-3473 §8.7 (#3872 standards-axis finding): route through the ONE
3243
+ // owner of this comparison rule (`stateFieldValuesDiffer`, defined
3244
+ // below) instead of carrying a second inline `JSON.stringify`-vs-`!==`
3245
+ // copy — this is exactly the duplicated-rule shape this epic exists to
3246
+ // remove. `stateFieldValuesDiffer` is a function declaration (hoisted),
3247
+ // so calling it here, above its textual definition, is safe.
3248
+ if (stateFieldValuesDiffer(before, after))
3000
3249
  divergedFields.push(key);
3001
3250
  }
3002
3251
  // ADR-3408 §8.5 Row 2 (D1's actual bug, the reason the guards had to be
@@ -3043,6 +3292,23 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3043
3292
  }
3044
3293
  }
3045
3294
  if (preservation.mutated || authoritativeReasserted) {
3295
+ // #3742: preservation RESTORES frontmatter keys the body-derived rebuild
3296
+ // could not produce (e.g. `current_phase` on a layout with no body
3297
+ // `**Current Phase:**` line) — but the comment channel was filtered
3298
+ // against the pre-restore key set during sync, so a full-line comment
3299
+ // attached to a restored key died with nothing to re-attach it. Propagate
3300
+ // the channel from the PRE-WRITE snapshot here, after the restores, so a
3301
+ // comment's survival depends on its key surviving the whole write — not
3302
+ // on which body line happened to feed the rebuild. Merge semantics
3303
+ // (propagateCommentChannel) keep any channel the synced content already
3304
+ // carried. No resync gate: this is the RMW path, where `resync` is the
3305
+ // DEFAULT (readModifyWriteStateMd derives it as `options.resync !==
3306
+ // false`) and preservation itself runs regardless — the factory-reset
3307
+ // semantic the #3742 review worried about lives in writeStateMd's
3308
+ // `rebuild` transactions, which never reach this branch.
3309
+ if (preFmSnapshot && !isUnparseableFrontmatter(preFmSnapshot)) {
3310
+ propagateCommentChannel(preFmSnapshot, preservation.postFm);
3311
+ }
3046
3312
  const yamlStr = reconstructFrontmatter(preservation.postFm);
3047
3313
  const body = stripFrontmatter(syncedContent);
3048
3314
  return `---\n${yamlStr}\n---\n\n${body}`;
@@ -3127,6 +3393,12 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
3127
3393
  authoritativeFm: options?.authoritativeFm,
3128
3394
  deriveProgressKeys: options?.deriveProgressKeys === true,
3129
3395
  divergedFields: options?.divergedFields,
3396
+ explicitProgressField: options?.explicitProgressField === true,
3397
+ // ADR-3473 §8.7 (#3872): forwarded so `applyPostSyncPreservation` can
3398
+ // fill it — an unenumerated option here is silently dropped
3399
+ // (Phase 1's commit message; #3871), which is exactly how a prior cut
3400
+ // of this option would have gone missing.
3401
+ preWriteState: options?.preWriteState,
3130
3402
  });
3131
3403
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
3132
3404
  return true;
@@ -3158,15 +3430,36 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
3158
3430
  * exactly that closed, tested set (`tests/state.test.cjs` A2f pins
3159
3431
  * `divergedFields` reporting bare `'progress'`).
3160
3432
  */
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
- });
3433
+ /**
3434
+ * #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
3435
+ * (`src/state-md-schema.cts`)'s `bodyLabel` field, in this EXPLICIT key
3436
+ * order — the pre-#3873 literal's own order, which puts `status` AFTER
3437
+ * `stopped_at`/`paused_at` (the opposite of `FRONTMATTER_BODY_SOURCE`'s order
3438
+ * in `state-transition.cts`; the two pre-existing tables disagreed with each
3439
+ * other's order too, so each projection reproduces its OWN table's order
3440
+ * rather than a shared derivation). Byte-identical to the pre-#3873 literal:
3441
+ * same 7 keys, same order, same frozen (NOT null-prototype — this table was
3442
+ * a plain `Object.freeze({...})` literal before #3873 and stays one) shape.
3443
+ * `last_activity` is deliberately excluded — see `STATE_FIELD_SCHEMA`'s
3444
+ * `last_activity` row docstring for the resolved disagreement. Pinned by
3445
+ * `tests/state.test.cjs`'s `bodyLabelProjectionMatchesTodaysTable` and
3446
+ * `lastActivityLabelResolutionMatchesShippedBehavior`.
3447
+ */
3448
+ const FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER = Object.freeze([
3449
+ 'current_phase',
3450
+ 'current_phase_name',
3451
+ 'current_plan',
3452
+ 'stopped_at',
3453
+ 'paused_at',
3454
+ 'status',
3455
+ 'last_activity_desc',
3456
+ ]);
3457
+ const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze(FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER.reduce((acc, key) => {
3458
+ const row = stateMdSchemaMod.STATE_FIELD_SCHEMA[key];
3459
+ if (row.bodyLabel !== undefined)
3460
+ acc[key] = row.bodyLabel;
3461
+ return acc;
3462
+ }, {}));
3170
3463
  /**
3171
3464
  * ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
3172
3465
  * entry. Throws for a `preserve-when-unchanged` field with no
@@ -3181,9 +3474,19 @@ const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze({
3181
3474
  * (e.g. `progress`), not a silent degrade.
3182
3475
  */
3183
3476
  function bodyLabelFor(field) {
3184
- const label = FRONTMATTER_KEY_TO_BODY_LABEL[field];
3185
- if (label !== undefined)
3186
- return label;
3477
+ // ADR-3473 §8.7 (#3872 review): an OWN-PROPERTY check, never a bare
3478
+ // bracket read — `FRONTMATTER_KEY_TO_BODY_LABEL` is a plain object literal
3479
+ // (real `Object.prototype` in its chain), so `[field]` for a hostile field
3480
+ // named `__proto__`/`constructor`/`toString` returns the INHERITED
3481
+ // prototype-chain member (`Object.prototype` itself, the `Object`
3482
+ // constructor function, `Object.prototype.toString`) instead of
3483
+ // `undefined` — which would then be returned as the "label" and leak a
3484
+ // non-string value into the caller's `updated` array. Proven by
3485
+ // `dottedResolutionDoesNotPollutePrototypes` (test matrix row 25) before
3486
+ // this fix. Mirrors `resolveFrontmatterPath`'s own-property discipline.
3487
+ if (Object.prototype.hasOwnProperty.call(FRONTMATTER_KEY_TO_BODY_LABEL, field)) {
3488
+ return FRONTMATTER_KEY_TO_BODY_LABEL[field];
3489
+ }
3187
3490
  const cls = stateTransitionMod.getFieldClassification(field);
3188
3491
  if (cls && cls.preservation === 'preserve-when-unchanged') {
3189
3492
  const err = new Error(`reconcileReportedFields: preserve-when-unchanged field ${JSON.stringify(field)} has no ` +
@@ -3196,98 +3499,343 @@ function bodyLabelFor(field) {
3196
3499
  return field;
3197
3500
  }
3198
3501
  /**
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.
3502
+ * ADR-3473 §8.7 (issue #3872): the provenance exclusion — the ONLY
3503
+ * frontmatter key measured to change on EVERY write, regardless of content.
3504
+ * Verified at the CLI (`40-design.md` "Two corrections from reproducing it"):
3505
+ * two content-identical writes to a git-backed fixture differ in exactly
3506
+ * this one key. `state_head` was deliberately measured OUT of this set —
3507
+ * it restamps every write but its PERSISTED VALUE changes only when git HEAD
3508
+ * actually moved, so it tracks a real fact and does not flood.
3204
3509
  *
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.
3510
+ * A CLOSED, ENUMERATED set — not a predicate or a callback (Greenspun's
3511
+ * Tenth Rule, ADR-3473 §8.7's Laws section: "the moment it takes a callback
3512
+ * it has become the classification table again under a new name"). It
3513
+ * exists to protect `src/state.cts:607` — `state.patch`'s ENTIRE
3514
+ * success/failure signal is `results.updated.length > 0` — admitting an
3515
+ * always-changing key here would make that boolean permanently `true`, so a
3516
+ * fully-failed patch would report success.
3517
+ */
3518
+ const STATE_UPDATED_PROVENANCE_EXCLUSION = Object.freeze(['last_updated']);
3519
+ /** Sentinel: "this dotted path did not resolve to any value" — distinct from every real value including `undefined`/`null`, so absence and an explicit null are never confused. */
3520
+ const STATE_FIELD_ABSENT = Symbol('state-field-absent');
3521
+ /**
3522
+ * ADR-3473 §8.7 (#3872): resolve `path` against a parsed frontmatter object.
3523
+ * Pure, never throws.
3213
3524
  *
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).
3525
+ * Order is pinned (test matrix row 26, `literalDottedKeyResolvesBeforePathTraversal`):
3526
+ * a LITERAL flat key wins first — a field name that happens to contain a `.`
3527
+ * but is stored as one flat key must not be shadowed by path traversal —
3528
+ * and only when no literal key exists does `path` get split and walked as a
3529
+ * dotted path.
3530
+ *
3531
+ * Hostile-input rows (23-25 of the test matrix) all resolve to
3532
+ * `STATE_FIELD_ABSENT` rather than throwing: a missing parent, a scalar
3533
+ * parent (`typeof cursor !== 'object'`), and — the prototype-pollution
3534
+ * case — a `__proto__`/`constructor`/`toString` segment. The own-property
3535
+ * check (`Object.prototype.hasOwnProperty.call`, never a bare `in` or
3536
+ * bracket read) is what makes the last one safe: an inherited
3537
+ * `Object.prototype` member is never mistaken for an own data key, and
3538
+ * because this function only ever READS a segment (never assigns one),
3539
+ * no prototype can be polluted by walking it.
3228
3540
  */
3229
- function reconcileReportedFields(statePath, preSyncContent, reported, divergedFields) {
3541
+ function resolveFrontmatterPath(fm, path) {
3542
+ if (Object.prototype.hasOwnProperty.call(fm, path))
3543
+ return fm[path];
3544
+ if (!path.includes('.'))
3545
+ return STATE_FIELD_ABSENT;
3546
+ let cursor = fm;
3547
+ for (const segment of path.split('.')) {
3548
+ if (typeof cursor !== 'object' || cursor === null || Array.isArray(cursor))
3549
+ return STATE_FIELD_ABSENT;
3550
+ if (!Object.prototype.hasOwnProperty.call(cursor, segment))
3551
+ return STATE_FIELD_ABSENT;
3552
+ cursor = cursor[segment];
3553
+ }
3554
+ return cursor;
3555
+ }
3556
+ /**
3557
+ * ADR-3473 §8.7 (#3872): representation-insensitive equality for a
3558
+ * persisted-vs-snapshot leaf value (test matrix rows 21/22). Frontmatter
3559
+ * scalars round-trip as STRINGS (`extractFrontmatter`, §8.1's open type
3560
+ * question) while an in-memory derivation can hold a real number or boolean
3561
+ * — a naive `!==` would report every numeric/boolean field changed on every
3562
+ * write. Mirrors the existing `divergedFields` diff's typeof-object branch
3563
+ * in `applyPostSyncPreservation` (JSON.stringify for objects, else a
3564
+ * normalized scalar compare) rather than inventing a second comparison.
3565
+ * Presence-vs-absence (`STATE_FIELD_ABSENT` on exactly one side) is always a
3566
+ * change — a deleted or newly-added key (test matrix rows 16/17) — never
3567
+ * folded into the scalar branch below it.
3568
+ */
3569
+ /**
3570
+ * ADR-3473 §8.7 (#3872): `String(v)` on an `unknown` is unsafe (a hostile
3571
+ * object could carry a custom, throwing, or `[object Object]`-degrading
3572
+ * `toString`) — narrowed per-branch here so each `String()` call below only
3573
+ * ever runs on a primitive TypeScript itself knows is safe to stringify.
3574
+ */
3575
+ function stateScalarString(v) {
3576
+ if (v === null || v === undefined)
3577
+ return '';
3578
+ if (typeof v === 'string')
3579
+ return v;
3580
+ if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint')
3581
+ return String(v);
3582
+ return JSON.stringify(v) ?? '';
3583
+ }
3584
+ function stateFieldValuesDiffer(before, after) {
3585
+ if (before === STATE_FIELD_ABSENT && after === STATE_FIELD_ABSENT)
3586
+ return false;
3587
+ if (before === STATE_FIELD_ABSENT || after === STATE_FIELD_ABSENT)
3588
+ return true;
3589
+ if (typeof before === 'object' || typeof after === 'object') {
3590
+ return JSON.stringify(before) !== JSON.stringify(after);
3591
+ }
3592
+ return stateScalarString(before).trim() !== stateScalarString(after).trim();
3593
+ }
3594
+ /**
3595
+ * ADR-3473 §8.7 (#3872): the declared dotted-leaf children of a frontmatter
3596
+ * key, read off `FIELD_CLASSIFICATION` (`progress` -> its five
3597
+ * `progress.*` rows) rather than walked from arbitrary nesting depth of a
3598
+ * user-authored document. A BOUNDED, DECLARED enumeration — the design
3599
+ * doc's Rejected #5 and the "Emit dotted leaves, not the parent" rule both
3600
+ * depend on this staying a closed set the schema names, not unbounded
3601
+ * traversal of whatever object shape happens to be on disk.
3602
+ */
3603
+ function declaredLeavesOf(key) {
3604
+ const prefix = `${key}.`;
3605
+ return Object.keys(FIELD_CLASSIFICATION).filter((k) => k.startsWith(prefix));
3606
+ }
3607
+ /**
3608
+ * ADR-3473 §8.7 (#3872): every frontmatter key — resolved at DOTTED-LEAF
3609
+ * granularity for a key with declared leaves (`progress` -> only the
3610
+ * `progress.*` leaves that actually moved, never bare `progress` itself;
3611
+ * design doc rule 4/Rejected #5) — whose PERSISTED value differs from the
3612
+ * transaction's pre-write SNAPSHOT. Pure: no I/O, no `FIELD_CLASSIFICATION`
3613
+ * preservation-policy consultation (that filter is exactly what this rule
3614
+ * deletes — ADR-3473 §8.7 "no field is excluded by classification").
3615
+ * `last_updated` is the one-element provenance exclusion; every other key,
3616
+ * including `state_head`, is a candidate.
3617
+ *
3618
+ * **A `FRONTMATTER_BODY_SOURCE` key is diffed via `bodyDeltas`, never via a
3619
+ * raw frontmatter compare.** Found while driving the #1264 regression check
3620
+ * through this rewrite at the CLI: `syncStateFrontmatter` re-derives EVERY
3621
+ * body-sourced key into frontmatter on EVERY write, independent of whether
3622
+ * this write's own transform touched it. A hand-authored (or day-1
3623
+ * bootstrap) STATE.md whose frontmatter has not yet caught up to an
3624
+ * already-stable body value — e.g. `current_phase_name` present in the body
3625
+ * but absent from a pre-write frontmatter block that only ever recorded
3626
+ * `status`/`progress` — makes that key look newly ADDED under a raw diff
3627
+ * (rows 15/17) even though nothing changed. The real "did THIS write change
3628
+ * it" signal for these keys is whether their BODY SOURCE moved, which is
3629
+ * exactly what `bodyDeltas` (built once, in `applyPostSyncPreservation`,
3630
+ * from `originalContent` vs `transformedContent`) already answers — reused
3631
+ * here rather than re-derived, and it is what correctly REPORTS #3818's
3632
+ * `current_phase` (the body source did move) while staying SILENT on a
3633
+ * merely-backfilled, body-unchanged key (the #1264 false positive this
3634
+ * function's first cut produced).
3635
+ *
3636
+ * **A declared dotted-leaf (`declaredLeavesOf`, e.g. every `progress.*` row)
3637
+ * absent from the snapshot and present in persisted is materialization, not
3638
+ * a change.** Found the same way as the paragraph above, one layer down:
3639
+ * `progress` is `source: 'disk'` (state-transition.cts), re-derived by
3640
+ * `buildStateFrontmatter`'s phase-directory scan on every write regardless
3641
+ * of whether the caller's own action touched it — and the phases directory
3642
+ * cannot move during a STATE.md write, so a fresh `progress` block appearing
3643
+ * where the snapshot had none is the scanner catching a never-synced
3644
+ * document up, not the caller changing anything. This is the SAME
3645
+ * provenance principle `STATE_UPDATED_PROVENANCE_EXCLUSION` applies to
3646
+ * `last_updated` (a field stamped by the write's occurrence, not its
3647
+ * action) — generalized to the declared-leaf case, deliberately NOT a
3648
+ * second classification-based exclusion: `progress`'s `preserve-always`
3649
+ * policy plays no part in the check below, and a leaf already PRESENT in
3650
+ * the snapshot is diffed exactly as every other field is, including
3651
+ * reporting its outright disappearance (row 16) — only the absent-in-
3652
+ * snapshot-but-materialized-in-persisted transition is suppressed.
3653
+ */
3654
+ function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
3655
+ const changed = [];
3656
+ const topKeys = new Set([...Object.keys(snapshotFm), ...Object.keys(persistedFm)]);
3657
+ for (const key of topKeys) {
3658
+ if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(key))
3659
+ continue;
3660
+ if (stateTransitionMod.getFrontmatterBodySource(key) !== null) {
3661
+ const delta = bodyDeltas ? bodyDeltas[key] : undefined;
3662
+ if (delta && stateFieldValuesDiffer(delta.pre ?? STATE_FIELD_ABSENT, delta.post ?? STATE_FIELD_ABSENT)) {
3663
+ changed.push(key);
3664
+ }
3665
+ continue;
3666
+ }
3667
+ const leaves = declaredLeavesOf(key);
3668
+ if (leaves.length > 0) {
3669
+ for (const leaf of leaves) {
3670
+ const before = resolveFrontmatterPath(snapshotFm, leaf);
3671
+ const after = resolveFrontmatterPath(persistedFm, leaf);
3672
+ // Generalizes the SAME provenance principle STATE_UPDATED_PROVENANCE_EXCLUSION
3673
+ // applies to `last_updated` one level up — this is NOT a classification-based
3674
+ // exclusion (progress's `preserve-always` policy plays no part here; that filter
3675
+ // stays deleted per §8.7). It is a fact about the DECLARED LEAF SET: every key
3676
+ // enumerated by `declaredLeavesOf` is `source: 'disk'` (state-transition.cts),
3677
+ // re-derived from a scan that cannot move during a STATE.md write (the write only
3678
+ // touches STATE.md, never the phases directory). So a leaf ABSENT from the
3679
+ // pre-write snapshot and PRESENT in persisted is the scanner catching a document
3680
+ // up to a derivation it had never synced before — the write's own OCCURRENCE
3681
+ // produced the bytes, not the caller's ACTION, exactly the `last_updated` shape.
3682
+ // A leaf already PRESENT in the snapshot behaves normally: any difference
3683
+ // (including disappearing entirely, row 16) is reported, because there the
3684
+ // snapshot proves the derivation had already run once, so a new persisted value
3685
+ // can only come from something genuinely moving (#3743/#3818).
3686
+ if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
3687
+ continue;
3688
+ if (stateFieldValuesDiffer(before, after))
3689
+ changed.push(leaf);
3690
+ }
3691
+ continue;
3692
+ }
3693
+ const before = resolveFrontmatterPath(snapshotFm, key);
3694
+ const after = resolveFrontmatterPath(persistedFm, key);
3695
+ if (stateFieldValuesDiffer(before, after))
3696
+ changed.push(key);
3697
+ }
3698
+ return changed;
3699
+ }
3700
+ /**
3701
+ * ADR-3473 §8.7 (issue #3872): the transaction diff. `updated` is derived
3702
+ * by comparing PERSISTED frontmatter against the transaction's pre-write
3703
+ * SNAPSHOT — replacing the prior comparison of the transform's own OUTPUT
3704
+ * against persisted bytes, which answered a different question ("did the
3705
+ * transform's write survive to disk", #3351) from the one §8.7 asks ("what
3706
+ * did this write actually change" — both #3351's direction and #3345/#3818's
3707
+ * fall out of ONE comparison against the pre-write state; see the design
3708
+ * doc's "ambiguity in §8.7" section for why the transform-output comparison
3709
+ * was rejected).
3710
+ *
3711
+ * No field is excluded by classification — `getFieldClassification` /
3712
+ * `preservation !== 'preserve-when-unchanged'` is gone, not relocated. The
3713
+ * ONLY exclusion is `STATE_UPDATED_PROVENANCE_EXCLUSION` (provenance, not
3714
+ * classification): an unchanged `progress` no longer needs a special filter
3715
+ * to stay unreported (#1264) because the diff itself says "unchanged" —
3716
+ * and a GENUINELY changed `progress.*` leaf (#3743, #3818) is no longer
3717
+ * suppressed by the same filter.
3718
+ *
3719
+ * @param preWriteState The transaction's pre-write snapshot + body — the
3720
+ * `preWriteState` out-param `applyPostSyncPreservation` filled during
3721
+ * THIS write (see `ReadModifyWriteOptions.preWriteState`'s docstring).
3722
+ * `.fm`/`.body` are `undefined` only when `readModifyWriteStateMd`'s own
3723
+ * #948 no-op guard fired (transform output was byte-identical to input),
3724
+ * in which case nothing was ever written and `[]` is the correct,
3725
+ * short-circuited answer — never a diff against a synthesized empty `{}`
3726
+ * snapshot, which would read every already-persisted key as newly ADDED.
3727
+ * @param reported The candidate field names — the transform's OWN success
3728
+ * list. Body Title-Case labels (`Status`, `Current Plan`, `Current
3729
+ * Position`) and frontmatter keys (including dotted leaves like
3730
+ * `progress.total_plans`) are both valid; each is resolved via the same
3731
+ * `valueOf` fallback chain used for the inclusion test below.
3732
+ * @param divergedFields Kept for signature/out-param stability (ADR-3408
3733
+ * §8.5) — populated exactly as before by `applyPostSyncPreservation` and
3734
+ * still read directly by other code and `tests/state.test.cjs`'s A2f case
3735
+ * — but no longer consulted here as a candidate SOURCE (design doc row
3736
+ * 18): the frontmatter diff subsumes what it used to contribute, and it
3737
+ * sees only what *preservation* changed, never what *sync* changed
3738
+ * (#3818's own direction), which is why keeping it as the candidate
3739
+ * source was rejected (design doc, Rejected #1).
3740
+ */
3741
+ function reconcileReportedFields(statePath, preWriteState, reported, divergedFields) {
3742
+ void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
3743
+ if (preWriteState.fm === undefined || preWriteState.body === undefined)
3744
+ return [];
3230
3745
  const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
3231
3746
  const persistedFm = extractFrontmatter(persisted, statePath);
3232
3747
  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").
3748
+ const snapshotFm = preWriteState.fm;
3749
+ const snapshotBody = preWriteState.body;
3750
+ // #3471 review (unchanged by this rewrite): body-FIRST, frontmatter-key-
3751
+ // FLAT-fallback, dotted-PATH-fallback last. Body-first mirrors the actual
3752
+ // write precedence `patchCore`/`updateCore` apply (#1162's fix — a
3753
+ // lowercase body label that happens to case-exact-match a frontmatter key
3754
+ // must still resolve against the body). `field` a literal flat key (even
3755
+ // one containing a `.`) is tried before it is split and walked as a
3756
+ // dotted path (test matrix row 26) — `resolveFrontmatterPath` pins that
3757
+ // same order for the frontmatter side alone.
3758
+ //
3759
+ // `Current Position` is special-cased: it names the WHOLE `## Current
3760
+ // Position` section, not a single `Label: value` line, so
3761
+ // `stateExtractField` can never resolve it (this is the root cause of the
3762
+ // "Current Position undercount" — a transform can correctly push
3763
+ // `'Current Position'` into its own `updated` list, and this function
3764
+ // still silently dropped it, because `valueOf` returned `null` for BOTH
3765
+ // sides and `null === null` failed the old `intended !== null` guard).
3766
+ // `sliceCurrentPositionSection` is the existing fence-aware section
3767
+ // locator (state-transition.cts) — reused rather than re-derived.
3255
3768
  const valueOf = (fm, body, field) => {
3769
+ if (field === 'Current Position') {
3770
+ const section = stateTransitionMod.sliceCurrentPositionSection(body);
3771
+ return section !== null ? section.trim() : null;
3772
+ }
3256
3773
  const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
3257
3774
  if (bodyValue !== null)
3258
3775
  return bodyValue;
3259
- return Object.prototype.hasOwnProperty.call(fm, field) ? String(fm[field]) : null;
3776
+ if (Object.prototype.hasOwnProperty.call(fm, field))
3777
+ return String(fm[field]);
3778
+ if (field.includes('.')) {
3779
+ const resolved = resolveFrontmatterPath(fm, field);
3780
+ if (resolved !== STATE_FIELD_ABSENT) {
3781
+ return stateScalarString(resolved);
3782
+ }
3783
+ }
3784
+ return null;
3260
3785
  };
3786
+ // A field in `reported` can itself be a declared derived leaf (e.g.
3787
+ // `plannedPhaseCore` pushing `'progress.total_plans'` — state-
3788
+ // transition.cts:1752). `valueOf`'s null-vs-string convention cannot tell
3789
+ // "absent from the frontmatter" apart from "resolved to the literal string
3790
+ // 'null'/''", so it cannot carry the same materialization rule
3791
+ // `computeChangedFrontmatterFields` applies below. Route these fields
3792
+ // through the SAME primitives (`resolveFrontmatterPath` + the
3793
+ // `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
3794
+ // second, parallel absence convention — one rule, reused, not duplicated.
3795
+ const isDeclaredDerivedLeaf = (candidate) => candidate.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, candidate);
3796
+ const changed = (field) => {
3797
+ if (isDeclaredDerivedLeaf(field)) {
3798
+ const before = resolveFrontmatterPath(snapshotFm, field);
3799
+ const after = resolveFrontmatterPath(persistedFm, field);
3800
+ // Same generalized provenance rule as computeChangedFrontmatterFields:
3801
+ // absent-in-snapshot-materializing-in-persisted is the disk scan
3802
+ // catching a never-synced document up, not this write's own action.
3803
+ if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
3804
+ return false;
3805
+ return stateFieldValuesDiffer(before, after);
3806
+ }
3807
+ const before = valueOf(snapshotFm, snapshotBody, field);
3808
+ const after = valueOf(persistedFm, persistedBody, field);
3809
+ if (before === null && after === null)
3810
+ return false;
3811
+ if (before === null || after === null)
3812
+ return true;
3813
+ return before.trim() !== after.trim();
3814
+ };
3815
+ // Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
3816
+ // granularity) whose persisted value differs from the snapshot, minus the
3817
+ // provenance exclusion. A frontmatter-diff-discovered field is mapped
3818
+ // through `bodyLabelFor` so it lands in the SAME output vocabulary a
3819
+ // transform would have used (`status` -> `'Status'`; `progress.total_plans`
3820
+ // has no body-line label and falls through to its raw dotted key, same as
3821
+ // today's `progress`/`milestone*` fall-through).
3822
+ const changedFrontmatterFields = computeChangedFrontmatterFields(snapshotFm, persistedFm, preWriteState.bodyDeltas);
3823
+ const mappedFrontmatterFields = changedFrontmatterFields.map((field) => bodyLabelFor(field));
3824
+ const seen = new Set();
3261
3825
  const reconciled = [];
3262
3826
  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()) {
3827
+ if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
3828
+ continue;
3829
+ if (changed(field)) {
3830
+ seen.add(field);
3266
3831
  reconciled.push(field);
3267
3832
  }
3268
3833
  }
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')
3834
+ for (const field of mappedFrontmatterFields) {
3835
+ if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
3287
3836
  continue;
3288
- const label = bodyLabelFor(field);
3289
- if (!reconciled.includes(label))
3290
- reconciled.push(label);
3837
+ seen.add(field);
3838
+ reconciled.push(field);
3291
3839
  }
3292
3840
  return reconciled;
3293
3841
  }
@@ -3342,11 +3890,21 @@ function cmdStateJson(cwd, raw) {
3342
3890
  ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
3343
3891
  const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
3344
3892
  const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
3893
+ // #3836: mirrors applyPostSyncPreservation's own derivation (state.cts
3894
+ // bodyDeltas, `last_activity_desc`) — the `Last Activity Description`
3895
+ // label, falling back to the prose `Last Activity:` line's parsed
3896
+ // description. Read-side twin of #3258's write-side wiring; this field is
3897
+ // `preserve-when-unchanged` per FIELD_CLASSIFICATION and was previously
3898
+ // absent from this read path entirely (never derived here, never in the
3899
+ // loop below), so a stale body annotation always beat a fresher curated
3900
+ // frontmatter value on every `state json` read.
3901
+ const bodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last activity');
3902
+ const bodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity Description')
3903
+ ?? parseProseLastActivityField(bodyLastActivityRaw).description;
3345
3904
  const unchanged = (v) => ({ pre: v, post: v });
3346
3905
  const ctx = {
3347
- preFm: null,
3348
3906
  postFm: built,
3349
- preFmSnapshot: existingFm,
3907
+ snapshot: existingFm,
3350
3908
  resync: true,
3351
3909
  deriveProgressKeys: false,
3352
3910
  bodyDeltas: {
@@ -3356,10 +3914,15 @@ function cmdStateJson(cwd, raw) {
3356
3914
  current_phase: unchanged(bodyCurrentPhase),
3357
3915
  current_plan: unchanged(bodyCurrentPlan),
3358
3916
  current_phase_name: unchanged(bodyPhaseSource),
3917
+ last_activity_desc: unchanged(bodyLastActivityDesc),
3359
3918
  },
3360
3919
  mutated: false,
3361
3920
  };
3362
- for (const field of ['status', 'stopped_at', 'paused_at', 'current_phase', 'current_plan', 'current_phase_name']) {
3921
+ // #3836: derive the field set from FIELD_CLASSIFICATION's
3922
+ // `preserve-when-unchanged` rows (single source of truth) instead of a
3923
+ // hand-typed literal that can drift from the table — this IS the fix,
3924
+ // not merely an addition of one more name to the literal.
3925
+ for (const field of stateTransitionMod.getPreserveWhenUnchangedFields()) {
3363
3926
  const cls = stateTransitionMod.getFieldClassification(field);
3364
3927
  if (cls)
3365
3928
  stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
@@ -3412,26 +3975,28 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
3412
3975
  // still runs after the sync; the override is re-asserted after it inside
3413
3976
  // readModifyWriteStateMd for layouts with no body `Phase:` line.
3414
3977
  const divergedFields = [];
3978
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
3979
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
3980
+ const preWriteState = {};
3415
3981
  const rmwOptions = {
3416
3982
  authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
3417
3983
  divergedFields,
3984
+ preWriteState,
3418
3985
  };
3419
3986
  let precomputedUpdated = [];
3420
- let preSyncContent = '';
3421
3987
  // #3311: begin-phase is the claim point — it is the one Current Position
3422
3988
  // transition that explicitly names its phase, so it both records this
3423
3989
  // session's claim and detects a conflicting live claim for a different
3424
3990
  // phase. The check runs INSIDE the STATE.md lock so concurrent begin-phase
3425
3991
  // calls cannot both read "no claim" and both write.
3426
3992
  let milestoneConflict = null;
3427
- readModifyWriteStateMd(statePath, (content) => {
3993
+ const wrote = readModifyWriteStateMd(statePath, (content) => {
3428
3994
  milestoneConflict = milestoneLockMod.claimMilestonePhase(cwd, String(phaseNumber));
3429
3995
  if (milestoneConflict) {
3430
3996
  milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
3431
3997
  }
3432
3998
  const result = transitionCore(content, intent, deps);
3433
3999
  precomputedUpdated = result.updated;
3434
- preSyncContent = result.content;
3435
4000
  // #3127 resume: the core preserved the mid-flight Current Phase Name, so
3436
4001
  // the intent-first override must not fire — it would drift frontmatter
3437
4002
  // away from the preserved body value. Dropping it here is safe because
@@ -3445,8 +4010,29 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
3445
4010
  // the bytes actually persisted (fix(#3351) generalized) and fold in any
3446
4011
  // field preservation restored that this transform never touched (#3345's
3447
4012
  // direction).
3448
- const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
4013
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
3449
4014
  output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, milestone_conflict: milestoneConflict }, raw, updated.length > 0 ? 'true' : 'false');
4015
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on `wrote`
4016
+ // (readModifyWriteStateMd's own return value — its #948 no-op guard skips
4017
+ // the write outright when the transform produced no diff), not on
4018
+ // `updated.length > 0`. Confirmed reproducer: an unrecognized-format
4019
+ // STATE.md makes `beginPhaseCore` match zero body fields AND leave
4020
+ // `existingFm` untouched, so the raw transform output is byte-identical to
4021
+ // the input, the RMW guard fires, and `wrote` is false — matching
4022
+ // `updated: []` here. Unlike `cmdStatePlannedPhase` (which must NOT use
4023
+ // this same `wrote` signal — see its comment for why `plannedPhaseCore`
4024
+ // mutates frontmatter in place even on this exact no-op shape),
4025
+ // `beginPhaseCore` never mutates `existingFm`, so `wrote` and
4026
+ // `updated.length > 0` agree on every case audited for this phase; `wrote`
4027
+ // is kept as the gate here (and on `cmdStateAdvancePlan`/
4028
+ // `cmdStateCompletePhase` below, where it is REQUIRED — `updated`/
4029
+ // `reconciled` can be non-empty there even when nothing was written,
4030
+ // confirmed by direct re-invocation) for one consistent rule across every
4031
+ // RMW-backed command in this file: publish iff `readModifyWriteStateMd`
4032
+ // itself reports a write. Best-effort — cannot throw, cannot change this
4033
+ // command's exit code or output.
4034
+ if (wrote)
4035
+ publishStateContract(cwd);
3450
4036
  }
3451
4037
  /**
3452
4038
  * Write a WAITING.json signal file when GSD hits a decision point.
@@ -3698,19 +4284,38 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
3698
4284
  // line, and the prose re-derivation of current_phase_name truncates names
3699
4285
  // that themselves contain a parenthetical — the authoritative override keeps
3700
4286
  // the exact value, exactly as cmdStateBeginPhase does for its EXECUTING line.
4287
+ //
4288
+ // #3834: without a name, the body-source delta rule that would normally
4289
+ // preserve the curated `current_phase_name` (FIELD_CLASSIFICATION:
4290
+ // preserve-when-unchanged) cannot fire — THIS write rewrites the `Phase:`
4291
+ // source line to `N — READY TO EXECUTE` itself, so pre/post disagree by
4292
+ // construction and the post-sync re-derivation harvests "READY TO EXECUTE"
4293
+ // as if it were the name. The fix mirrors the named-arg path: reassert an
4294
+ // authoritative override, falling back to the pre-write curated value (read
4295
+ // inside the RMW callback, before this write's own body mutation) rather
4296
+ // than leaving the field to a delta heuristic this exact transition defeats.
3701
4297
  const divergedFields = [];
4298
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
4299
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
4300
+ const preWriteState = {};
3702
4301
  const rmwOptions = {
3703
4302
  resync: false,
3704
4303
  deriveProgressKeys: true,
3705
4304
  authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
3706
4305
  divergedFields,
4306
+ preWriteState,
3707
4307
  };
3708
4308
  let precomputedUpdated = [];
3709
- let preSyncContent = '';
3710
4309
  readModifyWriteStateMd(statePath, (content) => {
4310
+ if (!intent.phaseName) {
4311
+ const preFm = extractFrontmatter(content, statePath);
4312
+ const curatedName = preFm['current_phase_name'];
4313
+ if (typeof curatedName === 'string' && curatedName.trim().length > 0) {
4314
+ rmwOptions.authoritativeFm = { current_phase_name: curatedName };
4315
+ }
4316
+ }
3711
4317
  const result = transitionCore(content, intent, deps);
3712
4318
  precomputedUpdated = result.updated;
3713
- preSyncContent = result.content;
3714
4319
  return result.content;
3715
4320
  }, cwd, rmwOptions);
3716
4321
  // ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
@@ -3719,11 +4324,35 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
3719
4324
  // (#3345's direction) — traced for this phase (design doc: "not traced in
3720
4325
  // the analysis pass") and found to need exactly the same treatment as
3721
4326
  // `cmdStateBeginPhase`.
3722
- const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
4327
+ const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
3723
4328
  const result = updated.length === 0
3724
4329
  ? { updated, phase: phaseNumber, plan_count: planCount, warning: 'STATE.md Current Position has no recognized labels — transition was a no-op. Verify STATE.md uses the canonical labeled format (Status:, Total Plans in Phase:, etc.).' }
3725
4330
  : { updated, phase: phaseNumber, plan_count: planCount };
3726
4331
  output(result, raw, updated.length > 0 ? 'true' : 'false');
4332
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on
4333
+ // `updated.length > 0`, NOT on `readModifyWriteStateMd`'s own write-happened
4334
+ // return value. The two are NOT equivalent here: readModifyWriteStateMd's
4335
+ // #948 no-op guard compares the transform's RAW returned string against the
4336
+ // RAW original file content, but `syncStateFrontmatter`'s progress-block
4337
+ // sync and this command's `authoritativeFm: {current_phase_name}` override
4338
+ // both run INSIDE the transform (via `frontmatterMod.reconstructFrontmatter`
4339
+ // over `existingFm`), so an unrecognized-format STATE.md — zero fields the
4340
+ // transition could actually apply, `updated: []`, the "transition was a
4341
+ // no-op" warning above — can still make the raw returned string differ
4342
+ // from the input (frontmatter gets synthesized: `gsd_state_version`,
4343
+ // `last_updated`, a zeroed `progress` block, `current_phase_name`), so the
4344
+ // RMW guard does NOT fire and a real write happens. That write is not a
4345
+ // meaningful state transition by this command's OWN reporting contract
4346
+ // (`updated: []`) — publishing on it would refresh state.json's
4347
+ // `updated_at` for a call this command itself reports did nothing.
4348
+ // `updated.length > 0` is the field-classification-table-backed signal
4349
+ // that actually answers "did plannedPhaseCore itself change anything this
4350
+ // caller asked it to change" — empirically verified: an unrecognized-format
4351
+ // STATE.md reproduces `updated: []` with a genuine (frontmatter-only) disk
4352
+ // write underneath it, and gating on `updated.length > 0` is what makes
4353
+ // this reproducer NOT publish.
4354
+ if (updated.length > 0)
4355
+ publishStateContract(cwd);
3727
4356
  }
3728
4357
  /**
3729
4358
  * Bug #2630: reset STATE.md for a new milestone cycle.
@@ -3746,16 +4375,24 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
3746
4375
  // steady-state syncStateFrontmatter post-sync.
3747
4376
  const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
3748
4377
  const deps = { clock: clock_cjs_1.realClock, sourcePath: statePath };
4378
+ let switched = false;
3749
4379
  const lockPath = acquireStateLock(statePath);
3750
4380
  try {
3751
4381
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
3752
4382
  const result = transitionCore(content, intent, deps);
3753
4383
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, result.content);
3754
4384
  output({ switched: true, version, name: resolvedName, status: 'planning' }, raw, 'true');
4385
+ switched = true;
3755
4386
  }
3756
4387
  finally {
3757
4388
  releaseStateLock(lockPath);
3758
4389
  }
4390
+ // #3227: publish AFTER releaseStateLock — publishStateContract derives `next`
4391
+ // from classifyProject, which shells out to git (bounded, but up to 3 x 10s).
4392
+ // Holding the STATE.md lock across that would turn a millisecond hold into a
4393
+ // git-bound one for every concurrent GSD process.
4394
+ if (switched)
4395
+ publishStateContract(cwd);
3759
4396
  }
3760
4397
  /**
3761
4398
  * Gate 1: Validate STATE.md against filesystem.
@@ -3851,10 +4488,31 @@ function readStateFrontmatterScoped(content, statePath) {
3851
4488
  function stateDiagnostic(code, severity, message, advice) {
3852
4489
  return { code, severity, message, remedy: adviseRemedy(advice) };
3853
4490
  }
3854
- function cmdStateValidate(cwd, raw) {
4491
+ function cmdStateValidate(cwd, raw, opts = {}) {
3855
4492
  const statePath = planningPaths(cwd).state;
4493
+ // #3696: `valid: false` used to exit 0, so a CI step or git hook could not gate
4494
+ // on state correctness without parsing JSON — every consumer had to
4495
+ // re-implement the "is this actually valid" decision, which is the
4496
+ // duplication #3473 is about.
4497
+ //
4498
+ // The DEFAULT is deliberately unchanged. `state validate`'s exit status is
4499
+ // Tier-2 observable output reaching "downstream projects that cannot be
4500
+ // enumerated" (ADR-3180 Decision 3, Hyrum's Law), so flipping 0 -> 1 for
4501
+ // everyone would break every script that runs it unconditionally. `--strict`
4502
+ // is the opt-in the issue itself offers as the alternative.
4503
+ //
4504
+ // Routed through one emit helper rather than a trailing assignment because
4505
+ // three of the exit paths below (`STATE.md not found`, S001, and the four
4506
+ // `return` branches in the phase-drift scan) emit and return early — a fix
4507
+ // that only set the exit code at the end of the function would silently miss
4508
+ // them, which is exactly the shape of the bug being fixed.
4509
+ const emit = (payload) => {
4510
+ if (opts.strict && payload.valid !== true)
4511
+ process.exitCode = 1;
4512
+ output(payload, raw, undefined);
4513
+ };
3856
4514
  if (!node_fs_1.default.existsSync(statePath)) {
3857
- output({ error: 'STATE.md not found' }, raw, undefined);
4515
+ emit({ error: 'STATE.md not found' });
3858
4516
  return;
3859
4517
  }
3860
4518
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
@@ -3867,10 +4525,10 @@ function cmdStateValidate(cwd, raw) {
3867
4525
  // unconditionally and returned immediately, matching every other
3868
4526
  // error-class code, not a mere warning). Message reused verbatim from
3869
4527
  // `textEncodingError`, not paraphrased.
3870
- output({
4528
+ emit({
3871
4529
  valid: false,
3872
4530
  warnings: [stateDiagnostic('S001', SEVERITY.ERROR, encErr, 'Re-save STATE.md as UTF-8 text with the embedded NUL byte(s) removed')],
3873
- }, raw, undefined);
4531
+ });
3874
4532
  return;
3875
4533
  }
3876
4534
  const warnings = [];
@@ -3888,7 +4546,7 @@ function cmdStateValidate(cwd, raw) {
3888
4546
  const phasesDir = planningPaths(cwd).phases;
3889
4547
  if (currentPhase === null) {
3890
4548
  warnings.push(stateDiagnostic('S002', SEVERITY.WARNING, 'Cannot validate phase drift: STATE.md has no usable current_phase, Current Phase, or Current Position Phase value', 'Set current_phase (frontmatter) or Current Phase / Current Position Phase (body) in STATE.md'));
3891
- output({ valid: false, warnings, scope }, raw, undefined);
4549
+ emit({ valid: false, warnings, scope });
3892
4550
  return;
3893
4551
  }
3894
4552
  const selectedPhaseKey = phaseKeyFromToken(currentPhase);
@@ -3897,7 +4555,7 @@ function cmdStateValidate(cwd, raw) {
3897
4555
  }
3898
4556
  if (!node_fs_1.default.existsSync(phasesDir)) {
3899
4557
  warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phases directory is missing for phase ${currentPhase}`, 'Create the phases directory or correct current_phase to a phase that exists on disk'));
3900
- output({ valid: false, warnings, scope }, raw, undefined);
4558
+ emit({ valid: false, warnings, scope });
3901
4559
  return;
3902
4560
  }
3903
4561
  let phaseDirPath;
@@ -3906,14 +4564,14 @@ function cmdStateValidate(cwd, raw) {
3906
4564
  const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
3907
4565
  if (!phaseDir) {
3908
4566
  warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: no phase directory matches phase ${currentPhase}`, 'Create a phase directory matching the current phase or correct current_phase'));
3909
- output({ valid: false, warnings, scope }, raw, undefined);
4567
+ emit({ valid: false, warnings, scope });
3910
4568
  return;
3911
4569
  }
3912
4570
  phaseDirPath = node_path_1.default.join(phasesDir, phaseDir.name);
3913
4571
  }
3914
4572
  catch {
3915
4573
  warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phases directory is unreadable for phase ${currentPhase}`, 'Check phases directory permissions and re-run validate'));
3916
- output({ valid: false, warnings, scope }, raw, undefined);
4574
+ emit({ valid: false, warnings, scope });
3917
4575
  return;
3918
4576
  }
3919
4577
  try {
@@ -3981,8 +4639,56 @@ function cmdStateValidate(cwd, raw) {
3981
4639
  catch {
3982
4640
  warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phase directory is unreadable for phase ${currentPhase}`, 'Check phase directory permissions and re-run validate'));
3983
4641
  }
4642
+ // #3696 — the `last_activity` invariant. Three readers consumed this field
4643
+ // and none of them checked it, so a value no reader can parse validated as
4644
+ // `{valid:true, warnings:[], scope:'complete'}`: the scan ran to completion
4645
+ // and simply never looked. Read through the same owner every other field here
4646
+ // uses (ADR-3180 §7.7) — never a private `stateExtractField` call, which is
4647
+ // what `scripts/lint-state-field-drift.cjs` counts.
4648
+ const lastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity', 'Last activity').value;
4649
+ // NOT FILLED IN IS NOT DRIFT, and that covers three shapes, not one: absent,
4650
+ // blank, and the shipped template's `[YYYY-MM-DD] — [What happened]`
4651
+ // placeholder. Only a value a writer actually supplied can be wrong.
4652
+ if (!(0, state_document_cjs_1.isUnfilledFieldValue)(lastActivity)) {
4653
+ // Calendar validity, not merely `\d{4}-\d{2}-\d{2}` shape: smart-entry's
4654
+ // reader rejects 2026-02-30 via isRealCalendarDate (ADR-227 — validate shape
4655
+ // AND value). Accepting it here would leave the two surfaces disagreeing
4656
+ // about whether the file is usable, which is the complaint #3696 opens with.
4657
+ //
4658
+ // Review round 2: this asserts the LEADING date token, not
4659
+ // `parseProseLastActivityField`'s fully-anchored `date — description`
4660
+ // grammar. That grammar is stricter than any real reader, and routing the
4661
+ // check through it made S008 fire on values smart-entry parses fine (e.g.
4662
+ // `2026-08-24 Shipped feature X`, no dash separator) — the same
4663
+ // two-surfaces-disagree defect, pointing the other way. See
4664
+ // `leadingCalendarDate`.
4665
+ if ((0, state_document_cjs_1.leadingCalendarDate)(lastActivity) === null) {
4666
+ warnings.push(stateDiagnostic('S008', SEVERITY.WARNING, `Unreadable last activity: "${lastActivity}" does not begin with a real calendar date, so no reader can date this project's activity`, 'Rewrite the Last activity line to begin with a date that exists, as "YYYY-MM-DD — what happened"'));
4667
+ }
4668
+ // The attached half of #3696: `templates/state.md` prescribes a single-line
4669
+ // field, but writers emit descriptions long enough to wrap, and
4670
+ // `stateExtractField`'s newline-excluding `(.+)` drops the remainder with no
4671
+ // diagnostic. The DOCUMENT is what violates the template here, so this
4672
+ // reports the violation rather than teaching the reader a multi-line grammar
4673
+ // the template does not sanction (ADR-3180 §7.7 Rejected #1 forbids widening
4674
+ // stateExtractField, which has 20 callers and a CRITICAL blast radius).
4675
+ //
4676
+ // Scan the body ONLY when the body is what was actually read. The ladder
4677
+ // prefers the frontmatter scalar, so a document carrying a clean
4678
+ // `last_activity:` in frontmatter AND a stale, wrapped `Last activity:` line
4679
+ // in the body would otherwise report S009 — and exit 1 under `--strict` —
4680
+ // over a remainder that no reader consumes and whose field is entirely
4681
+ // valid. Asking the owner with an EMPTY body isolates the frontmatter rung
4682
+ // without re-deriving the ladder here (which is what
4683
+ // `scripts/lint-state-field-drift.cjs` counts).
4684
+ const fromFrontmatter = (0, state_document_cjs_1.stateFieldValue)(fm, '', 'last_activity', 'Last activity').value;
4685
+ const dropped = fromFrontmatter !== null ? null : (0, state_document_cjs_1.stateFieldContinuation)(body, 'Last activity');
4686
+ if (dropped !== null) {
4687
+ warnings.push(stateDiagnostic('S009', SEVERITY.WARNING, `Truncated last activity description: "${dropped}" follows the Last activity line and is silently dropped by every reader`, 'Fold the Last activity description onto one line — the template prescribes a single-line field'));
4688
+ }
4689
+ }
3984
4690
  const valid = warnings.length === 0;
3985
- output({ valid, warnings, scope }, raw, undefined);
4691
+ emit({ valid, warnings, scope });
3986
4692
  }
3987
4693
  /**
3988
4694
  * Gate 2: Sync STATE.md from filesystem ground truth.
@@ -3997,6 +4703,19 @@ function cmdStateSync(cwd, options, raw) {
3997
4703
  }
3998
4704
  const verify = options && options.verify;
3999
4705
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
4706
+ // ADR-3473 §8.5 (#3881): `state sync` is on ADR-3408 §8.3's closed
4707
+ // sanctioned-regenerate list — "the body wins" — and `syncStateFrontmatter`
4708
+ // (below, via `writeStateMd`'s `sanctionedPermanentEmptyFallback`) is
4709
+ // therefore CORRECT to overwrite even an unparseable existing frontmatter
4710
+ // block (git merge-conflict markers, malformed YAML). What was missing was
4711
+ // disclosure: a derived conclusion (`synced: true`) must not be reported as
4712
+ // authoritative when the derivation dropped input it could not resolve
4713
+ // (§8.5) — silently destroying the only copy of an unreadable block with no
4714
+ // signal is "failure is a value" (§8.4) violated. Computed once, up front,
4715
+ // from the pre-write snapshot so both the `--verify` (dry-run) and the real
4716
+ // write branch can surface it identically.
4717
+ const existingSyncFm = extractFrontmatter(content, statePath);
4718
+ const syncFrontmatterWasUnparseable = isUnparseableFrontmatter(existingSyncFm);
4000
4719
  const changes = [];
4001
4720
  let modified = content;
4002
4721
  const phasesDir = planningPaths(cwd).phases;
@@ -4147,12 +4866,30 @@ function cmdStateSync(cwd, options, raw) {
4147
4866
  modified = syncResult.content;
4148
4867
  const coreChanges = syncResult.data?.changes ?? [];
4149
4868
  changes.push(...coreChanges);
4869
+ // #3881 (ADR-3473 §8.5): only warn when a write will actually regenerate the
4870
+ // frontmatter — if nothing changed this run, the unparseable block (if any)
4871
+ // was never touched, so there is nothing to disclose. Mirrors the exact
4872
+ // condition the write branch below uses to decide whether to write at all.
4873
+ const syncWillWrite = changes.length > 0 || modified !== content;
4874
+ if (syncWillWrite && syncFrontmatterWasUnparseable) {
4875
+ const unparseableWarning = `gsd: warning — STATE.md's existing frontmatter could not be parsed (malformed YAML, or ` +
4876
+ `unresolved content such as git merge-conflict markers) and was regenerated from the body; ` +
4877
+ `any content in the old frontmatter block — including merge-conflict markers — has been ` +
4878
+ `replaced. (#3881)`;
4879
+ process.stderr.write(`${unparseableWarning}\n`);
4880
+ changes.push(unparseableWarning);
4881
+ }
4150
4882
  if (verify) {
4151
4883
  output({ synced: false, changes, dry_run: true }, raw, undefined);
4152
4884
  return;
4153
4885
  }
4154
- if (changes.length > 0 || modified !== content) {
4155
- writeStateMd(statePath, modified, cwd);
4886
+ if (syncWillWrite) {
4887
+ // ADR-3473 §8.6: `rebuild()` is the typed expression of #905's contract —
4888
+ // `state sync` exists to let the body win, so preservation must NOT run,
4889
+ // and the snapshot is carried anyway because §8.7's reporting needs it.
4890
+ writeStateMd(statePath, modified, stateTransitionMod.rebuildStateTransaction({
4891
+ snapshot: extractFrontmatter(content, statePath),
4892
+ }), cwd);
4156
4893
  }
4157
4894
  output({ synced: true, changes, dry_run: false }, raw, undefined);
4158
4895
  }
@@ -4494,16 +5231,38 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
4494
5231
  // flat `string[]` (the command's OUTPUT CONTRACT — unchanged) happens once
4495
5232
  // below, right before `output()`.
4496
5233
  const updated = [];
4497
- let preSyncContent = '';
4498
5234
  const divergedFields = [];
4499
- readModifyWriteStateMd(statePath, (content) => {
5235
+ // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
5236
+ // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
5237
+ const preWriteState = {};
5238
+ // #3835: complete-phase unconditionally rewrites the body `Phase:` line to
5239
+ // `N — COMPLETE` below. That defeats current_phase_name's
5240
+ // preserve-when-unchanged delta rule the same way #3834's no-`--name`
5241
+ // planned-phase write does — pre/post body-source disagree BY CONSTRUCTION
5242
+ // (this write is what changed the source line), so the post-sync
5243
+ // re-derivation harvests nothing from "COMPLETE" and the curated key is
5244
+ // dropped entirely rather than preserved. The write site already documents
5245
+ // "an absent name does NOT clear an existing curated value" for the body
5246
+ // (`Current Phase Name` section below) — this reasserts the same rule for
5247
+ // the frontmatter key, mirroring cmdStatePlannedPhase's fix.
5248
+ const rmwOptions = { divergedFields, preWriteState };
5249
+ const wrote = readModifyWriteStateMd(statePath, (content) => {
4500
5250
  const currentPhase = resolvedPhase;
4501
5251
  // Bug #1255: operate on body only so the YAML frontmatter `status:` key
4502
5252
  // cannot shadow the body Status field (pipe-table or inline).
4503
- const existingFm = extractFrontmatter(content, statePath);
4504
- const hasFrontmatter = Object.keys(existingFm).length > 0;
4505
- let body = stripFrontmatter(content);
4506
- const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
5253
+ //
5254
+ // ADR-3473 §8.1 (#3881 review, finding 5): previously this block hand-reimplemented
5255
+ // the isUnparseableFrontmatter/rawFrontmatterPrefix shape inline instead of using the
5256
+ // canonical helper — the sixth copy of a block already duplicated 5x in
5257
+ // state-transition.cts. Routed through the shared `beginFrontmatterReassembly` so this
5258
+ // module can never drift from the frontmatter-preservation contract state-transition.cts
5259
+ // enforces everywhere else.
5260
+ const { existingFm, body: initialBody, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
5261
+ let body = initialBody;
5262
+ const curatedPhaseName = existingFm['current_phase_name'];
5263
+ if (typeof curatedPhaseName === 'string' && curatedPhaseName.trim().length > 0) {
5264
+ rmwOptions.authoritativeFm = { current_phase_name: curatedPhaseName };
5265
+ }
4507
5266
  // Update Status field (body only — #1255)
4508
5267
  const statusValue = `Phase ${currentPhase} complete`;
4509
5268
  let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
@@ -4584,10 +5343,8 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
4584
5343
  updated.push({ kind: 'section', name: 'Current Position' });
4585
5344
  }
4586
5345
  }
4587
- const out = reassemble(body);
4588
- preSyncContent = out;
4589
- return out;
4590
- }, cwd, { divergedFields });
5346
+ return reassemble(body);
5347
+ }, cwd, rmwOptions);
4591
5348
  // ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
4592
5349
  // the analysis pass"). Unlike the transitionCore-based commands, this
4593
5350
  // adapter's `updated` mixes FIELD entries (Status, Last Activity, Last
@@ -4603,8 +5360,17 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
4603
5360
  // string-matching a name against a Set.
4604
5361
  const sectionEntries = updated.filter((e) => e.kind === 'section').map((e) => e.name);
4605
5362
  const fieldEntries = updated.filter((e) => e.kind === 'field').map((e) => e.name);
4606
- const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preSyncContent, fieldEntries, divergedFields)];
5363
+ const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preWriteState, fieldEntries, divergedFields)];
4607
5364
  output({ updated: reconciled, phase: resolvedPhase }, raw, reconciled.length > 0 ? 'true' : 'false');
5365
+ // #3227: gate on `wrote` (readModifyWriteStateMd's own return value), not
5366
+ // `reconciled.length > 0` — a re-run of complete-phase against a phase
5367
+ // that is ALREADY marked complete (same status/date/Current Position
5368
+ // values already on disk) still has `stateReplaceField` report a match for
5369
+ // every field it looks up, so `reconciled` is non-empty even though the
5370
+ // #948 no-op guard skipped the write. Same reasoning as
5371
+ // cmdStateBeginPhase/cmdStatePlannedPhase/cmdStateAdvancePlan above.
5372
+ if (wrote)
5373
+ publishStateContract(cwd);
4608
5374
  }
4609
5375
  module.exports = {
4610
5376
  stateExtractField: state_document_cjs_1.stateExtractField,
@@ -4659,6 +5425,23 @@ module.exports = {
4659
5425
  // FIELD_CLASSIFICATION, exposed so a parity test can pin that every
4660
5426
  // `preserve-when-unchanged` row has a label here.
4661
5427
  _FRONTMATTER_KEY_TO_BODY_LABEL: FRONTMATTER_KEY_TO_BODY_LABEL,
5428
+ // Test seam (ADR-3473 §8.7, #3872): the transaction diff and its pure
5429
+ // building blocks, exposed so the ~15 boundary/hostile/property rows in
5430
+ // the test matrix (dotted-path resolution, prototype-pollution safety,
5431
+ // string/number representation insensitivity, the provenance exclusion)
5432
+ // can be driven directly with fabricated snapshot/persisted objects
5433
+ // instead of round-tripping every case through a full RMW write.
5434
+ _reconcileReportedFields: reconcileReportedFields,
5435
+ _computeChangedFrontmatterFields: computeChangedFrontmatterFields,
5436
+ _resolveFrontmatterPath: resolveFrontmatterPath,
5437
+ _stateFieldValuesDiffer: stateFieldValuesDiffer,
5438
+ _STATE_UPDATED_PROVENANCE_EXCLUSION: STATE_UPDATED_PROVENANCE_EXCLUSION,
5439
+ // Test seam (#3873 phase-3 test matrix row 9): `bodyLabelFor` itself is not
5440
+ // otherwise reachable from outside this module. Exposed so a test can drive
5441
+ // the real STATE_BODY_LABEL_UNWIRED_ROW throw directly, rather than only
5442
+ // pinning the table it reads (`_FRONTMATTER_KEY_TO_BODY_LABEL`) against
5443
+ // itself.
5444
+ _bodyLabelFor: bodyLabelFor,
4662
5445
  // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
4663
5446
  // steal decision is exercised without real pids. Mirrors capability-lock.cts.
4664
5447
  _setLockProbes(probes) {