@opengsd/gsd-core 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-debug-session-manager.md +1 -1
  6. package/agents/gsd-debugger.md +1 -1
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +78 -42
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +0 -1
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +3 -1
  15. package/agents/gsd-plan-checker.md +91 -112
  16. package/agents/gsd-planner.md +20 -4
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +82 -7
  21. package/agents/gsd-ui-researcher.md +70 -3
  22. package/agents/gsd-verifier.md +24 -2
  23. package/bin/install.js +847 -200
  24. package/commands/gsd/discuss-phase.md +1 -1
  25. package/commands/gsd/execute-phase.md +1 -1
  26. package/commands/gsd/import.md +1 -1
  27. package/commands/gsd/ns-workflow.md +2 -1
  28. package/commands/gsd/phase.md +1 -1
  29. package/commands/gsd/quick-batch.md +105 -0
  30. package/commands/gsd/quick.md +8 -4
  31. package/commands/gsd/surface.md +18 -8
  32. package/gsd-core/bin/gsd-tools.cjs +761 -100
  33. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  34. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  35. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  36. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  37. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  38. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  39. package/gsd-core/bin/lib/audit.cjs +163 -41
  40. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  41. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  42. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  43. package/gsd-core/bin/lib/capability-registry.cjs +785 -144
  44. package/gsd-core/bin/lib/capability-state.cjs +25 -4
  45. package/gsd-core/bin/lib/capability-validator.cjs +321 -18
  46. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  47. package/gsd-core/bin/lib/check-command-router.cjs +229 -6
  48. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  49. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  50. package/gsd-core/bin/lib/clusters.cjs +1 -0
  51. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  52. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  53. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  54. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  55. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  56. package/gsd-core/bin/lib/commands.cjs +877 -54
  57. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  58. package/gsd-core/bin/lib/config-loader.cjs +121 -29
  59. package/gsd-core/bin/lib/config.cjs +92 -2
  60. package/gsd-core/bin/lib/configuration.cjs +129 -37
  61. package/gsd-core/bin/lib/core-utils.cjs +118 -14
  62. package/gsd-core/bin/lib/decisions.cjs +213 -1
  63. package/gsd-core/bin/lib/edge-probe.cjs +23 -2
  64. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  65. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  66. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  67. package/gsd-core/bin/lib/frontmatter.cjs +975 -326
  68. package/gsd-core/bin/lib/gap-checker.cjs +41 -8
  69. package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  71. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  72. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
  73. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  74. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  75. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  76. package/gsd-core/bin/lib/host-integration.cjs +96 -11
  77. package/gsd-core/bin/lib/init-command-router.cjs +132 -21
  78. package/gsd-core/bin/lib/init.cjs +252 -56
  79. package/gsd-core/bin/lib/install-engine.cjs +252 -15
  80. package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
  81. package/gsd-core/bin/lib/install-profiles.cjs +100 -18
  82. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  83. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  84. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  85. package/gsd-core/bin/lib/intel.cjs +101 -26
  86. package/gsd-core/bin/lib/io.cjs +195 -15
  87. package/gsd-core/bin/lib/learnings.cjs +85 -14
  88. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  89. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  90. package/gsd-core/bin/lib/markdown-table.cjs +175 -4
  91. package/gsd-core/bin/lib/milestone.cjs +112 -7
  92. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  93. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  94. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  95. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  96. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  97. package/gsd-core/bin/lib/phase-id.cjs +321 -13
  98. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  99. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  100. package/gsd-core/bin/lib/phase.cjs +1175 -115
  101. package/gsd-core/bin/lib/plan-document.cjs +273 -0
  102. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  103. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  104. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  105. package/gsd-core/bin/lib/planning-snapshot.cjs +165 -34
  106. package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
  107. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  108. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  109. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  110. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  111. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  112. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  113. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  114. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  115. package/gsd-core/bin/lib/review-lane-descriptor.cjs +62 -14
  116. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  117. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  118. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  119. package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
  120. package/gsd-core/bin/lib/roadmap.cjs +248 -64
  121. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
  122. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  123. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
  124. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
  125. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  126. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  127. package/gsd-core/bin/lib/shell-command-projection.cjs +75 -8
  128. package/gsd-core/bin/lib/smart-entry.cjs +19 -31
  129. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  130. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  131. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  132. package/gsd-core/bin/lib/state-document.cjs +216 -5
  133. package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
  134. package/gsd-core/bin/lib/state-transition.cjs +850 -145
  135. package/gsd-core/bin/lib/state.cjs +1629 -287
  136. package/gsd-core/bin/lib/surface.cjs +33 -10
  137. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  138. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  139. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  140. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  141. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  142. package/gsd-core/bin/lib/uat.cjs +2542 -387
  143. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  144. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  145. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  146. package/gsd-core/bin/lib/update-context.cjs +6 -2
  147. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  148. package/gsd-core/bin/lib/validate.cjs +230 -12
  149. package/gsd-core/bin/lib/vendor/README.md +43 -5
  150. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  151. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  152. package/gsd-core/bin/lib/verification.cjs +287 -13
  153. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  154. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  155. package/gsd-core/bin/lib/verify.cjs +441 -56
  156. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  157. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  158. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  159. package/gsd-core/bin/lib/worktree-safety.cjs +185 -21
  160. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  161. package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
  162. package/gsd-core/bin/shared/exit-codes.json +8 -0
  163. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  164. package/gsd-core/bin/shared/model-catalog.json +8 -1
  165. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  166. package/gsd-core/references/agent-contracts.md +6 -5
  167. package/gsd-core/references/api-coverage.md +24 -2
  168. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  169. package/gsd-core/references/checkpoints.md +37 -19
  170. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  171. package/gsd-core/references/edge-probe.md +17 -5
  172. package/gsd-core/references/execute-mvp-tdd.md +18 -18
  173. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  174. package/gsd-core/references/execute-phase-response-language.md +6 -0
  175. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  176. package/gsd-core/references/executor-examples.md +42 -0
  177. package/gsd-core/references/failing-direction.md +78 -0
  178. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  179. package/gsd-core/references/gate-prompts.md +1 -1
  180. package/gsd-core/references/git-integration.md +5 -5
  181. package/gsd-core/references/git-planning-commit.md +3 -3
  182. package/gsd-core/references/gsd-run-resolver.md +1 -1
  183. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  184. package/gsd-core/references/model-profiles.md +1 -1
  185. package/gsd-core/references/mvp-concepts.md +2 -2
  186. package/gsd-core/references/nyquist-compliance.md +74 -0
  187. package/gsd-core/references/offer-next.md +3 -5
  188. package/gsd-core/references/phase-argument-parsing.md +3 -3
  189. package/gsd-core/references/plan-checker-examples.md +41 -0
  190. package/gsd-core/references/planner-antipatterns.md +25 -0
  191. package/gsd-core/references/planner-chunked.md +5 -1
  192. package/gsd-core/references/planner-coupling.md +42 -0
  193. package/gsd-core/references/planner-failing-direction.md +53 -0
  194. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  195. package/gsd-core/references/planner-quick-batch.md +71 -0
  196. package/gsd-core/references/planner-reviews.md +47 -0
  197. package/gsd-core/references/planner-revision.md +76 -3
  198. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  199. package/gsd-core/references/planning-config.md +39 -9
  200. package/gsd-core/references/response-language-directive.md +9 -0
  201. package/gsd-core/references/reviewer-instances.md +31 -0
  202. package/gsd-core/references/revision-loop.md +118 -11
  203. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  204. package/gsd-core/references/tdd.md +15 -12
  205. package/gsd-core/references/ui-brand.md +65 -21
  206. package/gsd-core/references/ui-consideration-probe.md +1 -1
  207. package/gsd-core/references/universal-anti-patterns.md +2 -2
  208. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  209. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  210. package/gsd-core/references/verify-mvp-mode.md +1 -1
  211. package/gsd-core/references/workstream-flag.md +11 -11
  212. package/gsd-core/templates/README.md +1 -1
  213. package/gsd-core/templates/SECURITY.md +3 -3
  214. package/gsd-core/templates/UI-SPEC.md +25 -3
  215. package/gsd-core/templates/VALIDATION.md +3 -3
  216. package/gsd-core/templates/phase-prompt.md +7 -0
  217. package/gsd-core/templates/state.md +7 -0
  218. package/gsd-core/templates/verification-report.md +5 -0
  219. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  220. package/gsd-core/workflows/add-backlog.md +3 -1
  221. package/gsd-core/workflows/add-phase.md +5 -3
  222. package/gsd-core/workflows/add-tests.md +4 -9
  223. package/gsd-core/workflows/add-todo.md +2 -2
  224. package/gsd-core/workflows/ai-integration-phase.md +5 -10
  225. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  226. package/gsd-core/workflows/audit-fix.md +14 -3
  227. package/gsd-core/workflows/audit-milestone.md +11 -9
  228. package/gsd-core/workflows/audit-uat.md +19 -2
  229. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  230. package/gsd-core/workflows/autonomous.md +12 -26
  231. package/gsd-core/workflows/check-todos.md +2 -2
  232. package/gsd-core/workflows/cleanup.md +3 -3
  233. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
  234. package/gsd-core/workflows/code-review-fix.md +3 -1
  235. package/gsd-core/workflows/code-review.md +192 -69
  236. package/gsd-core/workflows/complete-milestone.md +28 -14
  237. package/gsd-core/workflows/debug.md +6 -4
  238. package/gsd-core/workflows/diagnose-issues.md +17 -7
  239. package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
  240. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  241. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  242. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  243. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  244. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
  245. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  246. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  247. package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
  248. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  249. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  250. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  251. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
  252. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  253. package/gsd-core/workflows/discuss-phase.md +2 -2
  254. package/gsd-core/workflows/do.md +46 -19
  255. package/gsd-core/workflows/docs-update.md +6 -5
  256. package/gsd-core/workflows/edit-phase.md +3 -1
  257. package/gsd-core/workflows/eval-review.md +5 -10
  258. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
  259. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -11
  260. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  261. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  262. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  263. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +29 -5
  264. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  265. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  266. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +4 -2
  267. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  268. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  269. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  270. package/gsd-core/workflows/execute-phase.md +68 -66
  271. package/gsd-core/workflows/execute-plan.md +25 -20
  272. package/gsd-core/workflows/explore.md +3 -1
  273. package/gsd-core/workflows/extract-learnings.md +3 -1
  274. package/gsd-core/workflows/fast.md +8 -2
  275. package/gsd-core/workflows/forensics.md +3 -1
  276. package/gsd-core/workflows/graduation.md +6 -6
  277. package/gsd-core/workflows/health.md +4 -7
  278. package/gsd-core/workflows/help/modes/brief.md +2 -0
  279. package/gsd-core/workflows/help/modes/default.md +2 -0
  280. package/gsd-core/workflows/help/modes/full.md +12 -0
  281. package/gsd-core/workflows/help/modes/topic.md +2 -0
  282. package/gsd-core/workflows/help.md +2 -0
  283. package/gsd-core/workflows/import.md +17 -14
  284. package/gsd-core/workflows/inbox.md +5 -6
  285. package/gsd-core/workflows/ingest-docs.md +45 -12
  286. package/gsd-core/workflows/insert-phase.md +7 -5
  287. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  288. package/gsd-core/workflows/list-seeds.md +7 -3
  289. package/gsd-core/workflows/list-workspaces.md +3 -1
  290. package/gsd-core/workflows/manager.md +15 -26
  291. package/gsd-core/workflows/map-codebase.md +3 -1
  292. package/gsd-core/workflows/milestone-summary.md +3 -1
  293. package/gsd-core/workflows/mvp-phase.md +3 -3
  294. package/gsd-core/workflows/new-milestone.md +10 -22
  295. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  296. package/gsd-core/workflows/new-project.md +17 -29
  297. package/gsd-core/workflows/new-workspace.md +2 -2
  298. package/gsd-core/workflows/next.md +4 -2
  299. package/gsd-core/workflows/node-repair.md +2 -0
  300. package/gsd-core/workflows/note.md +2 -0
  301. package/gsd-core/workflows/onboard.md +1 -1
  302. package/gsd-core/workflows/pause-work.md +20 -5
  303. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  304. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  305. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
  306. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
  307. package/gsd-core/workflows/plan-phase.md +251 -54
  308. package/gsd-core/workflows/plan-review-convergence.md +148 -19
  309. package/gsd-core/workflows/plant-seed.md +3 -3
  310. package/gsd-core/workflows/pr-branch.md +195 -51
  311. package/gsd-core/workflows/profile-user.md +17 -15
  312. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  313. package/gsd-core/workflows/progress.md +52 -15
  314. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  315. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
  316. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  317. package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
  318. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  319. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  320. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  321. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  322. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  323. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  324. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  325. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  326. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  327. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  328. package/gsd-core/workflows/quick-batch.md +203 -0
  329. package/gsd-core/workflows/quick.md +33 -32
  330. package/gsd-core/workflows/reapply-patches.md +2 -0
  331. package/gsd-core/workflows/remove-phase.md +6 -4
  332. package/gsd-core/workflows/remove-workspace.md +3 -3
  333. package/gsd-core/workflows/resume-project.md +14 -14
  334. package/gsd-core/workflows/review.md +404 -21
  335. package/gsd-core/workflows/scan.md +3 -1
  336. package/gsd-core/workflows/section-manifest.json +12 -0
  337. package/gsd-core/workflows/secure-phase.md +3 -3
  338. package/gsd-core/workflows/session-report.md +2 -0
  339. package/gsd-core/workflows/settings-advanced.md +9 -9
  340. package/gsd-core/workflows/settings-integrations.md +66 -32
  341. package/gsd-core/workflows/settings.md +4 -6
  342. package/gsd-core/workflows/ship.md +22 -16
  343. package/gsd-core/workflows/sketch-wrap-up.md +13 -17
  344. package/gsd-core/workflows/sketch.md +13 -19
  345. package/gsd-core/workflows/smart-entry.md +4 -6
  346. package/gsd-core/workflows/spec-phase.md +31 -4
  347. package/gsd-core/workflows/spike-wrap-up.md +9 -11
  348. package/gsd-core/workflows/spike.md +21 -32
  349. package/gsd-core/workflows/stats.md +4 -2
  350. package/gsd-core/workflows/sync-skills.md +13 -5
  351. package/gsd-core/workflows/thread.md +13 -7
  352. package/gsd-core/workflows/transition.md +7 -5
  353. package/gsd-core/workflows/ui-phase.md +36 -21
  354. package/gsd-core/workflows/ui-review.md +7 -11
  355. package/gsd-core/workflows/ultraplan-phase.md +7 -13
  356. package/gsd-core/workflows/undo.md +9 -17
  357. package/gsd-core/workflows/update.md +47 -48
  358. package/gsd-core/workflows/validate-phase.md +3 -3
  359. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  360. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  361. package/gsd-core/workflows/verify-work.md +106 -21
  362. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  363. package/hooks/dist/gsd-check-update-worker.js +19 -2
  364. package/hooks/dist/gsd-config-reload.js +18 -12
  365. package/hooks/dist/gsd-context-monitor.js +302 -22
  366. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  367. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  368. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  369. package/hooks/dist/gsd-cursor-stop.js +2 -1
  370. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  371. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  372. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  373. package/hooks/dist/gsd-graphify-update.sh +22 -18
  374. package/hooks/dist/gsd-node-runner.sh +77 -0
  375. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  376. package/hooks/dist/gsd-prompt-guard.js +46 -12
  377. package/hooks/dist/gsd-read-guard.js +18 -7
  378. package/hooks/dist/gsd-read-injection-scanner.js +22 -13
  379. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  380. package/hooks/dist/gsd-session-state.sh +1 -0
  381. package/hooks/dist/gsd-statusline.js +222 -29
  382. package/hooks/dist/gsd-validate-commit.sh +523 -12
  383. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  384. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  385. package/hooks/dist/gsd-workflow-guard.js +36 -17
  386. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  387. package/hooks/dist/gsd-write-guard.js +35 -25
  388. package/hooks/dist/lib/cli-exit.js +560 -0
  389. package/hooks/dist/lib/exit-code-registry.js +98 -0
  390. package/hooks/dist/lib/git-cmd.js +210 -1
  391. package/hooks/dist/lib/git-probe.js +84 -0
  392. package/hooks/dist/lib/hook-exit.js +81 -0
  393. package/hooks/dist/lib/injection-patterns.js +36 -6
  394. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  395. package/hooks/gsd-agent-isolation-guard.js +77 -38
  396. package/hooks/gsd-check-update-worker.js +19 -2
  397. package/hooks/gsd-config-reload.js +18 -12
  398. package/hooks/gsd-context-monitor.js +302 -22
  399. package/hooks/gsd-cursor-post-tool.js +3 -1
  400. package/hooks/gsd-cursor-pre-tool.js +3 -1
  401. package/hooks/gsd-cursor-session-start.js +2 -1
  402. package/hooks/gsd-cursor-stop.js +2 -1
  403. package/hooks/gsd-cursor-subagent-start.js +28 -23
  404. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  405. package/hooks/gsd-ensure-canonical-path.js +2 -1
  406. package/hooks/gsd-graphify-update.sh +22 -18
  407. package/hooks/gsd-node-runner.sh +77 -0
  408. package/hooks/gsd-phase-boundary.sh +1 -0
  409. package/hooks/gsd-prompt-guard.js +46 -12
  410. package/hooks/gsd-read-guard.js +18 -7
  411. package/hooks/gsd-read-injection-scanner.js +22 -13
  412. package/hooks/gsd-secret-read-guard.js +1079 -0
  413. package/hooks/gsd-session-state.sh +1 -0
  414. package/hooks/gsd-statusline.js +222 -29
  415. package/hooks/gsd-validate-commit.sh +523 -12
  416. package/hooks/gsd-windsurf-pre-command.js +16 -11
  417. package/hooks/gsd-windsurf-pre-write.js +22 -13
  418. package/hooks/gsd-workflow-guard.js +36 -17
  419. package/hooks/gsd-worktree-path-guard.js +36 -21
  420. package/hooks/gsd-write-guard.js +35 -25
  421. package/hooks/hooks.json +6 -0
  422. package/hooks/lib/cli-exit.js +560 -0
  423. package/hooks/lib/exit-code-registry.js +98 -0
  424. package/hooks/lib/git-cmd.js +210 -1
  425. package/hooks/lib/git-probe.js +84 -0
  426. package/hooks/lib/hook-exit.js +81 -0
  427. package/hooks/lib/injection-patterns.js +36 -6
  428. package/hooks/managed-hooks-registry.cjs +4 -0
  429. package/package.json +14 -9
  430. package/scripts/base64-scan.sh +74 -12
  431. package/scripts/build-hooks.js +12 -0
  432. package/scripts/check-glossary-refs.cjs +77 -15
  433. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  434. package/scripts/ci-check-job-near-cap.cjs +49 -0
  435. package/scripts/ci-pr-mergeability.cjs +262 -0
  436. package/scripts/ci-test-scope.cjs +52 -12
  437. package/scripts/ci-timeout-report.cjs +230 -0
  438. package/scripts/docs-guard-registry.cjs +406 -0
  439. package/scripts/gen-capability-registry.cjs +8 -6
  440. package/scripts/gen-exit-code-docs.cjs +318 -0
  441. package/scripts/gen-exit-code-registry.cjs +891 -0
  442. package/scripts/gen-features.cjs +836 -0
  443. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  444. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  445. package/scripts/gen-loop-host-contract.cjs +189 -4
  446. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  447. package/scripts/gen-state-md-docs.cjs +727 -0
  448. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  449. package/scripts/lib/ci-job-timing.cjs +72 -0
  450. package/scripts/lib/cli-exit.cjs +546 -44
  451. package/scripts/lib/drift-scan.cjs +32 -2
  452. package/scripts/lib/exit-code-registry.cjs +98 -0
  453. package/scripts/lib/ndjson-reporter.cjs +119 -0
  454. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  455. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  456. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  457. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  458. package/scripts/lint-docs-guard-registration.cjs +495 -0
  459. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +198 -0
  460. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  461. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  462. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  463. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  464. package/scripts/lint-phase-enumeration-drift.cjs +45 -14
  465. package/scripts/lint-phase-id-drift.cjs +133 -8
  466. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  467. package/scripts/lint-portable-grep.cjs +176 -0
  468. package/scripts/lint-removed-but-needed.cjs +184 -16
  469. package/scripts/lint-response-language-coverage.cjs +524 -0
  470. package/scripts/lint-seam-enforcement.cjs +182 -0
  471. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  472. package/scripts/lint-source-test-name-collision.cjs +241 -0
  473. package/scripts/lint-state-write-path-drift.cjs +337 -432
  474. package/scripts/lint-test-file-count.allowlist.json +124 -4
  475. package/scripts/lint-test-file-count.cjs +25 -3
  476. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  477. package/scripts/lint-vendored-deps.cjs +208 -35
  478. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  479. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  480. package/scripts/mutation-matrix.cjs +599 -50
  481. package/scripts/npm-audit-baseline.cjs +376 -0
  482. package/scripts/prompt-injection-scan.sh +83 -14
  483. package/scripts/require-issue-link-policy.cjs +16 -1
  484. package/scripts/secret-scan.sh +75 -13
  485. package/scripts/select-docs-guards.cjs +56 -0
  486. package/scripts/sync-runtime-launcher.cjs +22 -3
  487. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  488. package/skills/gsd-execute-phase/SKILL.md +1 -1
  489. package/skills/gsd-import/SKILL.md +1 -1
  490. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  491. package/skills/gsd-phase/SKILL.md +1 -1
  492. package/skills/gsd-quick/SKILL.md +8 -4
  493. package/skills/gsd-quick-batch/SKILL.md +105 -0
  494. package/skills/gsd-surface/SKILL.md +18 -8
  495. package/vscode/package.json +1 -1
  496. package/bin/lib/ui-safety-gate.cjs +0 -109
  497. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  498. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -15,8 +15,16 @@
15
15
  * file I/O, and the disk-scan wrap it.
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.STATE_MD_SECTIONS = exports.FIELD_CLASSIFICATION = void 0;
18
+ exports.STATE_MD_SECTIONS = exports.FRONTMATTER_BODY_SOURCE = exports.FIELD_CLASSIFICATION = void 0;
19
+ exports.formatProgressMachineSegment = formatProgressMachineSegment;
20
+ exports.stateReplaceProgressPercent = stateReplaceProgressPercent;
21
+ exports.beginFrontmatterReassembly = beginFrontmatterReassembly;
22
+ exports.getFrontmatterBodySource = getFrontmatterBodySource;
23
+ exports.frontmatterKeyForBodyField = frontmatterKeyForBodyField;
19
24
  exports.getFieldClassification = getFieldClassification;
25
+ exports.getPreserveWhenUnchangedFields = getPreserveWhenUnchangedFields;
26
+ exports.openStateTransaction = openStateTransaction;
27
+ exports.rebuildStateTransaction = rebuildStateTransaction;
20
28
  exports.applyPreserveWhenUnchanged = applyPreserveWhenUnchanged;
21
29
  exports.applyStatePreservation = applyStatePreservation;
22
30
  exports.transitionCore = transitionCore;
@@ -28,7 +36,90 @@ const state_document_cjs_2 = require("./state-document.cjs");
28
36
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
29
37
  const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
30
38
  const pattern_cjs_1 = require("./pattern.cjs");
31
- const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter;
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ const stateMdSchemaMod = require("./state-md-schema.cjs");
41
+ const { STATE_FIELD_SCHEMA } = stateMdSchemaMod;
42
+ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatter;
43
+ function formatProgressMachineSegment(percent) {
44
+ // ADR-3180 Decision 7: rounding and the 100 ceiling belong to the
45
+ // completion-ratio kernel. The floor is added here because this helper is
46
+ // also fed persisted frontmatter values (hand-editable, unlike the
47
+ // count-shaped entries into that kernel), and `'░'.repeat` throws on a
48
+ // negative count. Bar and printed percent use the clamped value so the two
49
+ // halves of the segment can never disagree.
50
+ const clamped = Math.max(0, (0, phase_lifecycle_cjs_1.clampPercentFromFraction)(percent / 100));
51
+ const filled = Math.round(clamped / 10);
52
+ return `[${'█'.repeat(filled)}${'░'.repeat(10 - filled)}] ${clamped}%`;
53
+ }
54
+ // Consumers (a future STATE.md writer that bypasses all three reintroduces the
55
+ // #4213 divergence class): `cmdStateUpdateProgress` and `syncCore`'s progress
56
+ // intent (both in this module) plus the post-sync body reconciliation in
57
+ // `applyPostSyncPreservation` (src/state.cts). `cmdStateSync` never reaches
58
+ // that reconciliation — ADR-3408 §8.3: `state sync` lets the body win, so
59
+ // preservation must NOT run — which is why its correctness comes from
60
+ // `syncCore`'s call here.
61
+ function stateReplaceProgressPercent(content, percent) {
62
+ const body = stripFrontmatter(content);
63
+ // #2177: bold `**Progress:**` anywhere in the body wins outright; the plain
64
+ // `^Progress:` form is the fallback only when no bold line exists, so an
65
+ // earlier free-text line starting with `Progress:` cannot capture the
66
+ // rewrite ahead of the real status line.
67
+ const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
68
+ const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
69
+ const pattern = boldProgressPattern.test(body)
70
+ ? boldProgressPattern
71
+ : plainProgressPattern.test(body)
72
+ ? plainProgressPattern
73
+ : null;
74
+ if (!pattern)
75
+ return null;
76
+ const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
77
+ const progress = formatProgressMachineSegment(percent);
78
+ const updatedBody = body.replace(pattern, (_match, prefix, value) => (`${prefix}${machineSegment.test(value) ? value.replace(machineSegment, progress) : progress}`));
79
+ return content.slice(0, content.length - body.length) + updatedBody;
80
+ }
81
+ /**
82
+ * ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
83
+ * `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a
84
+ * frontmatter-fenced region exists but failed to parse (malformed YAML, or a
85
+ * refused anchor/alias/merge key)? A plain `Object.keys(existingFm).length >
86
+ * 0` check cannot distinguish that case from "no frontmatter block at all" —
87
+ * both parse to `{}` — so every `hasFrontmatter`-gated reassemble below would
88
+ * silently drop the raw frontmatter block on the next write. The marker is a
89
+ * non-enumerable-to-Object.keys Symbol key, so this check is additive and
90
+ * never fires for the genuinely-empty case.
91
+ */
92
+ function isUnparseableFrontmatter(existingFm) {
93
+ return existingFm[FRONTMATTER_UNPARSEABLE] === true;
94
+ }
95
+ /**
96
+ * ADR-3473 §8.1 (#3881): the exact bytes `stripFrontmatter` removed from the
97
+ * front of `content` to produce `strippedBody` — i.e. `content`'s raw
98
+ * frontmatter-fenced prefix, verbatim, whether or not it parsed. Reassembling
99
+ * with this prefix (instead of dropping it under `hasFrontmatter === false`)
100
+ * is what preserves an UNPARSEABLE frontmatter block across a write; it is a
101
+ * no-op difference from `content` itself when `strippedBody === content`
102
+ * (nothing was stripped).
103
+ */
104
+ function rawFrontmatterPrefix(content, strippedBody) {
105
+ return content.slice(0, content.length - strippedBody.length);
106
+ }
107
+ function beginFrontmatterReassembly(content, sourcePath) {
108
+ const existingFm = extractFrontmatter(content, sourcePath);
109
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
110
+ const body = stripFrontmatter(content);
111
+ // ADR-3473 §8.1 (#3881): computed from the ORIGINAL content/body pair, before any caller
112
+ // reassigns `body` further — the captured prefix is always the exact bytes stripped from
113
+ // the ORIGINAL content, regardless of what the caller does with `body` afterward.
114
+ const fmPrefix = rawFrontmatterPrefix(content, body);
115
+ const unparseableFm = isUnparseableFrontmatter(existingFm);
116
+ const reassemble = (b) => hasFrontmatter
117
+ ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
118
+ : unparseableFm
119
+ ? `${fmPrefix}${b}`
120
+ : b;
121
+ return { existingFm, hasFrontmatter, body, fmPrefix, unparseableFm, reassemble };
122
+ }
32
123
  // Stop predicate for section-body slicing: a level-2+ heading ends the section.
33
124
  const STOP_H2_PLUS = (lv) => lv >= 2;
34
125
  /**
@@ -45,45 +136,150 @@ const STOP_H2_PLUS = (lv) => lv >= 2;
45
136
  * (`FIELD_CLASSIFICATION['toString']` returns undefined, not the inherited
46
137
  * function). Use `getFieldClassification()` for lookups.
47
138
  */
48
- exports.FIELD_CLASSIFICATION = Object.freeze(Object.assign(Object.create(null), {
49
- // Schema
50
- gsd_state_version: { source: 'free', preservation: 'derive' },
51
- // Milestone (external — from ROADMAP.md)
52
- milestone: { source: 'external', preservation: 'preserve-if-placeholder' },
53
- milestone_name: { source: 'external', preservation: 'preserve-if-placeholder' },
54
- // Phase / plan position (body-derived)
55
- current_phase: { source: 'body', preservation: 'preserve-when-unchanged' },
56
- // #1743, #1695. #3468: row corrected to match its long-standing behavior
57
- // — was declared preserve-always, has always been delta-gated (only
58
- // restores when the body `Phase:` source is unchanged this write).
59
- current_phase_name: { source: 'curated', preservation: 'preserve-when-unchanged' },
60
- current_plan: { source: 'body', preservation: 'preserve-when-unchanged' },
61
- // Status / lifecycle (body-derived; #1230 delta heuristic applies)
62
- // guard: the 'unknown' sentinel is the ONLY true executor-side guard in
63
- // this table (stopped_at's `## Session` scoping is caller-side delta
64
- // extraction, not an executor condition) — ADR-3408 Decision 1.
65
- status: { source: 'body', preservation: 'preserve-when-unchanged', guard: 'non-sentinel-unknown' },
66
- stopped_at: { source: 'body', preservation: 'preserve-when-unchanged' },
67
- paused_at: { source: 'body', preservation: 'preserve-when-unchanged' },
68
- // Activity log
69
- last_updated: { source: 'free', preservation: 'derive' }, // realClock.nowIso()
70
- last_activity: { source: 'body', preservation: 'derive' }, // always refresh on transition
71
- last_activity_desc: { source: 'body', preservation: 'preserve-when-unchanged' },
72
- // Commit provenance (#2573) — ambient git read, recomputed on every write,
73
- // exactly like last_updated. Never preserved: a stale stamp would claim
74
- // STATE.md was written against a commit it wasn't.
75
- state_head: { source: 'free', preservation: 'derive' }, // #2573
76
- // Progress block (disk-derived, except the curated progress ratchet)
77
- // mergeStrategy: 'progress-ratchet' — completed_plans/completed_phases
78
- // only ever ratchet UP toward the derived value (#2969); everything
79
- // else in the merge is either always-derived (#2440) or always-curated.
80
- progress: { source: 'curated', preservation: 'preserve-always', mergeStrategy: 'progress-ratchet' }, // #3242, #1446
81
- 'progress.total_phases': { source: 'disk', preservation: 'derive' },
82
- 'progress.completed_phases': { source: 'disk', preservation: 'derive' },
83
- 'progress.total_plans': { source: 'disk', preservation: 'derive' },
84
- 'progress.completed_plans': { source: 'disk', preservation: 'derive' },
85
- 'progress.percent': { source: 'disk', preservation: 'derive' },
86
- }));
139
+ /**
140
+ * #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
141
+ * (`src/state-md-schema.cts`) rather than hand-maintained here. Byte-identical
142
+ * to the pre-#3873 literal table — same 19 keys, same key ORDER (walks
143
+ * `Object.keys(STATE_FIELD_SCHEMA)` directly; see that module's row-order
144
+ * comment for why this is the one projection allowed to do that), same
145
+ * per-row shape (`{source, preservation, guard?, mergeStrategy?}`, in that
146
+ * key order, `guard`/`mergeStrategy` present only when the schema row carries
147
+ * them — never as an `undefined` own-property), same frozen null-prototype
148
+ * container. Pinned by `tests/state-transition.test.cjs`'s
149
+ * `fieldClassificationProjectionMatchesTodaysTable`, whose comparand is
150
+ * today's literal copied VERBATIM into the test (never re-derived from this
151
+ * schema — see that test's own docstring on why a self-referential parity
152
+ * test proves nothing).
153
+ */
154
+ exports.FIELD_CLASSIFICATION = Object.freeze(Object.keys(STATE_FIELD_SCHEMA).reduce((acc, key) => {
155
+ const row = STATE_FIELD_SCHEMA[key];
156
+ const projected = { source: row.source, preservation: row.preservation };
157
+ if (row.guard !== undefined)
158
+ projected.guard = row.guard;
159
+ if (row.mergeStrategy !== undefined)
160
+ projected.mergeStrategy = row.mergeStrategy;
161
+ acc[key] = projected;
162
+ return acc;
163
+ }, Object.create(null)));
164
+ /**
165
+ * Which BODY field feeds each frontmatter key.
166
+ *
167
+ * `FIELD_CLASSIFICATION` above answers "who wins when frontmatter and body
168
+ * disagree"; this answers "and what is the body one called". They are separate
169
+ * questions and this one is display/routing knowledge, not preservation policy,
170
+ * so it does not widen the ADR-3408-governed table.
171
+ *
172
+ * #3699: `state update stopped_at …` reported `Field "stopped_at" not found in
173
+ * STATE.md` — byte-identical to what a genuinely absent field reports. The key
174
+ * IS present; it is a projection of a body field, and the message pointed away
175
+ * from the route that works. Naming the source is what makes the two cases
176
+ * distinguishable.
177
+ *
178
+ * Transcribed from `buildStateFrontmatter` (`state.cts`), which is the real
179
+ * deriver. That makes this a SECOND copy of knowledge that already exists, so it
180
+ * ships with a parity test asserting this key set equals the body-derived key set
181
+ * the builder actually emits (CLAUDE.md → Generative Fix Divergence). Keys the
182
+ * builder derives from disk, an external file, or the clock have no body source
183
+ * and are deliberately ABSENT here rather than mapped to a lie.
184
+ */
185
+ /**
186
+ * #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
187
+ * (`src/state-md-schema.cts`)'s `bodySource` field, in this EXPLICIT key
188
+ * order. This order is NOT `STATE_FIELD_SCHEMA`'s own row order filtered down
189
+ * to the body-sourced keys — the pre-#3873 literal already put `status`
190
+ * before `stopped_at`/`paused_at` here while `FRONTMATTER_KEY_TO_BODY_LABEL`
191
+ * (`src/state.cts`) put it AFTER them, i.e. the two pre-existing tables
192
+ * disagreed with each other's order too, and this projection must reproduce
193
+ * ITS table's order specifically. Byte-identical to the pre-#3873 literal —
194
+ * same 8 keys, same order, same frozen null-prototype container with frozen
195
+ * per-key arrays. Pinned by `tests/state-transition.test.cjs`'s
196
+ * `bodySourceProjectionMatchesTodaysTable`.
197
+ */
198
+ const FRONTMATTER_BODY_SOURCE_KEY_ORDER = Object.freeze([
199
+ 'current_phase',
200
+ 'current_phase_name',
201
+ 'current_plan',
202
+ 'status',
203
+ 'stopped_at',
204
+ 'paused_at',
205
+ 'last_activity',
206
+ 'last_activity_desc',
207
+ ]);
208
+ exports.FRONTMATTER_BODY_SOURCE = Object.freeze(FRONTMATTER_BODY_SOURCE_KEY_ORDER.reduce((acc, key) => {
209
+ const row = STATE_FIELD_SCHEMA[key];
210
+ acc[key] = Object.freeze([...(row.bodySource ?? [])]);
211
+ return acc;
212
+ }, Object.create(null)));
213
+ /**
214
+ * The frontmatter keys whose body source lives inside `## Session`.
215
+ *
216
+ * #3374 established that these fields must be written where the reader reads
217
+ * them: `buildStateFrontmatter` harvests `Stopped At` / `Paused At` from the
218
+ * session section only, so a whole-body replace "lets a decoy `**Stopped at:**`
219
+ * line in an unrelated (e.g. archive) section absorb the refresh while the
220
+ * harvested session value stays stale" (`stateReplaceFieldInSession`'s own
221
+ * docstring). `updateCore` was still doing the whole-body replace.
222
+ */
223
+ const SESSION_SCOPED_KEYS = new Set(['stopped_at', 'paused_at']);
224
+ /**
225
+ * The `(primary, fallback)` label pair for a session-scoped frontmatter KEY.
226
+ */
227
+ function sessionLabelsForKey(key) {
228
+ if (!SESSION_SCOPED_KEYS.has(key))
229
+ return null;
230
+ const labels = exports.FRONTMATTER_BODY_SOURCE[key];
231
+ return { primary: labels[0], fallback: labels[1] ?? null };
232
+ }
233
+ /**
234
+ * The same pair, resolved from a BODY LABEL the caller named (`Stopped At`,
235
+ * `Stopped at`, `Paused At`). `null` for anything else.
236
+ *
237
+ * Deliberately does NOT accept a frontmatter key. An earlier cut resolved both
238
+ * spellings through one function and used it for the write, which made
239
+ * `state update stopped_at …` write the BODY line through the session writer —
240
+ * silently defeating the "frontmatter keys are not directly writable" contract
241
+ * this whole change exists to state, and reporting `updated: false` while having
242
+ * written. The write may only ever be reached by naming a body field.
243
+ */
244
+ function sessionLabelsForBodyField(field) {
245
+ const key = frontmatterKeyForBodyField(field);
246
+ return key === null ? null : sessionLabelsForKey(key);
247
+ }
248
+ /**
249
+ * Would a session-scoped write actually land? Asks by attempting the real write
250
+ * with a throwaway value and seeing whether anything moved.
251
+ *
252
+ * Deliberately reuses the writer rather than re-deriving "where is the session
253
+ * section" — a separate scope check could disagree with the writer, and a
254
+ * presence check that disagrees with the write it guards is the whole bug class
255
+ * here. `stateReplaceFieldInSession` is replace-only and pure, so probing costs
256
+ * nothing and the result is discarded.
257
+ */
258
+ function sessionSourceExists(body, labels) {
259
+ return (0, state_document_cjs_1.stateReplaceFieldInSession)(body, labels.primary, labels.fallback, '\u0000probe') !== body;
260
+ }
261
+ /**
262
+ * Own-property body-source lookup. `null` for a key with no body source (a
263
+ * disk/external/clock-derived key) and for anything not a frontmatter key.
264
+ */
265
+ function getFrontmatterBodySource(field) {
266
+ if (!Object.prototype.hasOwnProperty.call(exports.FRONTMATTER_BODY_SOURCE, field))
267
+ return null;
268
+ return exports.FRONTMATTER_BODY_SOURCE[field];
269
+ }
270
+ /**
271
+ * Reverse lookup: the frontmatter key a body field feeds, or `null`.
272
+ * Lets a failed body-field update name the frontmatter key that still carries a
273
+ * value (#3699 case D), instead of reporting a bare absence.
274
+ */
275
+ function frontmatterKeyForBodyField(bodyField) {
276
+ const wanted = bodyField.trim().toLowerCase();
277
+ for (const key of Object.keys(exports.FRONTMATTER_BODY_SOURCE)) {
278
+ if (exports.FRONTMATTER_BODY_SOURCE[key].some((f) => f.toLowerCase() === wanted))
279
+ return key;
280
+ }
281
+ return null;
282
+ }
87
283
  /**
88
284
  * Own-property classification lookup. Returns `null` for unknown fields
89
285
  * (including inherited prototype methods like `toString`/`valueOf`).
@@ -93,6 +289,94 @@ function getFieldClassification(field) {
93
289
  return null;
94
290
  return exports.FIELD_CLASSIFICATION[field];
95
291
  }
292
+ /**
293
+ * #3836: the single source of truth for "which frontmatter keys carry the
294
+ * `preserve-when-unchanged` policy" — read straight off `FIELD_CLASSIFICATION`
295
+ * rather than re-typed as a hand-maintained literal array at each consumer.
296
+ * `cmdStateJson` (`state.cts`) previously hardcoded a 6-field list that had
297
+ * already drifted from this table by one row (`last_activity_desc`, #3258) —
298
+ * exactly the "second table parallel to the first" shape ADR-3473 exists to
299
+ * remove. `progress`/`milestone`/`milestone_name` carry a different
300
+ * preservation policy (`preserve-always` / `preserve-if-placeholder`) and are
301
+ * naturally excluded by the filter, not by a separate exclusion list.
302
+ */
303
+ function getPreserveWhenUnchangedFields() {
304
+ return Object.keys(exports.FIELD_CLASSIFICATION).filter((field) => exports.FIELD_CLASSIFICATION[field].preservation === 'preserve-when-unchanged');
305
+ }
306
+ /**
307
+ * Shared constructor body for `openStateTransaction` / `rebuildStateTransaction`
308
+ * (ADR-3473 §8.6 Decision 2/3). Validates `init.snapshot` and freezes the
309
+ * result so nothing downstream can mutate a transaction after construction
310
+ * (this is what makes the aliasing fix in `applyPreserveAlways`'s clone hold:
311
+ * the snapshot a caller passed in cannot be rewritten out from under it).
312
+ *
313
+ * `{}` and a null-prototype object are BOTH legal snapshots (Decision 2 / row
314
+ * 15 of the behavior table): `extractFrontmatter` returns `{}` for a document
315
+ * with no frontmatter or an unterminated one and never returns null or throws
316
+ * (`src/frontmatter.cts`), so `{}` is the honest snapshot of a real document —
317
+ * and `/gsd-health --repair`, which runs precisely when STATE.md is broken,
318
+ * depends on that staying legal. What is NOT legal is the snapshot being
319
+ * ABSENT (`null`/`undefined`/an array/a non-object): that is the caller
320
+ * forgetting to read the pre-write document at all, a construction failure,
321
+ * not a data question. Conflating "absent" with "empty" would turn the repair
322
+ * path's normal case into a hard throw.
323
+ */
324
+ function createStateTransaction(kind, init, ctorName) {
325
+ if (init === null || typeof init !== 'object' || Array.isArray(init)) {
326
+ const err = new Error(`${ctorName}: expected an init object, got ${init === null ? 'null' : typeof init}. ` +
327
+ 'Per ADR-3473 §8.6 / Decision 2, an absent init is a construction failure, distinct from ' +
328
+ 'a legal empty snapshot ({}) — do not "fix" this by tolerating null.');
329
+ err.code = 'STATE_TRANSACTION_SNAPSHOT_REQUIRED';
330
+ err.constructorName = ctorName;
331
+ throw err;
332
+ }
333
+ const snapshot = init.snapshot;
334
+ if (snapshot === null || snapshot === undefined || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
335
+ const err = new Error(`${ctorName}: init.snapshot is required and must be a non-array object (frontmatter map). ` +
336
+ `Per ADR-3473 §8.6 / Decision 2, an ABSENT snapshot is a construction failure — this is NOT ` +
337
+ 'the same as a legal EMPTY snapshot ({}), which every executor accepts and simply finds ' +
338
+ 'nothing to restore from (extractFrontmatter returns {} for a document with no parseable ' +
339
+ 'frontmatter, and /gsd-health --repair depends on that staying legal). Pass {} explicitly ' +
340
+ 'when the document truly has none; do not tolerate null/undefined here.');
341
+ err.code = 'STATE_TRANSACTION_SNAPSHOT_REQUIRED';
342
+ err.constructorName = ctorName;
343
+ throw err;
344
+ }
345
+ return Object.freeze({
346
+ kind,
347
+ snapshot,
348
+ resync: init.resync === true,
349
+ deriveProgressKeys: init.deriveProgressKeys === true,
350
+ bodyDeltas: init.bodyDeltas,
351
+ explicitProgressField: init.explicitProgressField === true,
352
+ });
353
+ }
354
+ /**
355
+ * ADR-3473 §8.6's `open()`: the default write-path transaction. Carries the
356
+ * pre-write snapshot and applies preservation (`applyStatePreservation` runs
357
+ * its full dispatch loop against it) — this is every STATE.md write EXCEPT
358
+ * the two sanctioned exceptions below.
359
+ */
360
+ function openStateTransaction(init) {
361
+ return createStateTransaction('open', init, 'openStateTransaction');
362
+ }
363
+ /**
364
+ * ADR-3473 §8.6's `rebuild()`: the TYPED expression of ADR-3408 §8.3's closed
365
+ * list of sanctioned exceptions to the preservation pipeline. Exactly two
366
+ * callers may construct this: `cmdStateSync` (`state sync` re-derives
367
+ * frontmatter FROM the body per #905 — the body is authoritative and
368
+ * preservation would fight it) and `REGENERATE_STATE` (`/gsd-health --repair`'s
369
+ * factory reset — the whole point is to replace what's there). The snapshot
370
+ * is still carried (for §8.7's reporting) but `applyStatePreservation` skips
371
+ * its dispatch loop entirely for a `rebuild` transaction.
372
+ *
373
+ * This list is NOT debt to be paid down later — it is a closed, deliberate
374
+ * set. Adding a third caller is an amendment to ADR-3408 §8.3, not a call site
375
+ * convenience.
376
+ */
377
+ function rebuildStateTransaction(init) {
378
+ return createStateTransaction('rebuild', init, 'rebuildStateTransaction');
379
+ }
96
380
  /**
97
381
  * ADR-3408 §8.2: an unenforced `preserve-when-unchanged` row throws. Both
98
382
  * ends of this invariant are gsd-core's own source — a declared row the
@@ -146,7 +430,7 @@ function applyPreserveWhenUnchanged(field, cls, ctx) {
146
430
  // 2. Only a real, non-whitespace-only curated string is worth restoring
147
431
  // (#3468: tightened from `.length > 0` to a trimmed check — a whitespace-
148
432
  // only snapshot is not a real curated value).
149
- const snapshot = ctx.preFmSnapshot[field];
433
+ const snapshot = ctx.snapshot[field];
150
434
  if (typeof snapshot !== 'string' || snapshot.trim().length === 0)
151
435
  return;
152
436
  // 3. Closed-vocabulary guard: status's 'unknown' sentinel is never restored.
@@ -163,28 +447,111 @@ function applyPreserveWhenUnchanged(field, cls, ctx) {
163
447
  ctx.postFm[field] = snapshot;
164
448
  ctx.mutated = true;
165
449
  }
450
+ /**
451
+ * The closed set of `progress` keys whose non-zero value means "a real
452
+ * measurement happened" (ADR-3473 §8.6 / #3756).
453
+ */
454
+ const PROGRESS_TOTAL_KEYS = ['total_phases', 'total_plans'];
455
+ /**
456
+ * Did this row's derived (or curated) value represent a REAL measurement?
457
+ *
458
+ * For a `progress-ratchet` row (today, only `progress`): an empty
459
+ * milestone-scoped scan is "nothing was measured", not "zero is done"
460
+ * (#3756, and the convention #3233 established — `computeProgressPercent`
461
+ * already returns `null` for an empty denominator). Only the TOTALS decide:
462
+ * `completed_*` being zero is normal for a real project, so it is
463
+ * deliberately excluded from this check. A non-object / absent / negative /
464
+ * non-numeric total is NOT a measurement, so it degrades TOWARD preservation,
465
+ * never toward deletion — `toFiniteNumber` (not a raw `=== 0`/`> 0` test)
466
+ * because frontmatter scalars arrive as STRINGS (`"0"`, not `0`).
467
+ *
468
+ * For any other row (no `progress-ratchet` strategy) the question is
469
+ * meaningless, so it answers `true` and behavior is unchanged — this
470
+ * function is only ever consulted from inside the `preserve-always` /
471
+ * `progress-ratchet` branch below.
472
+ */
473
+ function scanMeasuredSomething(cls, value) {
474
+ if (cls.mergeStrategy !== 'progress-ratchet')
475
+ return true;
476
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
477
+ return false;
478
+ const rec = value;
479
+ return PROGRESS_TOTAL_KEYS.some((k) => ((0, state_document_cjs_2.toFiniteNumber)(rec[k]) ?? 0) > 0);
480
+ }
481
+ /**
482
+ * Deep-clone a curated value before it re-enters `postFm` (ADR-3473 §8.6,
483
+ * "Defects fixed inline" / aliasing). `structuredClone` is a Node built-in;
484
+ * this repo takes no external deps for it. WHY a clone and not a reference
485
+ * assignment: the transaction's `snapshot` is now the SAME object §8.7's
486
+ * reporting will diff against. Assigning the nested curated object by
487
+ * reference would make `postFm.progress` alias that snapshot, so a later
488
+ * in-place mutation of `postFm` would silently rewrite the snapshot too, and
489
+ * the diff would report "no change" for a field that did change.
490
+ */
491
+ function cloneCurated(value) {
492
+ return structuredClone(value);
493
+ }
494
+ /**
495
+ * Structural equality for a restored value vs. what `postFm` already held
496
+ * (ADR-3473 §8.6, "Defects fixed inline" / #948 no-op-write family).
497
+ * `JSON.stringify` compare when either side is an object (the `progress`
498
+ * block), `===` otherwise. WHY: `applyPreserveAlways` previously set
499
+ * `ctx.mutated = true` unconditionally at its tail, even when it restored a
500
+ * value identical to what was already there — driving a write that changes
501
+ * nothing but still bumps `last_updated` / restamps `state_head`.
502
+ * `applyPreserveWhenUnchanged` already guards this (its step 5); this brings
503
+ * the two executors into agreement.
504
+ */
505
+ function preservedValuesEqual(a, b) {
506
+ if (typeof a === 'object' || typeof b === 'object') {
507
+ return JSON.stringify(a) === JSON.stringify(b);
508
+ }
509
+ return a === b;
510
+ }
166
511
  /**
167
512
  * Executor for `preservation: 'preserve-always'` (ADR-3408 §8.1). Only
168
513
  * `progress` carries this policy today. Preserves #3242/#1446/#2440/#2969
169
- * semantics byte-for-byte: gated on `!resync` and a truthy `preFm[field]`;
170
- * the `mergeStrategy: 'progress-ratchet'` per-key merge only fires when the
171
- * caller opts in via `deriveProgressKeys`, else the whole curated block wins
172
- * wholesale.
514
+ * semantics byte-for-byte on every row the behavior table marks unchanged;
515
+ * ADR-3473 §8.6 fixes the #3756 defect (a resyncing write that measured
516
+ * nothing must not drop a real curated block) plus the two "Defects fixed
517
+ * inline" no-op-write / aliasing bugs.
173
518
  */
174
519
  function applyPreserveAlways(field, cls, ctx) {
175
- if (ctx.resync || !ctx.preFm || !ctx.preFm[field])
520
+ const curated = ctx.snapshot[field];
521
+ if (!curated)
522
+ return;
523
+ const derived = ctx.postFm[field];
524
+ const derivedMeasured = scanMeasuredSomething(cls, derived);
525
+ const curatedMeasured = scanMeasuredSomething(cls, curated);
526
+ // On a resyncing write the fresh derivation is authoritative — UNLESS it
527
+ // measured nothing while the curated block did (#3756), AND the caller did
528
+ // not explicitly name a progress-affecting field this write. The
529
+ // unmeasured-scan guard exists to stop an INCIDENTAL resync (e.g. `state
530
+ // add-decision`, whose `resync` defaults true for reasons that have
531
+ // nothing to do with `progress`) from dropping a real curated block when a
532
+ // milestone-scoped disk scan measures nothing (#3756's archived-milestone
533
+ // case). It must not also block a write the user pointed AT `progress` on
534
+ // purpose: `preserve-always`'s own contract is "never overwrite unless the
535
+ // caller explicitly names this field" (FIELD_CLASSIFICATION doc comment),
536
+ // and `state update Progress` / `state patch Progress=...` are exactly
537
+ // that naming — the resync they trigger must win even when the disk scan
538
+ // it also drives (e.g. because there are no phase dirs at all) reads as
539
+ // "unmeasured" (tests/frontmatter.test.cjs: "state.update \"Progress\"
540
+ // resyncs progress frontmatter from the updated body", pre-existing, #3242).
541
+ if (ctx.resync && (derivedMeasured || !curatedMeasured || ctx.explicitProgressField))
176
542
  return;
177
- if (cls.mergeStrategy === 'progress-ratchet' && ctx.deriveProgressKeys && ctx.postFm[field]) {
543
+ let next;
544
+ if (cls.mergeStrategy === 'progress-ratchet' && ctx.deriveProgressKeys && derived && derivedMeasured) {
178
545
  // #2440: total_plans and total_phases always take the derived (post-sync)
179
546
  // value even under !resync. This is used by cmdStatePlannedPhase where
180
547
  // total_plans must correct upward after plans are added. For body-only
181
548
  // writes (state.update/patch without the flag), the wholesale restore
182
549
  // below preserves everything as before — the #3242 Bug A protection
183
550
  // stays fully in force.
184
- const curated = ctx.preFm[field];
185
- const derived = (ctx.postFm[field] ?? {});
186
- const merged = { ...derived };
187
- if (curated) {
551
+ const curatedRecord = curated;
552
+ const derivedRecord = (derived ?? {});
553
+ const merged = { ...derivedRecord };
554
+ if (curatedRecord) {
188
555
  // #2440: total_plans and total_phases always take the derived value.
189
556
  // #2969: completed_plans and completed_phases take the derived value
190
557
  // when it is GREATER than the curated value (gap-closure plans that
@@ -195,11 +562,11 @@ function applyPreserveAlways(field, cls, ctx) {
195
562
  // disk counts, and a stale curated percent would be incoherent against
196
563
  // the ratcheted-up completed counts (e.g. 54/54 at 93%).
197
564
  const ratchetUpKeys = new Set(['completed_plans', 'completed_phases']);
198
- for (const [key, value] of Object.entries(curated)) {
565
+ for (const [key, value] of Object.entries(curatedRecord)) {
199
566
  if (key === 'total_plans' || key === 'total_phases' || key === 'percent')
200
567
  continue;
201
568
  if (ratchetUpKeys.has(key)) {
202
- const derivedNum = typeof derived[key] === 'number' ? derived[key] : -Infinity;
569
+ const derivedNum = typeof derivedRecord[key] === 'number' ? derivedRecord[key] : -Infinity;
203
570
  const curatedNum = typeof value === 'number' ? value : -Infinity;
204
571
  // Take the derived value only when it ratchets up (strictly
205
572
  // greater — #2969's `>` not `>=`); else keep curated.
@@ -212,11 +579,14 @@ function applyPreserveAlways(field, cls, ctx) {
212
579
  }
213
580
  }
214
581
  }
215
- ctx.postFm[field] = merged;
582
+ next = merged;
216
583
  }
217
584
  else {
218
- ctx.postFm[field] = ctx.preFm[field];
585
+ next = cloneCurated(curated);
219
586
  }
587
+ if (preservedValuesEqual(ctx.postFm[field], next))
588
+ return;
589
+ ctx.postFm[field] = next;
220
590
  ctx.mutated = true;
221
591
  }
222
592
  /**
@@ -241,7 +611,7 @@ function applyPreserveIfPlaceholder(_field, _cls, ctx) {
241
611
  && derivedName.length > 0
242
612
  && derivedName !== MILESTONE_PLACEHOLDER
243
613
  && !/^[\s—–:-]/.test(derivedName);
244
- const snapshotName = ctx.preFmSnapshot['milestone_name'];
614
+ const snapshotName = ctx.snapshot['milestone_name'];
245
615
  const snapshotNameIsReal = typeof snapshotName === 'string'
246
616
  && snapshotName.length > 0
247
617
  && snapshotName !== MILESTONE_PLACEHOLDER;
@@ -251,7 +621,7 @@ function applyPreserveIfPlaceholder(_field, _cls, ctx) {
251
621
  ctx.postFm['milestone_name'] = snapshotName;
252
622
  ctx.mutated = true;
253
623
  }
254
- const snapshotVersion = ctx.preFmSnapshot['milestone'];
624
+ const snapshotVersion = ctx.snapshot['milestone'];
255
625
  if (typeof snapshotVersion === 'string' && snapshotVersion.length > 0 &&
256
626
  ctx.postFm['milestone'] !== snapshotVersion) {
257
627
  ctx.postFm['milestone'] = snapshotVersion;
@@ -277,14 +647,22 @@ function applyDerive(_field, _cls, _ctx) {
277
647
  * any field was restored.
278
648
  */
279
649
  function applyStatePreservation(input) {
650
+ const { transaction } = input;
651
+ // A `rebuild()` transaction still carries the snapshot (§8.7's reporting
652
+ // needs it) but must not run preservation at all: `state sync` / `REGENERATE_STATE`
653
+ // exist to let the body / factory-reset win, and restoring curated values
654
+ // over that would re-lock exactly what the command was invoked to replace.
655
+ if (transaction.kind === 'rebuild') {
656
+ return { postFm: input.postFm, mutated: false };
657
+ }
280
658
  const ctx = {
281
- preFm: input.preFm,
282
659
  postFm: input.postFm,
283
- preFmSnapshot: input.preFmSnapshot,
284
- resync: input.resync,
285
- deriveProgressKeys: input.deriveProgressKeys === true,
286
- bodyDeltas: input.bodyDeltas,
660
+ snapshot: transaction.snapshot,
661
+ resync: transaction.resync,
662
+ deriveProgressKeys: transaction.deriveProgressKeys === true,
663
+ bodyDeltas: transaction.bodyDeltas,
287
664
  mutated: false,
665
+ explicitProgressField: transaction.explicitProgressField === true,
288
666
  };
289
667
  for (const field of Object.keys(exports.FIELD_CLASSIFICATION)) {
290
668
  const cls = getFieldClassification(field);
@@ -386,12 +764,14 @@ function beginPhaseCore(content, intent, deps) {
386
764
  // #1255: body-field replacements operate on body only (frontmatter stripped),
387
765
  // not on the full content. The YAML `status:` key matches `^Status:\s*`
388
766
  // before the body pipe-table row if full content is passed.
389
- const existingFm = extractFrontmatter(content, deps.sourcePath);
390
- const hasFrontmatter = Object.keys(existingFm).length > 0;
767
+ const { reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
768
+ // #3881 review, finding 5: `body` is deliberately a LITERAL `stripFrontmatter(content)`
769
+ // assignment here rather than the helper's own `body` (which the destructure above skips) —
770
+ // scripts/lint-state-write-path-drift.cjs's Axis 3 backward scan is a single-hop textual
771
+ // pattern match, not real dataflow, and only recognizes `body = stripFrontmatter(...)` written
772
+ // out at the call site. `stripFrontmatter` is pure and idempotent, so computing it here (in
773
+ // addition to the helper's own internal call) changes nothing observable.
391
774
  let body = stripFrontmatter(content);
392
- const reassemble = (b) => hasFrontmatter
393
- ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
394
- : b;
395
775
  const today = deps.clock.localToday();
396
776
  // Consult the field-classification table for the frontmatter keys this
397
777
  // transition touches (codex Phase 1 review: "table not consulted by
@@ -615,6 +995,46 @@ function mutateCurrentPositionResume(body, intent, today, updated) {
615
995
  }
616
996
  return body.slice(0, span.start) + sectionBody + body.slice(span.end);
617
997
  }
998
+ const PLAN_SHAPE_N = /^(\d+)(?:\s.*)?$/;
999
+ const PLAN_SHAPE_N_OF_M = /^(\d+)\s+of\s+(\d+)(?:\s.*)?$/;
1000
+ /**
1001
+ * Parse a decimal group into a plan number, or `null` if it is not a value we
1002
+ * are willing to do arithmetic on.
1003
+ *
1004
+ * `parseInt` is deliberately not used on the raw field: it truncates (`"2 of 5"`
1005
+ * -> 2), accepts a sign (`"+2"`), and silently loses precision past
1006
+ * `Number.MAX_SAFE_INTEGER`, where the number we report and the string we write
1007
+ * back stop agreeing. The grammars above already exclude signs and trailing
1008
+ * text, so the only remaining hazard is magnitude.
1009
+ */
1010
+ function planNumberFrom(digits) {
1011
+ const n = Number(digits);
1012
+ return Number.isSafeInteger(n) ? n : null;
1013
+ }
1014
+ /**
1015
+ * Advance the leading integer of a written plan value, preserving everything
1016
+ * the author wrote around it: the zero-padding width ("04" -> "05") and any
1017
+ * trailing remainder ("2 of 99" -> "3 of 99", and the `\r` of a CRLF file).
1018
+ *
1019
+ * The three parse branches disagree about the field NAME and about whether a
1020
+ * total is carried inline, but they agree completely about this: only the
1021
+ * leading digits are the plan number, and nothing else on the line belongs to
1022
+ * this transition. Writing `String(newPlan)` instead — as the legacy branch
1023
+ * did — discards the author's text on a branch nobody was reading.
1024
+ *
1025
+ * padStart never truncates, so 09 -> 10 widens rather than clipping.
1026
+ */
1027
+ function bumpLeadingNumber(raw, next) {
1028
+ const digits = /^\d+/.exec(raw);
1029
+ // Total rather than pass-through. `raw.replace(/^\d+/, …)` returns the input
1030
+ // unchanged when there are no leading digits, so `+2` advanced in `data` and
1031
+ // wrote the file untouched — the command reported progress it had not made
1032
+ // and could be re-run forever. The grammars make that unreachable today;
1033
+ // returning null keeps it unreachable if a fourth branch is ever added.
1034
+ if (!digits)
1035
+ return null;
1036
+ return raw.replace(/^\d+/, () => String(next).padStart(digits[0].length, '0'));
1037
+ }
618
1038
  /**
619
1039
  * Update fields within the ## Current Position section for advancePlan.
620
1040
  * Mirrors `updateCurrentPositionFields` (state.cts:496) byte-for-behaviour:
@@ -666,19 +1086,80 @@ function mutateCurrentPositionForAdvance(content, fields, statusDefaults, lastAc
666
1086
  mutated = true;
667
1087
  }
668
1088
  }
669
- if (fields.plan) {
1089
+ if (fields.plan || fields.currentPlan) {
670
1090
  // Plan is always replaced — system-derived, not executor-authored.
671
- if (/^Plan:/m.test(sectionBody)) {
672
- sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
673
- mutated = true;
674
- }
675
- else {
676
- const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Plan', fields.plan);
1091
+ //
1092
+ // Which NAME to write is decided by what the SECTION carries, not by which
1093
+ // header field the value was read from. Mirroring the header was wrong in
1094
+ // both directions: a legacy header with a `Current Plan:` section line left
1095
+ // the section a plan behind, and a hybrid header with a `Plan:` section line
1096
+ // mutated nothing at all. The invariant is per-name — every site spelled
1097
+ // `Current Plan` gets the `Current Plan` value, every `Plan` site gets the
1098
+ // `Plan` value — so both are passed in and each is written where its own
1099
+ // name appears.
1100
+ //
1101
+ // Title-Case LITERALS reach both the regex and stateReplaceField
1102
+ // (ADR-3408 §8.3(b)): a literal cannot collide with a lowercase/snake_case
1103
+ // frontmatter key, whatever the caller passed.
1104
+ //
1105
+ // The replacements go through a replacer FUNCTION, never a replacement
1106
+ // string. `fields.plan` is derived from file content, and `String.replace`
1107
+ // expands `$&`, `` $` `` and `$'` in a replacement string — a STATE.md
1108
+ // carrying `Current Plan: 04 of 06 $&` would splice part of itself into the
1109
+ // document. `stateReplaceField` already uses a function for this reason;
1110
+ // these arms now agree with it.
1111
+ // Each name is written INDEPENDENTLY, and each falls back on its own.
1112
+ //
1113
+ // Two defects lived in the previous shape, both of which produced the
1114
+ // split-brain document this arm exists to prevent:
1115
+ //
1116
+ // - The fallback was guarded by `!mutated`, and `mutated` is FUNCTION-wide
1117
+ // — already set by the `phase`/`status`/`lastActivity` arms above, which
1118
+ // `advancePlanCore` always populates. A section spelled `**Current
1119
+ // Plan:**` (bold) or as a pipe-table row therefore skipped its fallback
1120
+ // because an UNRELATED field had been refreshed, and the section stayed a
1121
+ // plan behind the header.
1122
+ // - The fallback then picked ONE name by ternary. In the legacy shape both
1123
+ // values are populated, so it always chose `Current Plan` and a
1124
+ // `**Plan:**` section line — which base did write — got nothing.
1125
+ //
1126
+ // `planWritten` is local, so nothing outside this arm can satisfy its guard.
1127
+ let planWritten = false;
1128
+ const writePlanField = (name, value) => {
1129
+ if (!value)
1130
+ return;
1131
+ // Plain `Name:` line first. Title-Case LITERALS reach both the regex and
1132
+ // stateReplaceField (ADR-3408 §8.3(b)), and the replacement goes through a
1133
+ // replacer FUNCTION so a `$&` / `` $` `` / `$'` in the author's text is not
1134
+ // expanded into the document.
1135
+ if (name === 'Current Plan') {
1136
+ if (/^Current Plan:/m.test(sectionBody)) {
1137
+ sectionBody = sectionBody.replace(/^Current Plan:.*$/m, () => `Current Plan: ${value}`);
1138
+ planWritten = true;
1139
+ return;
1140
+ }
1141
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Current Plan', value);
1142
+ if (replaced !== null) {
1143
+ sectionBody = replaced;
1144
+ planWritten = true;
1145
+ }
1146
+ return;
1147
+ }
1148
+ if (/^Plan:/m.test(sectionBody)) {
1149
+ sectionBody = sectionBody.replace(/^Plan:.*$/m, () => `Plan: ${value}`);
1150
+ planWritten = true;
1151
+ return;
1152
+ }
1153
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Plan', value);
677
1154
  if (replaced !== null) {
678
1155
  sectionBody = replaced;
679
- mutated = true;
1156
+ planWritten = true;
680
1157
  }
681
- }
1158
+ };
1159
+ writePlanField('Current Plan', fields.currentPlan);
1160
+ writePlanField('Plan', fields.plan);
1161
+ if (planWritten)
1162
+ mutated = true;
682
1163
  }
683
1164
  if (!mutated)
684
1165
  return content;
@@ -690,8 +1171,10 @@ function mutateCurrentPositionForAdvance(content, fields, statusDefaults, lastAc
690
1171
  /**
691
1172
  * Apply an `advancePlan` transition to STATE.md content.
692
1173
  *
693
- * Parses Current Plan / Total Plans (legacy separate fields or compound
694
- * "Plan: X of Y" format), increments the plan number, updates body fields
1174
+ * Parses Current Plan / Total Plans in any of three shapes — the legacy
1175
+ * separate fields, the compound "Plan: X of Y", or the hybrid
1176
+ * "Current Plan: X of Y" (legacy name, compound value, no Total Plans
1177
+ * sibling) — increments the plan number, updates body fields
695
1178
  * and the ## Current Position section. When currentPlan >= totalPlans,
696
1179
  * takes the phase-complete branch (sets Status to "Phase complete — ready
697
1180
  * for verification") instead of advancing.
@@ -708,36 +1191,134 @@ function advancePlanCore(content, deps) {
708
1191
  // not on the full content. The YAML `status:` key matches `^Status:\s*`
709
1192
  // before the body field if full content is passed (codex Phase 2 review:
710
1193
  // HIGH blocking finding — same pattern beginPhaseCore already handles).
711
- const existingFm = extractFrontmatter(content, deps.sourcePath);
712
- const hasFrontmatter = Object.keys(existingFm).length > 0;
713
- let body = stripFrontmatter(content);
714
- const reassemble = (b) => hasFrontmatter
715
- ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
716
- : b;
717
- // Parse plan number — legacy first, then compound.
1194
+ const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
1195
+ let body = initialBody;
1196
+ // #3807: refuse a Current Position section carrying more than one `Phase:`
1197
+ // entry BEFORE mutating. The plan fields below come from document-wide
1198
+ // first-match extraction, so in a wave-log style section (one entry per
1199
+ // completed wave) the FIRST entry's plan counter silently advanced — in the
1200
+ // reporting incident, a hard-gated final plan 7→8 of 8 — while the entry
1201
+ // the caller meant sat untouched below it, with advanced:true and no
1202
+ // ambiguity signal. advance-plan now refuses before acting. Scoped via the
1203
+ // #2956 canonical locator (stateCurrentPositionSlice — H2 or H3 heading,
1204
+ // the same one cmdStateAdvancePlan's own milestone read uses); NO whole-body
1205
+ // fallback — a legacy-format document with unrelated `Phase:` history lines
1206
+ // elsewhere has no section to disambiguate and must keep its current
1207
+ // behavior rather than be falsely refused.
1208
+ const positionScope = (0, state_document_cjs_1.stateCurrentPositionSlice)(body);
1209
+ if (positionScope !== null) {
1210
+ const phaseCandidates = (positionScope.match(/^Phase:.*$/gm) || []);
1211
+ if (phaseCandidates.length > 1) {
1212
+ return {
1213
+ content,
1214
+ updated: [],
1215
+ data: {
1216
+ error: true,
1217
+ reason: 'ambiguous_position_phase',
1218
+ phase_candidates: phaseCandidates.map((l) => l.trim()),
1219
+ },
1220
+ };
1221
+ }
1222
+ }
1223
+ // Parse plan number — legacy pair first, then the hybrid, then compound.
1224
+ //
1225
+ // These branches decide ONE thing: which numbers the advance is computed
1226
+ // from. They deliberately do not record which FIELD supplied them, because
1227
+ // the write path no longer asks — every spelling is written back from its own
1228
+ // raw text (#3791 review round 6, B1/M1). An earlier revision tracked a
1229
+ // `planSourceField`/`planRawValue` pair here and then wrote the OTHER
1230
+ // spelling from this one's numbers, which is precisely how a field ended up
1231
+ // holding a value nothing had derived for it.
718
1232
  const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan');
719
1233
  const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
720
1234
  const planField = (0, state_document_cjs_1.stateExtractField)(content, 'Plan');
721
- let currentPlan;
722
- let totalPlans;
723
- let useCompoundFormat = false;
724
- if (legacyPlan && legacyTotal) {
725
- currentPlan = parseInt(legacyPlan, 10);
726
- totalPlans = parseInt(legacyTotal, 10);
727
- }
728
- else if (planField) {
729
- currentPlan = parseInt(planField, 10);
730
- const ofMatch = planField.match(/of\s+(\d+)/);
731
- totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
732
- useCompoundFormat = true;
733
- }
734
- else {
735
- currentPlan = NaN;
736
- totalPlans = NaN;
737
- }
738
- if (isNaN(currentPlan) || isNaN(totalPlans)) {
1235
+ // Every branch below reads its numbers out of an ANCHORED match's capture
1236
+ // groups. Nothing here calls parseInt on a raw field value, so a value the
1237
+ // grammar does not fully describe cannot half-parse into a plausible number.
1238
+ const legacyNMatch = legacyPlan ? PLAN_SHAPE_N.exec(legacyPlan) : null;
1239
+ const legacyNofMMatch = legacyPlan ? PLAN_SHAPE_N_OF_M.exec(legacyPlan) : null;
1240
+ const totalNMatch = legacyTotal ? PLAN_SHAPE_N.exec(legacyTotal) : null;
1241
+ const planNofMMatch = planField ? PLAN_SHAPE_N_OF_M.exec(planField) : null;
1242
+ const planNMatch = planField ? PLAN_SHAPE_N.exec(planField) : null;
1243
+ let parsedCurrent = null;
1244
+ let parsedTotal = null;
1245
+ if (legacyPlan && legacyTotal && (legacyNMatch || legacyNofMMatch) && totalNMatch) {
1246
+ // Legacy pair wins whenever both fields are present and both are readable,
1247
+ // even if the Current Plan value also carries an "of M" — the explicit
1248
+ // sibling field is the stated intent, so it supplies the total.
1249
+ parsedCurrent = planNumberFrom((legacyNMatch ?? legacyNofMMatch)[1]);
1250
+ parsedTotal = planNumberFrom(totalNMatch[1]);
1251
+ }
1252
+ else if (legacyNofMMatch) {
1253
+ // Hybrid: legacy field name, compound value, no readable Total Plans
1254
+ // sibling. Written by hand (and by agents) often enough to be worth
1255
+ // reading — #3784.
1256
+ parsedCurrent = planNumberFrom(legacyNofMMatch[1]);
1257
+ parsedTotal = planNumberFrom(legacyNofMMatch[2]);
1258
+ }
1259
+ else if (planNofMMatch) {
1260
+ parsedCurrent = planNumberFrom(planNofMMatch[1]);
1261
+ parsedTotal = planNumberFrom(planNofMMatch[2]);
1262
+ }
1263
+ // No branch for a bare `Plan: N` paired with a `Total Plans in Phase: M`
1264
+ // sibling and no `Current Plan` at all (#3791 review round 6, M2). A revision
1265
+ // of this PR accepted it; base did not (its `else if (planField)` arm had no
1266
+ // `of M` match and errored via NaN), and it is out of #3784's scope, which is
1267
+ // the hybrid `Current Plan: N of M`. It cannot be given the schema-row +
1268
+ // forcing-test coupling the other shapes have, either: `Plan` is body-only,
1269
+ // `buildStateFrontmatter` never reads it into frontmatter, so there is no
1270
+ // `current_*` key to hang a row on. An accepted shape with no schema row and
1271
+ // no forcing test is exactly the drift this diff is otherwise built to
1272
+ // prevent, so the shape is refused and named in the error instead.
1273
+ if (parsedCurrent === null || parsedTotal === null) {
739
1274
  return { content: reassemble(body), updated: [], data: { error: true } };
740
1275
  }
1276
+ const currentPlan = parsedCurrent;
1277
+ const totalPlans = parsedTotal;
1278
+ // Each SPELLING's own plan number, read from its own value (#3791 review
1279
+ // round 6, B1/M1). The parse above picks ONE field to advance FROM; these are
1280
+ // what each field independently claims, and they are the only honest basis
1281
+ // for writing that field back.
1282
+ const legacyOwnCurrent = legacyNMatch || legacyNofMMatch
1283
+ ? planNumberFrom((legacyNMatch ?? legacyNofMMatch)[1])
1284
+ : null;
1285
+ const planOwnCurrent = planNofMMatch || planNMatch
1286
+ ? planNumberFrom((planNofMMatch ?? planNMatch)[1])
1287
+ : null;
1288
+ // A document carrying BOTH spellings with DIFFERENT plan numbers disagrees
1289
+ // with itself, and no rule here can say which half is right. Refuse.
1290
+ //
1291
+ // This is the #3807 posture one field over: name the conflict, let the caller
1292
+ // resolve it, never pick. The alternative shipped in an earlier revision of
1293
+ // this PR and was the round-6 Blocker — with `Plan` as the parse source, the
1294
+ // write path re-stamped `Current Plan`'s value with the number it had just
1295
+ // derived from `Plan`, so `Current Plan: 7` beside `Plan: 2 of 5` silently
1296
+ // became `Current Plan: 3`. A number with no relationship to the field it was
1297
+ // written into, no error, no diagnostic.
1298
+ //
1299
+ // Placed BEFORE the phase-complete branch deliberately. Guarding only the
1300
+ // normal advance leaves `Current Plan: 7` beside `Plan: 5 of 5` writing a
1301
+ // terminal "Phase complete — ready for verification" into a document whose
1302
+ // two spellings never agreed on where execution was.
1303
+ //
1304
+ // Differing TOTALS are NOT a disagreement about position and are preserved,
1305
+ // not resolved: `Current Plan: 2` / `Total Plans in Phase: 5` beside
1306
+ // `Plan: 2 of 9` advances to `3` and `3 of 9`. Reconciling the two totals
1307
+ // would be this transition inventing an answer to a question nobody asked it.
1308
+ if (legacyOwnCurrent !== null && planOwnCurrent !== null && legacyOwnCurrent !== planOwnCurrent) {
1309
+ return {
1310
+ content: reassemble(body),
1311
+ updated: [],
1312
+ data: {
1313
+ error: true,
1314
+ reason: 'ambiguous_plan_position',
1315
+ plan_candidates: [
1316
+ `Current Plan: ${legacyPlan}`,
1317
+ `Plan: ${planField}`,
1318
+ ],
1319
+ },
1320
+ };
1321
+ }
741
1322
  const updated = [];
742
1323
  const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status'];
743
1324
  const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
@@ -759,24 +1340,98 @@ function advancePlanCore(content, deps) {
759
1340
  }
760
1341
  // Normal advance branch.
761
1342
  const newPlan = currentPlan + 1;
762
- let planDisplayValue;
763
- if (useCompoundFormat) {
764
- planDisplayValue = planField.replace(/^\d+/, String(newPlan));
1343
+ // The value each SPELLING should carry after the advance. A document may hold
1344
+ // both names (a `Current Plan:` header and a `Plan:` line in the section, or
1345
+ // the reverse), and each has always rendered differently — the legacy field
1346
+ // holds a bare/padded number while the section's `Plan:` line holds the
1347
+ // compound `N of M`.
1348
+ //
1349
+ // Each is advanced from ITS OWN raw text, never from the other's numbers
1350
+ // (#3791 review round 6, B1/M1). `bumpLeadingNumber` replaces only the leading
1351
+ // digits, so the field's zero-padding width, its own ` of M` and any trailing
1352
+ // annotation all survive — which is what the changeset claims, and what the
1353
+ // previous revision did only for whichever field happened to be the parse
1354
+ // source. The other field it re-stamped from numbers that were never its own.
1355
+ const advanceOwn = (raw, own) => {
1356
+ if (raw === null)
1357
+ return undefined;
1358
+ // Present but unreadable (`Plan: TBD`). Leave it exactly as authored: this
1359
+ // transition cannot advance what it cannot read, and writing a derived
1360
+ // number over it is the fabrication B1 was filed for. Stale-and-untouched is
1361
+ // honest; refusing the whole document because an unrelated line is
1362
+ // unreadable would be a narrowing #3784 does not license.
1363
+ if (own === null)
1364
+ return undefined;
1365
+ return bumpLeadingNumber(raw, newPlan) ?? undefined;
1366
+ };
1367
+ // Title-Case LITERALS to stateReplaceField (ADR-3408 §8.3(b)): a literal
1368
+ // cannot collide with a lowercase/snake_case frontmatter key, so it is safe
1369
+ // regardless of how the content argument was derived. `body` here is in fact
1370
+ // `stripFrontmatter(content)`, but the write-path drift guard does not do
1371
+ // dataflow tracking (by design), and satisfying its invariant by construction
1372
+ // is better than asking a reader to re-derive that it holds.
1373
+ // One value per SPELLING, then write both names everywhere they appear.
1374
+ //
1375
+ // `Current Plan` — whatever the author wrote, advanced in place: padding
1376
+ // and any ` of M` preserved.
1377
+ // `Plan` — likewise, so its OWN total survives. `Plan: 2 of 9`
1378
+ // beside a `Total Plans in Phase: 5` advances to
1379
+ // `3 of 9`, not `3 of 5`: the two totals disagreeing is
1380
+ // the document's business, not this transition's to
1381
+ // reconcile.
1382
+ //
1383
+ // The two are deliberately different strings for the legacy shape, which is
1384
+ // why this is a per-name value rather than one shared display value. Writing
1385
+ // only the name the value was PARSED from is what left the other name stale:
1386
+ // a `**Plan:** 2 of 6` header beside a `Current Plan:` line advanced one and
1387
+ // not the other, in whichever direction the precedence happened to fall.
1388
+ //
1389
+ // Each write is a no-op when that name is absent (`stateReplaceField` returns
1390
+ // null), so a document carrying only one spelling is unaffected — and
1391
+ // `undefined` means "present but not advanceable", which is left untouched
1392
+ // rather than overwritten.
1393
+ const currentPlanDisplayValue = advanceOwn(legacyPlan, legacyOwnCurrent);
1394
+ const planDisplayValue = planField === null
1395
+ // No top-level `Plan:` field to advance, but the `## Current Position`
1396
+ // section may still carry a `Plan:` line in a shape `stateExtractField`
1397
+ // does not read. There is no raw text here to preserve, so it gets the
1398
+ // compound rendering that line has always carried.
1399
+ ? `${newPlan} of ${totalPlans}`
1400
+ : advanceOwn(planField, planOwnCurrent);
1401
+ if (currentPlanDisplayValue !== undefined) {
1402
+ body = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', currentPlanDisplayValue) || body;
1403
+ }
1404
+ // Only touch `Plan` when the document actually declares one. Writing it
1405
+ // unconditionally meant a `stateReplaceField` whose first match could be any
1406
+ // `Plan:` line anywhere in the body — including prose outside
1407
+ // `## Current Position` that was never a field. `planField` is the read of
1408
+ // that same field from the top of this function, so the write is scoped to a
1409
+ // document that has one.
1410
+ if (planField !== null && planDisplayValue !== undefined) {
765
1411
  body = (0, state_document_cjs_1.stateReplaceField)(body, 'Plan', planDisplayValue) || body;
766
1412
  }
767
- else {
768
- planDisplayValue = `${newPlan} of ${totalPlans}`;
769
- body = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', String(newPlan)) || body;
770
- }
771
1413
  body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute') || body;
772
1414
  body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today) || body;
773
1415
  body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last activity', lastActivityDefaults, today) || body;
774
1416
  body = mutateCurrentPositionForAdvance(body, {
775
1417
  status: 'Ready to execute',
776
1418
  lastActivity: today,
1419
+ // Both spellings, each with its own value. The section writes whichever
1420
+ // name it actually carries; passing only the header's name is what left
1421
+ // two sites disagreeing about where execution is.
777
1422
  plan: planDisplayValue,
1423
+ currentPlan: currentPlanDisplayValue,
778
1424
  }, statusDefaults, lastActivityDefaults);
779
- updated.push('Current Plan', 'Status', 'Last Activity', 'Current Position');
1425
+ // Report `Current Plan` only when it actually moved. The write above is
1426
+ // conditional now — a `Current Plan:` that is present but unreadable is left
1427
+ // as authored — so an unconditional push here would report progress this
1428
+ // transition had not made, which is the same sin `bumpLeadingNumber` returns
1429
+ // null to avoid. `reconcileReportedFields` at the `state.cts` caller would
1430
+ // catch it against the persisted bytes, but `transitionCore`'s own `updated`
1431
+ // is consumed directly too and has to be true on its own.
1432
+ if (currentPlanDisplayValue !== undefined)
1433
+ updated.push('Current Plan');
1434
+ updated.push('Status', 'Last Activity', 'Current Position');
780
1435
  return {
781
1436
  content: reassemble(body),
782
1437
  updated,
@@ -831,12 +1486,8 @@ function completePhaseCore(content, intent, deps) {
831
1486
  }
832
1487
  // #1255: body-field replacements operate on body only (frontmatter stripped),
833
1488
  // so the YAML `status:` / `current_phase:` keys cannot shadow the body fields.
834
- const existingFm = extractFrontmatter(content, deps.sourcePath);
835
- const hasFrontmatter = Object.keys(existingFm).length > 0;
836
- let body = stripFrontmatter(content);
837
- const reassemble = (b) => hasFrontmatter
838
- ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
839
- : b;
1489
+ const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
1490
+ let body = initialBody;
840
1491
  // Current Phase — preserve the existing `of <total>` shape and the phase name
841
1492
  // in parens (mirrors phase.cts:1675-1697 byte-for-behaviour).
842
1493
  const phaseValue = intent.nextPhaseNum || intent.phaseNum;
@@ -1010,12 +1661,8 @@ function plannedPhaseCore(content, intent, deps) {
1010
1661
  }
1011
1662
  }
1012
1663
  // #1255: body-field replacements operate on body only.
1013
- const existingFm = extractFrontmatter(content, deps.sourcePath);
1014
- const hasFrontmatter = Object.keys(existingFm).length > 0;
1015
- let body = stripFrontmatter(content);
1016
- const reassemble = (b) => hasFrontmatter
1017
- ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
1018
- : b;
1664
+ const { existingFm, hasFrontmatter, body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
1665
+ let body = initialBody;
1019
1666
  const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status'];
1020
1667
  const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
1021
1668
  // Status — template-aware (preserve executor-authored values).
@@ -1258,12 +1905,8 @@ function milestoneCompleteCore(content, intent, deps) {
1258
1905
  }
1259
1906
  }
1260
1907
  // #1255: body-field replacements operate on body only.
1261
- const existingFm = extractFrontmatter(content, deps.sourcePath);
1262
- const hasFrontmatter = Object.keys(existingFm).length > 0;
1263
- let body = stripFrontmatter(content);
1264
- const reassemble = (b) => hasFrontmatter
1265
- ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
1266
- : b;
1908
+ const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
1909
+ let body = initialBody;
1267
1910
  // Status — `<version> milestone complete`.
1268
1911
  const statusAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Status', null, `${version} milestone complete`);
1269
1912
  if (statusAfter !== body) {
@@ -1365,8 +2008,10 @@ function milestoneCompleteCore(content, intent, deps) {
1365
2008
  * `data.updated` / `data.failed` mirror the pre-migration CLI output shape.
1366
2009
  */
1367
2010
  function patchCore(content, intent) {
1368
- const existingFm = extractFrontmatter(content);
1369
- const hasFrontmatter = Object.keys(existingFm).length > 0;
2011
+ const { existingFm, hasFrontmatter, fmPrefix, unparseableFm } = beginFrontmatterReassembly(content);
2012
+ // #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a
2013
+ // literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's
2014
+ // Axis 3 single-hop backward scan.
1370
2015
  let body = stripFrontmatter(content);
1371
2016
  const fm = { ...existingFm };
1372
2017
  const updated = [];
@@ -1413,7 +2058,9 @@ function patchCore(content, intent) {
1413
2058
  }
1414
2059
  const result = hasFrontmatter
1415
2060
  ? `---\n${reconstructFrontmatter(fm)}\n---\n\n${body}`
1416
- : body;
2061
+ : unparseableFm
2062
+ ? `${fmPrefix}${body}`
2063
+ : body;
1417
2064
  return { content: result, updated, data: { updated, failed } };
1418
2065
  }
1419
2066
  // ----------------------------------------------------------------------------
@@ -1428,16 +2075,77 @@ function patchCore(content, intent) {
1428
2075
  * Mirrors the pre-migration body-strip/reassemble contract.
1429
2076
  */
1430
2077
  function updateCore(content, intent) {
1431
- const existingFm = extractFrontmatter(content);
1432
- const hasFrontmatter = Object.keys(existingFm).length > 0;
2078
+ const { existingFm, hasFrontmatter, reassemble } = beginFrontmatterReassembly(content);
2079
+ // #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a
2080
+ // literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's
2081
+ // Axis 3 single-hop backward scan.
1433
2082
  const body = stripFrontmatter(content);
1434
- const result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value);
2083
+ // #3699 review: session-scoped fields are written through the session-scoped
2084
+ // writer. A whole-body `stateReplaceField` matches the FIRST occurrence
2085
+ // anywhere, so with no `Stopped At:` line in `## Session` but a stale one in
2086
+ // `## Session Continuity Archive`, `state update "Stopped At" …` reported
2087
+ // `updated: true` while rewriting the ARCHIVE line and leaving both the session
2088
+ // section and the `stopped_at` frontmatter key untouched — a silent corruption
2089
+ // of a historical record reported as success. #3374 already established this
2090
+ // rule for the other writer; this one had not adopted it.
2091
+ const sessionWriteLabels = sessionLabelsForBodyField(intent.field);
2092
+ let result;
2093
+ if (sessionWriteLabels) {
2094
+ // Replace-only by contract: unchanged content means the field is not in the
2095
+ // session section, which is a miss, not a write.
2096
+ const replaced = (0, state_document_cjs_1.stateReplaceFieldInSession)(body, sessionWriteLabels.primary, sessionWriteLabels.fallback, intent.value);
2097
+ result = replaced === body ? null : replaced;
2098
+ }
2099
+ else {
2100
+ result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value);
2101
+ }
1435
2102
  if (result === null) {
2103
+ // #3699 case D — the frontmatter fallback.
2104
+ //
2105
+ // Normally frontmatter keys are NOT writable here: they are projections, and
2106
+ // `buildStateFrontmatter` re-derives them from the body on every write, so a
2107
+ // direct frontmatter write would be discarded. But when the body source line
2108
+ // is absent entirely, there is nothing to derive FROM: the key's existing
2109
+ // value survives on `preserve-when-unchanged`, and neither the frontmatter
2110
+ // key nor the body field can be updated by any route. That document is
2111
+ // unrepairable through `state update`, which is the gap this closes.
2112
+ //
2113
+ // Deliberately narrow — all three must hold:
2114
+ // (1) the field is a frontmatter key with a known body source,
2115
+ // (2) NO body source line exists, so the body route is genuinely unavailable
2116
+ // (this is what keeps case A, where the body route works, routing to the
2117
+ // body as before), and
2118
+ // (3) the frontmatter already carries the key, so this updates a value that
2119
+ // is really there rather than inventing one.
2120
+ //
2121
+ // The presence check in (2) is UNSCOPED on purpose, unlike the builder's
2122
+ // `## Session` scoping for stopped_at/paused_at. The asymmetry is the safe
2123
+ // direction: any `Stopped at:` line anywhere in the body — including one in an
2124
+ // archive section — suppresses the fallback, so this never writes frontmatter
2125
+ // while a body line the user could edit still exists.
2126
+ const bodySource = getFrontmatterBodySource(intent.field);
2127
+ const frontmatterCarriesKey = hasFrontmatter && Object.prototype.hasOwnProperty.call(existingFm, intent.field);
2128
+ // The presence check asks the same question the WRITE asks, in the same
2129
+ // scope. An earlier cut checked the whole body on the reasoning that any
2130
+ // editable line should suppress the repair — but a line the reader never
2131
+ // reads is not a source, and suppressing on it left the document
2132
+ // unrepairable while pointing the user at a command that would rewrite the
2133
+ // wrong line. Same scope for read, write and probe, or they disagree.
2134
+ const sessionProbeLabels = sessionLabelsForKey(intent.field);
2135
+ const bodySourceExists = sessionProbeLabels
2136
+ ? sessionSourceExists(body, sessionProbeLabels)
2137
+ : (bodySource ?? []).some((f) => (0, state_document_cjs_1.stateExtractField)(body, f) !== null);
2138
+ if (bodySource && frontmatterCarriesKey && !bodySourceExists) {
2139
+ const nextFm = { ...existingFm, [intent.field]: intent.value };
2140
+ return {
2141
+ content: `---\n${reconstructFrontmatter(nextFm)}\n---\n\n${body}`,
2142
+ updated: [intent.field],
2143
+ data: { updated: true, wroteFrontmatter: true },
2144
+ };
2145
+ }
1436
2146
  return { content, updated: [], data: { updated: false } };
1437
2147
  }
1438
- const reassembled = hasFrontmatter
1439
- ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}`
1440
- : result;
2148
+ const reassembled = reassemble(result);
1441
2149
  return { content: reassembled, updated: [intent.field], data: { updated: true } };
1442
2150
  }
1443
2151
  // Stop predicate for prune section slicing: a level-2 OR level-3 heading ends
@@ -1588,13 +2296,10 @@ function syncCore(content, intent, deps) {
1588
2296
  if (currentProgress) {
1589
2297
  const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
1590
2298
  if (currentPercent !== intent.percent) {
1591
- const barWidth = 10;
1592
- const filled = Math.round((intent.percent / 100) * barWidth);
1593
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1594
- const progressStr = `[${bar}] ${intent.percent}%`;
1595
- changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
1596
- const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Progress', progressStr);
2299
+ const result = stateReplaceProgressPercent(modified, intent.percent);
1597
2300
  if (result) {
2301
+ const progressStr = formatProgressMachineSegment(intent.percent);
2302
+ changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
1598
2303
  modified = result;
1599
2304
  updated.push('Progress');
1600
2305
  }