@opengsd/gsd-core 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-debug-session-manager.md +11 -0
  4. package/agents/gsd-doc-synthesizer.md +2 -4
  5. package/agents/gsd-executor.md +5 -5
  6. package/agents/gsd-mempalace-curator.md +5 -2
  7. package/agents/gsd-phase-researcher.md +20 -1
  8. package/agents/gsd-plan-checker.md +37 -0
  9. package/agents/gsd-planner.md +44 -46
  10. package/agents/gsd-user-profiler.md +3 -0
  11. package/agents/gsd-verifier.md +12 -3
  12. package/bin/install.js +841 -971
  13. package/bin/lib/ui-safety-gate.cjs +2 -0
  14. package/commands/gsd/code-review.md +1 -1
  15. package/commands/gsd/execute-phase.md +1 -1
  16. package/commands/gsd/map-codebase.md +1 -1
  17. package/commands/gsd/mempalace-capture.md +1 -1
  18. package/commands/gsd/mempalace-recall.md +1 -1
  19. package/commands/gsd/new-milestone.md +1 -1
  20. package/commands/gsd/quick.md +1 -1
  21. package/commands/gsd/review-backlog.md +2 -1
  22. package/commands/gsd/verify-work.md +1 -1
  23. package/gsd-core/bin/gsd-tools.cjs +469 -88
  24. package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
  25. package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
  26. package/gsd-core/bin/lib/api-coverage.cjs +3 -5
  27. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  28. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  29. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  30. package/gsd-core/bin/lib/audit.cjs +876 -240
  31. package/gsd-core/bin/lib/broken-windows.cjs +1 -1
  32. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  33. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +575 -101
  35. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  36. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  37. package/gsd-core/bin/lib/capability-validator.cjs +495 -22
  38. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  39. package/gsd-core/bin/lib/check-command-router.cjs +71 -37
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  41. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  43. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  44. package/gsd-core/bin/lib/commands.cjs +651 -86
  45. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  47. package/gsd-core/bin/lib/config-loader.cjs +75 -0
  48. package/gsd-core/bin/lib/config.cjs +10 -1
  49. package/gsd-core/bin/lib/core-utils.cjs +127 -29
  50. package/gsd-core/bin/lib/decisions.cjs +23 -0
  51. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  52. package/gsd-core/bin/lib/frontmatter.cjs +155 -20
  53. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  54. package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
  55. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  56. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  57. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  58. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  59. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  60. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  61. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  62. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  63. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  64. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  65. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  66. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  67. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  68. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  69. package/gsd-core/bin/lib/init.cjs +321 -129
  70. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  71. package/gsd-core/bin/lib/install-engine.cjs +745 -258
  72. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  73. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  74. package/gsd-core/bin/lib/install-profiles.cjs +134 -57
  75. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  76. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  77. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  78. package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
  79. package/gsd-core/bin/lib/io.cjs +10 -0
  80. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  81. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  82. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  83. package/gsd-core/bin/lib/milestone.cjs +754 -70
  84. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  85. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  86. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  87. package/gsd-core/bin/lib/pattern.cjs +122 -0
  88. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +444 -36
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  91. package/gsd-core/bin/lib/phase-locator.cjs +125 -18
  92. package/gsd-core/bin/lib/phase.cjs +646 -143
  93. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  94. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  95. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  96. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  97. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  98. package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
  99. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  100. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  101. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  102. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  103. package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
  104. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  105. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  106. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  107. package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
  108. package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
  109. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  110. package/gsd-core/bin/lib/roadmap.cjs +385 -94
  111. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
  112. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  113. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
  114. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  115. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  116. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
  117. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  118. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  119. package/gsd-core/bin/lib/security.cjs +104 -5
  120. package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
  121. package/gsd-core/bin/lib/smart-entry.cjs +142 -22
  122. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  123. package/gsd-core/bin/lib/state-document.cjs +152 -8
  124. package/gsd-core/bin/lib/state-transition.cjs +371 -117
  125. package/gsd-core/bin/lib/state.cjs +1794 -357
  126. package/gsd-core/bin/lib/surface.cjs +23 -9
  127. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  128. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  129. package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
  130. package/gsd-core/bin/lib/uat.cjs +399 -56
  131. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  132. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  133. package/gsd-core/bin/lib/unusable-input.cjs +24 -0
  134. package/gsd-core/bin/lib/update-context.cjs +8 -2
  135. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  136. package/gsd-core/bin/lib/validate.cjs +20 -6
  137. package/gsd-core/bin/lib/vendor/README.md +37 -0
  138. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  139. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  140. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  141. package/gsd-core/bin/lib/verification.cjs +258 -8
  142. package/gsd-core/bin/lib/verify.cjs +368 -888
  143. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  144. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  145. package/gsd-core/bin/lib/workstream.cjs +2 -2
  146. package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
  147. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  148. package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
  149. package/gsd-core/references/agent-contracts.md +43 -26
  150. package/gsd-core/references/checkpoints.md +2 -2
  151. package/gsd-core/references/context-budget.md +1 -1
  152. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  153. package/gsd-core/references/doc-conflict-engine.md +1 -1
  154. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  155. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  156. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  157. package/gsd-core/references/execute-phase-response-language.md +1 -1
  158. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  159. package/gsd-core/references/gate-prompts.md +1 -1
  160. package/gsd-core/references/git-planning-commit.md +2 -1
  161. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  162. package/gsd-core/references/model-profiles.md +12 -4
  163. package/gsd-core/references/mvp-concepts.md +9 -9
  164. package/gsd-core/references/planner-guidance.md +3 -9
  165. package/gsd-core/references/planner-preconditions.md +1 -1
  166. package/gsd-core/references/planner-reviews.md +1 -1
  167. package/gsd-core/references/planning-config.md +8 -6
  168. package/gsd-core/references/revision-loop.md +1 -1
  169. package/gsd-core/references/specless-probe-fallback.md +1 -1
  170. package/gsd-core/references/universal-anti-patterns.md +3 -3
  171. package/gsd-core/references/verifier-phase-gates.md +192 -0
  172. package/gsd-core/references/verify-mvp-mode.md +1 -1
  173. package/gsd-core/references/workstream-flag.md +22 -6
  174. package/gsd-core/templates/discussion-log.md +1 -1
  175. package/gsd-core/templates/phase-prompt.md +2 -4
  176. package/gsd-core/templates/state.md +4 -4
  177. package/gsd-core/templates/verification-report.md +9 -1
  178. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  179. package/gsd-core/workflows/autonomous.md +1 -1
  180. package/gsd-core/workflows/cleanup.md +62 -3
  181. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
  182. package/gsd-core/workflows/code-review-fix.md +37 -10
  183. package/gsd-core/workflows/code-review.md +38 -12
  184. package/gsd-core/workflows/complete-milestone.md +141 -18
  185. package/gsd-core/workflows/debug.md +7 -5
  186. package/gsd-core/workflows/diagnose-issues.md +35 -9
  187. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  188. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  189. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
  190. package/gsd-core/workflows/edit-phase.md +26 -1
  191. package/gsd-core/workflows/eval-review.md +3 -5
  192. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
  193. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  194. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
  195. package/gsd-core/workflows/execute-phase.md +38 -50
  196. package/gsd-core/workflows/execute-plan.md +36 -4
  197. package/gsd-core/workflows/explore.md +131 -4
  198. package/gsd-core/workflows/fast.md +10 -2
  199. package/gsd-core/workflows/health.md +73 -4
  200. package/gsd-core/workflows/import.md +4 -4
  201. package/gsd-core/workflows/ingest-docs.md +5 -5
  202. package/gsd-core/workflows/mvp-phase.md +6 -3
  203. package/gsd-core/workflows/new-milestone.md +14 -9
  204. package/gsd-core/workflows/new-project.md +14 -14
  205. package/gsd-core/workflows/next.md +12 -0
  206. package/gsd-core/workflows/plan-phase.md +41 -17
  207. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  208. package/gsd-core/workflows/progress.md +34 -6
  209. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
  210. package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
  211. package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
  212. package/gsd-core/workflows/quick.md +35 -15
  213. package/gsd-core/workflows/review.md +26 -5
  214. package/gsd-core/workflows/secure-phase.md +1 -1
  215. package/gsd-core/workflows/session-report.md +2 -1
  216. package/gsd-core/workflows/settings.md +66 -2
  217. package/gsd-core/workflows/ship.md +104 -44
  218. package/gsd-core/workflows/spec-phase.md +30 -12
  219. package/gsd-core/workflows/sync-skills.md +63 -8
  220. package/gsd-core/workflows/transition.md +46 -11
  221. package/gsd-core/workflows/ui-phase.md +5 -5
  222. package/gsd-core/workflows/ui-review.md +2 -2
  223. package/gsd-core/workflows/update.md +1 -1
  224. package/gsd-core/workflows/validate-phase.md +1 -1
  225. package/gsd-core/workflows/verify-work.md +9 -7
  226. package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
  227. package/hooks/dist/gsd-check-update-worker.js +56 -13
  228. package/hooks/dist/gsd-check-update.js +19 -1
  229. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  230. package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
  231. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  232. package/hooks/dist/gsd-prompt-guard.js +21 -20
  233. package/hooks/dist/gsd-read-injection-scanner.js +38 -24
  234. package/hooks/dist/gsd-statusline.js +18 -0
  235. package/hooks/dist/gsd-update-banner.js +22 -1
  236. package/hooks/dist/gsd-workflow-guard.js +134 -36
  237. package/hooks/dist/lib/git-cmd.js +92 -59
  238. package/hooks/dist/lib/injection-patterns.js +45 -0
  239. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  240. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  241. package/hooks/gsd-agent-isolation-guard.js +103 -14
  242. package/hooks/gsd-check-update-worker.js +56 -13
  243. package/hooks/gsd-check-update.js +19 -1
  244. package/hooks/gsd-cursor-pre-tool.js +0 -3
  245. package/hooks/gsd-cursor-subagent-start.js +77 -2
  246. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  247. package/hooks/gsd-prompt-guard.js +21 -20
  248. package/hooks/gsd-read-injection-scanner.js +38 -24
  249. package/hooks/gsd-statusline.js +18 -0
  250. package/hooks/gsd-update-banner.js +22 -1
  251. package/hooks/gsd-workflow-guard.js +134 -36
  252. package/hooks/lib/git-cmd.js +92 -59
  253. package/hooks/lib/injection-patterns.js +45 -0
  254. package/hooks/lib/isolation-deny-reason.js +39 -0
  255. package/hooks/lib/isolation-sentinel.js +9 -0
  256. package/package.json +21 -9
  257. package/pi/gsd.cjs +19 -5
  258. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  259. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  260. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  261. package/scripts/changeset/lint.cjs +60 -5
  262. package/scripts/check-alias-drift.cjs +7 -43
  263. package/scripts/check-contract-drift.cjs +297 -0
  264. package/scripts/ci-test-scope.cjs +19 -2
  265. package/scripts/command-contract-helpers.cjs +903 -1
  266. package/scripts/gen-adr-index.cjs +728 -38
  267. package/scripts/gen-capability-registry.cjs +3 -15
  268. package/scripts/gen-context-index.cjs +2 -11
  269. package/scripts/gen-health-docs.cjs +390 -0
  270. package/scripts/gen-inventory-manifest.cjs +50 -4
  271. package/scripts/gen-loop-host-contract.cjs +4 -24
  272. package/scripts/gen-registry.cjs +3 -14
  273. package/scripts/lib/alias-drift-families.cjs +46 -0
  274. package/scripts/lib/drift-scan.cjs +278 -0
  275. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  276. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  277. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  278. package/scripts/lint-canary-version-leak.cjs +73 -0
  279. package/scripts/lint-command-contract.cjs +96 -13
  280. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  281. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  282. package/scripts/lint-default-flip-documentation.cjs +193 -0
  283. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  284. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  285. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  286. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  287. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  288. package/scripts/lint-milestone-window-drift.cjs +468 -0
  289. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  290. package/scripts/lint-plan-count-drift.cjs +318 -0
  291. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  292. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  293. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  294. package/scripts/lint-regression-test-names.cjs +15 -13
  295. package/scripts/lint-removed-but-needed.cjs +320 -0
  296. package/scripts/lint-state-field-drift.cjs +805 -0
  297. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  298. package/scripts/lint-test-file-count.allowlist.json +21 -10
  299. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  300. package/scripts/lint-vendored-deps.cjs +124 -0
  301. package/scripts/pr-changed-files.cjs +63 -0
  302. package/scripts/pr-template-policy.cjs +14 -4
  303. package/scripts/prompt-injection-scan.sh +25 -0
  304. package/scripts/require-issue-link-policy.cjs +192 -0
  305. package/scripts/state-write-path-drift-baseline.json +19 -0
  306. package/scripts/sync-runtime-launcher.cjs +2 -4
  307. package/skills/gsd-autonomous/SKILL.md +0 -1
  308. package/skills/gsd-code-review/SKILL.md +1 -1
  309. package/skills/gsd-execute-phase/SKILL.md +1 -2
  310. package/skills/gsd-map-codebase/SKILL.md +1 -1
  311. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  312. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  313. package/skills/gsd-new-milestone/SKILL.md +1 -1
  314. package/skills/gsd-next/SKILL.md +0 -1
  315. package/skills/gsd-plan-phase/SKILL.md +0 -1
  316. package/skills/gsd-progress/SKILL.md +0 -1
  317. package/skills/gsd-quick/SKILL.md +1 -1
  318. package/skills/gsd-review-backlog/SKILL.md +2 -1
  319. package/skills/gsd-stats/SKILL.md +0 -1
  320. package/skills/gsd-verify-work/SKILL.md +1 -1
  321. package/vscode/package.json +1 -1
  322. package/gsd-core/workflows/discovery-phase.md +0 -298
  323. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  324. package/gsd-core/workflows/verify-phase.md +0 -574
  325. package/scripts/affected-tests-lib.cjs +0 -554
  326. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  327. package/scripts/run-affected-tests.cjs +0 -7
  328. package/scripts/run-tests.cjs +0 -1051
@@ -17,6 +17,7 @@
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.STATE_MD_SECTIONS = exports.FIELD_CLASSIFICATION = void 0;
19
19
  exports.getFieldClassification = getFieldClassification;
20
+ exports.applyPreserveWhenUnchanged = applyPreserveWhenUnchanged;
20
21
  exports.applyStatePreservation = applyStatePreservation;
21
22
  exports.transitionCore = transitionCore;
22
23
  exports.sliceCurrentPositionSection = sliceCurrentPositionSection;
@@ -26,10 +27,8 @@ const state_document_cjs_1 = require("./state-document.cjs");
26
27
  const state_document_cjs_2 = require("./state-document.cjs");
27
28
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
28
29
  const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
29
- // eslint-disable-next-line @typescript-eslint/no-require-imports
30
- const phaseIdMod = require("./phase-id.cjs");
30
+ const pattern_cjs_1 = require("./pattern.cjs");
31
31
  const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter;
32
- const { escapeRegex } = phaseIdMod;
33
32
  // Stop predicate for section-body slicing: a level-2+ heading ends the section.
34
33
  const STOP_H2_PLUS = (lv) => lv >= 2;
35
34
  /**
@@ -54,18 +53,31 @@ exports.FIELD_CLASSIFICATION = Object.freeze(Object.assign(Object.create(null),
54
53
  milestone_name: { source: 'external', preservation: 'preserve-if-placeholder' },
55
54
  // Phase / plan position (body-derived)
56
55
  current_phase: { source: 'body', preservation: 'preserve-when-unchanged' },
57
- current_phase_name: { source: 'curated', preservation: 'preserve-always' }, // #1743, #1695
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' },
58
60
  current_plan: { source: 'body', preservation: 'preserve-when-unchanged' },
59
61
  // Status / lifecycle (body-derived; #1230 delta heuristic applies)
60
- status: { source: 'body', preservation: 'preserve-when-unchanged' },
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' },
61
66
  stopped_at: { source: 'body', preservation: 'preserve-when-unchanged' },
62
67
  paused_at: { source: 'body', preservation: 'preserve-when-unchanged' },
63
68
  // Activity log
64
69
  last_updated: { source: 'free', preservation: 'derive' }, // realClock.nowIso()
65
70
  last_activity: { source: 'body', preservation: 'derive' }, // always refresh on transition
66
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
67
76
  // Progress block (disk-derived, except the curated progress ratchet)
68
- progress: { source: 'curated', preservation: 'preserve-always' }, // #3242, #1446
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
69
81
  'progress.total_phases': { source: 'disk', preservation: 'derive' },
70
82
  'progress.completed_phases': { source: 'disk', preservation: 'derive' },
71
83
  'progress.total_plans': { source: 'disk', preservation: 'derive' },
@@ -82,103 +94,216 @@ function getFieldClassification(field) {
82
94
  return exports.FIELD_CLASSIFICATION[field];
83
95
  }
84
96
  /**
85
- * Pure, table-driven post-sync preservation. Mutates `postFm` in place to
86
- * mirror the pre-consolidation inline block (which also mutated in place) and
87
- * returns whether any field was restored.
97
+ * ADR-3408 §8.2: an unenforced `preserve-when-unchanged` row throws. Both
98
+ * ends of this invariant are gsd-core's own source — a declared row the
99
+ * *caller code* forgot to wire via `bodyDeltas` — so it is a programming
100
+ * error, unreachable from any user document. The bright line, stated because
101
+ * conflating its two sides would be severe: a drifted, malformed, or
102
+ * unparseable user STATE.md NEVER reaches this throw (§8.5 governs that case
103
+ * with preserve-and-warn); this fires only when the *caller* omitted a
104
+ * `bodyDeltas` entry for a row the table itself declares. Getting this
105
+ * backwards turns every desynced project's `phase.complete` into a hard
106
+ * failure.
88
107
  */
89
- function applyStatePreservation(input) {
90
- const { preFm, postFm, preFmSnapshot, resync } = input;
91
- let mutated = false;
92
- // Curated progress ratchet (#3242/#1446; closes the #1264 class by routing
93
- // the policy through the table). Restored only when the table says preserve-
94
- // always AND this transition is not re-deriving from disk (!resync). sync and
95
- // the lifecycle transitions pass resync=true and recompute; patch/update and
96
- // body-only writes pass resync=false and keep the curated counters.
97
- const progressCls = getFieldClassification('progress');
98
- if (progressCls !== null &&
99
- progressCls.preservation === 'preserve-always' &&
100
- !resync &&
101
- preFm &&
102
- preFm['progress']) {
103
- // #2440: when the caller opts in (deriveProgressKeys), total_plans and
104
- // total_phases always take the derived (post-sync) value even under !resync.
105
- // This is used by cmdStatePlannedPhase where total_plans must correct upward
106
- // after plans are added. For body-only writes (state.update/patch without
107
- // the flag), the wholesale restore preserves everything as before — the
108
- // #3242 Bug A protection stays fully in force.
109
- if (input.deriveProgressKeys && postFm['progress']) {
110
- const curated = preFm['progress'];
111
- const derived = (postFm['progress'] ?? {});
112
- const merged = { ...derived };
113
- if (curated) {
114
- // #2440: total_plans and total_phases always take the derived value.
115
- // #2969: completed_plans and completed_phases take the derived value
116
- // when it is GREATER than the curated value (gap-closure plans that
117
- // completed after the plan count grew) — ratcheting UP only, never
118
- // deriving downward (preserves the #3242 curated-progress protection
119
- // for cases unrelated to plan-count growth, e.g. a deleted SUMMARY).
120
- // percent also takes the derived value — the resync recomputed it from
121
- // disk counts, and a stale curated percent would be incoherent against
122
- // the ratcheted-up completed counts (e.g. 54/54 at 93%).
123
- const ratchetUpKeys = new Set(['completed_plans', 'completed_phases']);
124
- for (const [key, value] of Object.entries(curated)) {
125
- if (key === 'total_plans' || key === 'total_phases' || key === 'percent')
108
+ function throwUnwiredRow(field) {
109
+ const err = new Error(`applyStatePreservation: preserve-when-unchanged row ${JSON.stringify(field)} reached the ` +
110
+ 'executor with no wired ctx.bodyDeltas entry. This is an internal invariant violation (ADR-3408 ' +
111
+ '§8.2) — the caller (readModifyWriteStateMd) forgot to supply this field\'s body-source delta. ' +
112
+ 'Add a bodyDeltas entry for this field per ADR-3408 §8.3, or remove the row from ' +
113
+ 'FIELD_CLASSIFICATION if the field no longer needs this policy.');
114
+ err.code = 'STATE_PRESERVATION_UNWIRED_ROW';
115
+ err.field = field;
116
+ throw err;
117
+ }
118
+ /**
119
+ * Executor for `preservation: 'preserve-when-unchanged'` (ADR-3408 §8.1). The
120
+ * #1230 delta heuristic: restore the pre-write frontmatter snapshot when this
121
+ * write did not change the field's body source, and the snapshot is a real
122
+ * (non-empty-after-trim) curated value the derived value should not clobber.
123
+ *
124
+ * Every row carrying this policy — status, stopped_at, current_phase_name,
125
+ * current_phase, current_plan, paused_at, last_activity_desc — is honored by
126
+ * this ONE executor; `cls.guard` is the only field-specific variation (the
127
+ * closed vocabulary of ADR-3408 Decision 1).
128
+ *
129
+ * Exported (ADR-3408 §8.5 / D3) so `cmdStateJson` (state.cts) — a read-only
130
+ * path with no transform of its own — can route its stale-vs-fresh decision
131
+ * through the SAME executor the write path uses, rather than maintaining a
132
+ * third private copy of this policy. `cmdStateJson` calls this directly
133
+ * (not the full `applyStatePreservation` dispatch loop) so its read stays
134
+ * scoped to exactly the fields it has always governed and never touches
135
+ * `progress` or `milestone*`, which are different policies with their own
136
+ * read-path rules (`shouldPreserveExistingProgress`, `preserve-if-placeholder`).
137
+ */
138
+ function applyPreserveWhenUnchanged(field, cls, ctx) {
139
+ // 1. A declared row with no wired delta is an internal invariant violation
140
+ // — throw (ADR-3408 §8.2). Never reached for a user-document defect: the
141
+ // production caller (readModifyWriteStateMd) wires every preserve-when-
142
+ // unchanged row unconditionally.
143
+ const delta = ctx.bodyDeltas ? ctx.bodyDeltas[field] : undefined;
144
+ if (!delta)
145
+ throwUnwiredRow(field);
146
+ // 2. Only a real, non-whitespace-only curated string is worth restoring
147
+ // (#3468: tightened from `.length > 0` to a trimmed check — a whitespace-
148
+ // only snapshot is not a real curated value).
149
+ const snapshot = ctx.preFmSnapshot[field];
150
+ if (typeof snapshot !== 'string' || snapshot.trim().length === 0)
151
+ return;
152
+ // 3. Closed-vocabulary guard: status's 'unknown' sentinel is never restored.
153
+ // Exact-match, case-sensitive — 'Unknown' is a real value and IS restored.
154
+ if (cls.guard === 'non-sentinel-unknown' && snapshot === 'unknown')
155
+ return;
156
+ // 4. The body source changed this write → the freshly-derived value wins.
157
+ if (delta.pre !== delta.post)
158
+ return;
159
+ // 5. Already correct → no-op (avoid a spurious `mutated=true`).
160
+ if (ctx.postFm[field] === snapshot)
161
+ return;
162
+ // 6. Restore.
163
+ ctx.postFm[field] = snapshot;
164
+ ctx.mutated = true;
165
+ }
166
+ /**
167
+ * Executor for `preservation: 'preserve-always'` (ADR-3408 §8.1). Only
168
+ * `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.
173
+ */
174
+ function applyPreserveAlways(field, cls, ctx) {
175
+ if (ctx.resync || !ctx.preFm || !ctx.preFm[field])
176
+ return;
177
+ if (cls.mergeStrategy === 'progress-ratchet' && ctx.deriveProgressKeys && ctx.postFm[field]) {
178
+ // #2440: total_plans and total_phases always take the derived (post-sync)
179
+ // value even under !resync. This is used by cmdStatePlannedPhase where
180
+ // total_plans must correct upward after plans are added. For body-only
181
+ // writes (state.update/patch without the flag), the wholesale restore
182
+ // below preserves everything as before — the #3242 Bug A protection
183
+ // stays fully in force.
184
+ const curated = ctx.preFm[field];
185
+ const derived = (ctx.postFm[field] ?? {});
186
+ const merged = { ...derived };
187
+ if (curated) {
188
+ // #2440: total_plans and total_phases always take the derived value.
189
+ // #2969: completed_plans and completed_phases take the derived value
190
+ // when it is GREATER than the curated value (gap-closure plans that
191
+ // completed after the plan count grew) — ratcheting UP only, never
192
+ // deriving downward (preserves the #3242 curated-progress protection
193
+ // for cases unrelated to plan-count growth, e.g. a deleted SUMMARY).
194
+ // percent also takes the derived value — the resync recomputed it from
195
+ // disk counts, and a stale curated percent would be incoherent against
196
+ // the ratcheted-up completed counts (e.g. 54/54 at 93%).
197
+ const ratchetUpKeys = new Set(['completed_plans', 'completed_phases']);
198
+ for (const [key, value] of Object.entries(curated)) {
199
+ if (key === 'total_plans' || key === 'total_phases' || key === 'percent')
200
+ continue;
201
+ if (ratchetUpKeys.has(key)) {
202
+ const derivedNum = typeof derived[key] === 'number' ? derived[key] : -Infinity;
203
+ const curatedNum = typeof value === 'number' ? value : -Infinity;
204
+ // Take the derived value only when it ratchets up (strictly
205
+ // greater — #2969's `>` not `>=`); else keep curated.
206
+ if (derivedNum > curatedNum)
126
207
  continue;
127
- if (ratchetUpKeys.has(key)) {
128
- const derivedNum = typeof derived[key] === 'number' ? derived[key] : -Infinity;
129
- const curatedNum = typeof value === 'number' ? value : -Infinity;
130
- // Take the derived value only when it ratchets up; else keep curated.
131
- if (derivedNum > curatedNum)
132
- continue;
133
- merged[key] = value;
134
- }
135
- else {
136
- merged[key] = value;
137
- }
208
+ merged[key] = value;
209
+ }
210
+ else {
211
+ merged[key] = value;
138
212
  }
139
213
  }
140
- postFm['progress'] = merged;
141
214
  }
142
- else {
143
- postFm['progress'] = preFm['progress'];
215
+ ctx.postFm[field] = merged;
216
+ }
217
+ else {
218
+ ctx.postFm[field] = ctx.preFm[field];
219
+ }
220
+ ctx.mutated = true;
221
+ }
222
+ /**
223
+ * Executor for `preservation: 'preserve-if-placeholder'` (ADR-3408 §8.1).
224
+ * `milestone` and `milestone_name` both carry this policy in the table, and
225
+ * both rows dispatch into this SAME executor body — no branch is selected by
226
+ * field name (ADR-3408 §8.1). The body always restores the name+version pair
227
+ * together (#948/#2135), ignoring which of the two rows triggered the call;
228
+ * this is deliberately safe because the executor is idempotent: whichever
229
+ * row fires first either performs the restore (after which the second row's
230
+ * call recomputes against already-restored state and finds nothing left to
231
+ * do) or finds no placeholder to restore (in which case the second row's
232
+ * call, seeing the same unchanged inputs, reaches the same conclusion). Two
233
+ * dispatches per write converge to the identical single-pass result, so the
234
+ * field argument itself is unused here — it exists only to satisfy the
235
+ * shared executor signature every policy branch in the dispatch loop shares.
236
+ */
237
+ function applyPreserveIfPlaceholder(_field, _cls, ctx) {
238
+ const MILESTONE_PLACEHOLDER = 'milestone';
239
+ const derivedName = ctx.postFm['milestone_name'];
240
+ const derivedLooksLikeName = typeof derivedName === 'string'
241
+ && derivedName.length > 0
242
+ && derivedName !== MILESTONE_PLACEHOLDER
243
+ && !/^[\s—–:-]/.test(derivedName);
244
+ const snapshotName = ctx.preFmSnapshot['milestone_name'];
245
+ const snapshotNameIsReal = typeof snapshotName === 'string'
246
+ && snapshotName.length > 0
247
+ && snapshotName !== MILESTONE_PLACEHOLDER;
248
+ if (derivedLooksLikeName || !snapshotNameIsReal)
249
+ return;
250
+ if (ctx.postFm['milestone_name'] !== snapshotName) {
251
+ ctx.postFm['milestone_name'] = snapshotName;
252
+ ctx.mutated = true;
253
+ }
254
+ const snapshotVersion = ctx.preFmSnapshot['milestone'];
255
+ if (typeof snapshotVersion === 'string' && snapshotVersion.length > 0 &&
256
+ ctx.postFm['milestone'] !== snapshotVersion) {
257
+ ctx.postFm['milestone'] = snapshotVersion;
258
+ ctx.mutated = true;
259
+ }
260
+ }
261
+ /**
262
+ * Executor for `preservation: 'derive'`. Explicit no-op — the sync's
263
+ * freshly-derived value stands untouched. Naming this executor (rather than
264
+ * skipping `derive` rows by omission) is what makes ADR-3408 §8.2's throw
265
+ * decidable: "policy says do nothing" is now distinguishable from "nobody
266
+ * wired this", because every member of `FieldPreservation` reaches an
267
+ * executor.
268
+ */
269
+ function applyDerive(_field, _cls, _ctx) {
270
+ // No-op by design — see docstring.
271
+ }
272
+ /**
273
+ * Pure, table-driven post-sync preservation (ADR-3408 §8.1). One loop over
274
+ * `FIELD_CLASSIFICATION`, dispatching on the row's `preservation` value —
275
+ * never on the field name. Mutates `postFm` in place to mirror the
276
+ * pre-#3468 inline block (which also mutated in place) and returns whether
277
+ * any field was restored.
278
+ */
279
+ function applyStatePreservation(input) {
280
+ const ctx = {
281
+ preFm: input.preFm,
282
+ postFm: input.postFm,
283
+ preFmSnapshot: input.preFmSnapshot,
284
+ resync: input.resync,
285
+ deriveProgressKeys: input.deriveProgressKeys === true,
286
+ bodyDeltas: input.bodyDeltas,
287
+ mutated: false,
288
+ };
289
+ for (const field of Object.keys(exports.FIELD_CLASSIFICATION)) {
290
+ const cls = getFieldClassification(field);
291
+ if (!cls)
292
+ continue;
293
+ if (cls.preservation === 'preserve-when-unchanged') {
294
+ applyPreserveWhenUnchanged(field, cls, ctx);
295
+ }
296
+ else if (cls.preservation === 'preserve-always') {
297
+ applyPreserveAlways(field, cls, ctx);
298
+ }
299
+ else if (cls.preservation === 'preserve-if-placeholder') {
300
+ applyPreserveIfPlaceholder(field, cls, ctx);
301
+ }
302
+ else if (cls.preservation === 'derive') {
303
+ applyDerive(field, cls, ctx);
144
304
  }
145
- mutated = true;
146
- }
147
- // status — #1230 body-delta heuristic. Table: preserve-when-unchanged.
148
- const statusCls = getFieldClassification('status');
149
- if (statusCls !== null &&
150
- statusCls.preservation === 'preserve-when-unchanged' &&
151
- input.postBodyStatus === input.preBodyStatus &&
152
- typeof preFmSnapshot['status'] === 'string' &&
153
- preFmSnapshot['status'].length > 0 &&
154
- preFmSnapshot['status'] !== 'unknown' &&
155
- postFm['status'] !== preFmSnapshot['status']) {
156
- postFm['status'] = preFmSnapshot['status'];
157
- mutated = true;
158
- }
159
- // stopped_at — same #1230 body-delta heuristic. Table: preserve-when-unchanged.
160
- const stoppedCls = getFieldClassification('stopped_at');
161
- if (stoppedCls !== null &&
162
- stoppedCls.preservation === 'preserve-when-unchanged' &&
163
- input.postBodyStoppedAt === input.preBodyStoppedAt &&
164
- typeof preFmSnapshot['stopped_at'] === 'string' &&
165
- preFmSnapshot['stopped_at'].length > 0 &&
166
- postFm['stopped_at'] !== preFmSnapshot['stopped_at']) {
167
- postFm['stopped_at'] = preFmSnapshot['stopped_at'];
168
- mutated = true;
169
- }
170
- // current_phase_name — curated (#1743/#1695). Table: preserve-always.
171
- const phaseNameCls = getFieldClassification('current_phase_name');
172
- if (phaseNameCls !== null &&
173
- phaseNameCls.preservation === 'preserve-always' &&
174
- input.postBodyPhaseSource === input.preBodyPhaseSource &&
175
- typeof preFmSnapshot['current_phase_name'] === 'string' &&
176
- preFmSnapshot['current_phase_name'].length > 0 &&
177
- postFm['current_phase_name'] !== preFmSnapshot['current_phase_name']) {
178
- postFm['current_phase_name'] = preFmSnapshot['current_phase_name'];
179
- mutated = true;
180
- }
181
- return { postFm, mutated };
305
+ }
306
+ return { postFm: ctx.postFm, mutated: ctx.mutated };
182
307
  }
183
308
  // ----------------------------------------------------------------------------
184
309
  // Body section constants (ADR-1769 §6 — single writer after migration)
@@ -299,7 +424,7 @@ function beginPhaseCore(content, intent, deps) {
299
424
  // Extract from body (not full content) so the YAML `status:` key cannot
300
425
  // shadow the body Status field (#1255).
301
426
  const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || '';
302
- const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(intent.phaseNumber))}\\b`, 'i').test(currentStatus);
427
+ const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${(0, pattern_cjs_1.escapeRegex)(String(intent.phaseNumber))}\\b`, 'i').test(currentStatus);
303
428
  // Status update (applies on both first-time and resume — Status is always refreshed).
304
429
  tryField('Status', `Executing Phase ${intent.phaseNumber}`);
305
430
  // Last Activity date — safe to refresh on resume (tracks when execute-phase ran).
@@ -507,6 +632,25 @@ function mutateCurrentPositionForAdvance(content, fields, statusDefaults, lastAc
507
632
  return content;
508
633
  let sectionBody = content.slice(span.start, span.end);
509
634
  let mutated = false;
635
+ // #3395: Phase is always replaced when a caller passes it — system-derived,
636
+ // not executor-authored (same rule as Plan below). plannedPhaseCore uses
637
+ // this so the transition that declares phase N planned also owns the `Phase:`
638
+ // line the frontmatter resync and `state json` re-derive current_phase from;
639
+ // before, the line survived stale from a previous phase and every
640
+ // body-derived consumer kept reading it (#948 class).
641
+ if (fields.phase) {
642
+ if (/^Phase:/m.test(sectionBody)) {
643
+ sectionBody = sectionBody.replace(/^Phase:.*$/m, `Phase: ${fields.phase}`);
644
+ mutated = true;
645
+ }
646
+ else {
647
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Phase', fields.phase);
648
+ if (replaced !== null) {
649
+ sectionBody = replaced;
650
+ mutated = true;
651
+ }
652
+ }
653
+ }
510
654
  if (fields.status) {
511
655
  const replaced = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Status', statusDefaults, fields.status);
512
656
  if (replaced !== null && replaced !== sectionBody) {
@@ -676,6 +820,7 @@ function completePhaseCore(content, intent, deps) {
676
820
  'current_plan',
677
821
  'last_activity',
678
822
  'last_activity_desc',
823
+ 'stopped_at',
679
824
  'progress',
680
825
  ]) {
681
826
  const cls = getFieldClassification(fmKey);
@@ -720,8 +865,8 @@ function completePhaseCore(content, intent, deps) {
720
865
  updated.push('Current Phase');
721
866
  }
722
867
  // Current Phase Name — only written when a next-phase display name is known
723
- // (#1743/#1695: classified curated/preserve-always, so an absent name does
724
- // NOT clear an existing curated value).
868
+ // (#1743/#1695: classified curated/preserve-when-unchanged, so an absent
869
+ // name does NOT clear an existing curated value).
725
870
  if (nextPhaseDisplayName) {
726
871
  const after = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase Name', nextPhaseDisplayName);
727
872
  if (after) {
@@ -766,6 +911,28 @@ function completePhaseCore(content, intent, deps) {
766
911
  body = ladAfter;
767
912
  updated.push('Last Activity Description');
768
913
  }
914
+ // Stopped At — #3374: write the continuity line this transition implies.
915
+ // The frontmatter `stopped_at` is a projection of this body line
916
+ // (source: 'body' in FIELD_CLASSIFICATION), and phase completion is exactly
917
+ // the event the line describes — leaving it stale made the post-sync harvest
918
+ // overwrite a fresher frontmatter value with pre-completion prose on every
919
+ // completion (#3374), and left the workflow's later prose refresh as a
920
+ // divergence source. Session-SCOPED replace (stateReplaceFieldInSession):
921
+ // the harvest reads only the session section, so the write must target the
922
+ // same scope — a whole-body replace let a decoy `**Stopped at:**` line in an
923
+ // unrelated section absorb the refresh. Replace-only (no insertion): a
924
+ // STATE.md with no session continuity line keeps its shape, and the
925
+ // unchanged body source then lets the preservation delta keep an existing
926
+ // frontmatter value. Last-phase wording reuses the ADR-2207 status phrase;
927
+ // milestone termination wording stays owned by milestoneCompleteCore.
928
+ const stoppedAtLine = intent.isLastPhase
929
+ ? `Phase ${intent.phaseNum} complete — all phases complete`
930
+ : `Phase ${intent.phaseNum} complete${intent.nextPhaseNum ? `, ready to plan Phase ${intent.nextPhaseNum}` : ''}`;
931
+ const stoppedAfter = (0, state_document_cjs_1.stateReplaceFieldInSession)(body, 'Stopped At', 'Stopped at', stoppedAtLine);
932
+ if (stoppedAfter !== body) {
933
+ body = stoppedAfter;
934
+ updated.push('Stopped At');
935
+ }
769
936
  // Progress block — re-derive completed/total phases from the roadmap when
770
937
  // available (milestone-wide source of truth), then recompute the percent.
771
938
  // Only runs when a Completed Phases field exists (the existing guard).
@@ -821,7 +988,10 @@ function completePhaseCore(content, intent, deps) {
821
988
  * per-phase body fields after plan-phase runs: Status (template-aware — only
822
989
  * replaces handler-generated values, preserving executor-authored ones),
823
990
  * Total Plans in Phase, Last Activity (template-aware), Last Activity
824
- * Description, and the ## Current Position section. The adapter wraps this in
991
+ * Description, and the ## Current Position section — including its `Phase:`
992
+ * line, which this transition owns (#3395: the line is the body source
993
+ * `current_phase` re-derives from, so it must not survive stale from a
994
+ * previous phase). The adapter wraps this in
825
995
  * `readModifyWriteStateMd({ resync: false })` so the milestone-wide progress.*
826
996
  * frontmatter is NOT re-derived from a half-planned disk snapshot (#500 RC1).
827
997
  *
@@ -874,9 +1044,20 @@ function plannedPhaseCore(content, intent, deps) {
874
1044
  body = ladResult;
875
1045
  updated.push('Last Activity Description');
876
1046
  }
877
- // ## Current Position section — Status + Last activity (template-aware).
1047
+ // ## Current Position section — Phase + Status + Last activity.
1048
+ // #3395: plannedPhaseCore owns the `Phase:` line for the same reason
1049
+ // beginPhaseCore/completePhaseCore do — it is the body source the frontmatter
1050
+ // resync and `state json` re-derive `current_phase` from. Before, a stale
1051
+ // line from a previous phase survived this transition and every
1052
+ // body-derived consumer kept reading it (the write path was already
1053
+ // protected by the #3258 preserve-when-unchanged row; the source itself was
1054
+ // never refreshed). The label mirrors beginPhaseCore's `N (Name) — EXECUTING`
1055
+ // convention with this transition's status vocabulary ("Ready to execute").
1056
+ // Phase is system-derived, always replaced (Knuth invariant does not apply);
1057
+ // Status / Last activity stay template-aware.
878
1058
  const beforePos = body;
879
1059
  body = mutateCurrentPositionForAdvance(body, {
1060
+ phase: `${intent.phaseNumber}${intent.phaseName ? ` (${intent.phaseName})` : ''} — READY TO EXECUTE`,
880
1061
  status: 'Ready to execute',
881
1062
  lastActivity: `${today} — Phase ${intent.phaseNumber} planning complete`,
882
1063
  }, statusDefaults, lastActivityDefaults);
@@ -911,10 +1092,11 @@ function plannedPhaseCore(content, intent, deps) {
911
1092
  * preserved.
912
1093
  *
913
1094
  * This is a destructive reset intent: it intentionally overwrites the curated
914
- * `progress` / `current_phase_name` fields (classified preserve-always) because
915
- * a new milestone starts from zero. That is the intent's contract, not a
916
- * violation of the field-classification table — the table governs the steady-
917
- * state RMW transitions; a milestone boundary is an explicit reset.
1095
+ * `progress` (preserve-always) / `current_phase_name` (preserve-when-unchanged)
1096
+ * fields because a new milestone starts from zero. That is the intent's
1097
+ * contract, not a violation of the field-classification table — the table
1098
+ * governs the steady-state RMW transitions; a milestone boundary is an
1099
+ * explicit reset.
918
1100
  *
919
1101
  * The adapter wraps this in `acquireStateLock` + `platformWriteSync` (NOT
920
1102
  * `readModifyWriteStateMd`) because milestoneSwitch rebuilds frontmatter
@@ -1132,13 +1314,49 @@ function milestoneCompleteCore(content, intent, deps) {
1132
1314
  * Apply a `patch` transition to STATE.md content.
1133
1315
  *
1134
1316
  * Migrates `cmdStatePatch` (state.cts) onto the substrate. Applies each
1135
- * caller-supplied `{field: value}` pair via `stateReplaceField` over the full
1136
- * content (body + frontmatter — patch can target either), tracking which fields
1137
- * were updated vs. not found.
1317
+ * caller-supplied `{field: value}` pair, resolved BODY-FIRST:
1318
+ *
1319
+ * - A key that resolves against the STRIPPED body (via `stateReplaceField`,
1320
+ * case-insensitive on the field name) is applied there and reported
1321
+ * `updated` — this is the legitimate, documented case (display-cased body
1322
+ * fields — Status, Current Plan, Phase — which are never frontmatter
1323
+ * keys). It wins deterministically even when the same key also happens to
1324
+ * exist as a parsed frontmatter key (e.g. `status` matches both the
1325
+ * frontmatter key and a `Status:` body line) — frontmatter is inert for
1326
+ * that key.
1327
+ * - Only when the body has no match is the key checked against parsed
1328
+ * frontmatter (determined structurally, never by a naming heuristic), and
1329
+ * routed through the seam: `FIELD_CLASSIFICATION` governs it. A CLASSIFIED
1330
+ * key (has a row, e.g. `current_phase`, `current_phase_name`) is NOT
1331
+ * writable by an arbitrary patch — policy owns it — and is reported
1332
+ * `failed`. An UNCLASSIFIED key (no row, e.g. a custom `risk_level`) is a
1333
+ * pass-through per Phase 1 behavior-table row 19 ("field absent from
1334
+ * FIELD_CLASSIFICATION → untouched pass-through"): it is applied directly
1335
+ * to the frontmatter object before reassembly and reported `updated`.
1336
+ * - A key matching neither the body nor the frontmatter is reported `failed`.
1337
+ *
1338
+ * ADR-3408 §8.3(b): this used to run `stateReplaceField` over the FULL
1339
+ * document (body + frontmatter), which — because `field` is an arbitrary,
1340
+ * caller-supplied string, unlike every other `stateReplaceField` call site in
1341
+ * this file, which passes a fixed Title-Case string literal that can never
1342
+ * collide with a lowercase/snake_case YAML key — let a frontmatter-shaped
1343
+ * patch key (e.g. `status`, `current_phase`) match and rewrite the YAML
1344
+ * frontmatter block directly via `stateReplaceField`'s case-insensitive
1345
+ * `^field:` line pattern, entirely outside `FIELD_CLASSIFICATION` and the
1346
+ * write-seam preservation policy: a second, undeclared writer. The fix is
1347
+ * that a CLASSIFIED frontmatter key no longer writes outside the declared
1348
+ * policy table — not that every frontmatter-shaped key stops working.
1349
+ * `.gsd/phase/refactor-3469-one-write-seam/40-design.md` row 9 requires
1350
+ * frontmatter changes to route through the seam (still work, governed by
1351
+ * FIELD_CLASSIFICATION), not to stop working outright. Body-shaped keys
1352
+ * (`Status`, `Current Plan`, `Phase`, ...) are the LEGITIMATE case and are
1353
+ * unaffected — they were always matched against the body text, and still are.
1138
1354
  *
1139
1355
  * The curated-field preservation that fixes #1743/#1695 is NOT in this core —
1140
- * it lives in `readModifyWriteStateMd`'s post-sync delta (table-driven via
1141
- * `getFieldClassification('current_phase_name').preservation === 'preserve-always'`).
1356
+ * it lives in the write seam's post-sync delta (table-driven via
1357
+ * `current_phase_name`'s `preserve-when-unchanged` row, ADR-3408 §8.1 —
1358
+ * reclassified from `preserve-always` in #3468 to match its long-standing,
1359
+ * delta-gated behavior).
1142
1360
  * `patch` consulting the table "refuses to overwrite" curated fields implicitly:
1143
1361
  * when the patch does not change a curated field's body source line, the
1144
1362
  * existing frontmatter value wins over the sync re-derivation. The adapter
@@ -1147,19 +1365,55 @@ function milestoneCompleteCore(content, intent, deps) {
1147
1365
  * `data.updated` / `data.failed` mirror the pre-migration CLI output shape.
1148
1366
  */
1149
1367
  function patchCore(content, intent) {
1368
+ const existingFm = extractFrontmatter(content);
1369
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
1370
+ let body = stripFrontmatter(content);
1371
+ const fm = { ...existingFm };
1150
1372
  const updated = [];
1151
1373
  const failed = [];
1152
- let result = content;
1153
1374
  for (const [field, value] of Object.entries(intent.patches)) {
1154
- const replaced = (0, state_document_cjs_1.stateReplaceField)(result, field, value);
1375
+ // Body-first: a key that resolves against a body field is the
1376
+ // legitimate, documented case (display-cased body fields — Status,
1377
+ // Current Plan, Phase — are never frontmatter keys) and wins
1378
+ // deterministically even when the same key also happens to exist as a
1379
+ // frontmatter key (case-insensitively, via stateReplaceField's
1380
+ // `^field:` pattern — e.g. `status` matching both the frontmatter key
1381
+ // and a `Status:` body line). Frontmatter is only consulted when the
1382
+ // body has no match for this key.
1383
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
1155
1384
  if (replaced !== null) {
1156
- result = replaced;
1385
+ body = replaced;
1157
1386
  updated.push(field);
1387
+ continue;
1158
1388
  }
1159
- else {
1160
- failed.push(field);
1389
+ if (Object.prototype.hasOwnProperty.call(existingFm, field)) {
1390
+ // Frontmatter-shaped key: route through the seam. A classified field
1391
+ // is policy-owned — a raw patch may not bypass it. An unclassified
1392
+ // field is an untouched pass-through (behavior-table row 19).
1393
+ if (getFieldClassification(field) !== null) {
1394
+ failed.push(field);
1395
+ }
1396
+ else {
1397
+ fm[field] = value;
1398
+ updated.push(field);
1399
+ }
1400
+ continue;
1161
1401
  }
1162
- }
1402
+ failed.push(field);
1403
+ }
1404
+ if (updated.length === 0) {
1405
+ // No field matched — return `content` VERBATIM (mirrors `updateCore`'s
1406
+ // null-result branch): reassembling via stripFrontmatter/
1407
+ // reconstructFrontmatter even when nothing changed can round-trip the
1408
+ // frontmatter block to different bytes than the original (key order,
1409
+ // formatting), which would falsely defeat `readModifyWriteStateMd`'s
1410
+ // #948 no-op write guard for every patch that updates nothing, not just
1411
+ // a frontmatter-shaped one.
1412
+ return { content, updated, data: { updated, failed } };
1413
+ }
1414
+ const result = hasFrontmatter
1415
+ ? `---\n${reconstructFrontmatter(fm)}\n---\n\n${body}`
1416
+ : body;
1163
1417
  return { content: result, updated, data: { updated, failed } };
1164
1418
  }
1165
1419
  // ----------------------------------------------------------------------------