@opengsd/gsd-core 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +1 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-dom-verifier.md +169 -0
  7. package/agents/gsd-eval-auditor.md +1 -1
  8. package/agents/gsd-executor.md +17 -9
  9. package/agents/gsd-framework-selector.md +1 -3
  10. package/agents/gsd-intel-updater.md +1 -1
  11. package/agents/gsd-mempalace-curator.md +0 -1
  12. package/agents/gsd-pattern-mapper.md +11 -0
  13. package/agents/gsd-phase-researcher.md +3 -1
  14. package/agents/gsd-plan-checker.md +15 -55
  15. package/agents/gsd-planner.md +6 -4
  16. package/agents/gsd-project-researcher.md +1 -1
  17. package/agents/gsd-research-synthesizer.md +2 -2
  18. package/agents/gsd-roadmapper.md +15 -11
  19. package/agents/gsd-ui-checker.md +63 -4
  20. package/agents/gsd-ui-researcher.md +41 -3
  21. package/agents/gsd-verifier.md +1 -1
  22. package/bin/install.js +609 -134
  23. package/commands/gsd/discuss-phase.md +1 -1
  24. package/commands/gsd/import.md +1 -1
  25. package/commands/gsd/quick.md +8 -4
  26. package/gsd-core/bin/gsd-tools.cjs +567 -51
  27. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  28. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  29. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  30. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  31. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  32. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  33. package/gsd-core/bin/lib/audit.cjs +163 -41
  34. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  35. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  36. package/gsd-core/bin/lib/capability-registry.cjs +336 -95
  37. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  38. package/gsd-core/bin/lib/capability-validator.cjs +205 -18
  39. package/gsd-core/bin/lib/check-command-router.cjs +145 -5
  40. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  41. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  43. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  44. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  45. package/gsd-core/bin/lib/commands.cjs +543 -44
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  47. package/gsd-core/bin/lib/config-loader.cjs +118 -29
  48. package/gsd-core/bin/lib/config.cjs +92 -2
  49. package/gsd-core/bin/lib/configuration.cjs +129 -37
  50. package/gsd-core/bin/lib/core-utils.cjs +84 -7
  51. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  52. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  53. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  54. package/gsd-core/bin/lib/frontmatter.cjs +840 -305
  55. package/gsd-core/bin/lib/gap-checker.cjs +27 -3
  56. package/gsd-core/bin/lib/git-base-branch.cjs +174 -39
  57. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  58. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +6 -3
  59. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  60. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  61. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  62. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  63. package/gsd-core/bin/lib/init.cjs +120 -41
  64. package/gsd-core/bin/lib/install-engine.cjs +68 -3
  65. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -1
  66. package/gsd-core/bin/lib/install-profiles.cjs +78 -4
  67. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  68. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  69. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  70. package/gsd-core/bin/lib/intel.cjs +101 -26
  71. package/gsd-core/bin/lib/io.cjs +160 -15
  72. package/gsd-core/bin/lib/learnings.cjs +85 -14
  73. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  74. package/gsd-core/bin/lib/markdown-table.cjs +52 -4
  75. package/gsd-core/bin/lib/milestone.cjs +90 -5
  76. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  77. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  78. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  79. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  80. package/gsd-core/bin/lib/phase-id.cjs +70 -4
  81. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  82. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  83. package/gsd-core/bin/lib/phase.cjs +405 -84
  84. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  85. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  86. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  87. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  88. package/gsd-core/bin/lib/planning-snapshot.cjs +18 -14
  89. package/gsd-core/bin/lib/planning-workspace.cjs +56 -0
  90. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  91. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  92. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  93. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  94. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  95. package/gsd-core/bin/lib/review-lane-descriptor.cjs +9 -9
  96. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  97. package/gsd-core/bin/lib/roadmap-parser.cjs +79 -16
  98. package/gsd-core/bin/lib/roadmap.cjs +74 -19
  99. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +96 -8
  100. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -1
  101. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +287 -55
  102. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  103. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  104. package/gsd-core/bin/lib/shell-command-projection.cjs +71 -8
  105. package/gsd-core/bin/lib/smart-entry.cjs +12 -22
  106. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  107. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  108. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  109. package/gsd-core/bin/lib/state-document.cjs +186 -0
  110. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  111. package/gsd-core/bin/lib/state-transition.cjs +517 -101
  112. package/gsd-core/bin/lib/state.cjs +946 -163
  113. package/gsd-core/bin/lib/surface.cjs +10 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  115. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  116. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  117. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  118. package/gsd-core/bin/lib/uat.cjs +1376 -125
  119. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  120. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  121. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  122. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  123. package/gsd-core/bin/lib/vendor/README.md +43 -5
  124. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  125. package/gsd-core/bin/lib/verification.cjs +14 -1
  126. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  127. package/gsd-core/bin/lib/verify.cjs +95 -40
  128. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  129. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  130. package/gsd-core/bin/lib/worktree-safety.cjs +177 -21
  131. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  132. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  133. package/gsd-core/bin/shared/exit-codes.json +8 -0
  134. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  135. package/gsd-core/bin/shared/model-catalog.json +8 -1
  136. package/gsd-core/references/agent-contracts.md +3 -2
  137. package/gsd-core/references/api-coverage.md +24 -2
  138. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  139. package/gsd-core/references/checkpoints.md +37 -19
  140. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  141. package/gsd-core/references/edge-probe.md +8 -0
  142. package/gsd-core/references/execute-mvp-tdd.md +1 -3
  143. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  144. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  145. package/gsd-core/references/failing-direction.md +78 -0
  146. package/gsd-core/references/gate-prompts.md +1 -1
  147. package/gsd-core/references/git-integration.md +5 -5
  148. package/gsd-core/references/git-planning-commit.md +3 -3
  149. package/gsd-core/references/gsd-run-resolver.md +1 -1
  150. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  151. package/gsd-core/references/model-profiles.md +1 -1
  152. package/gsd-core/references/nyquist-compliance.md +74 -0
  153. package/gsd-core/references/offer-next.md +3 -5
  154. package/gsd-core/references/phase-argument-parsing.md +3 -3
  155. package/gsd-core/references/planner-failing-direction.md +53 -0
  156. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  157. package/gsd-core/references/planner-revision.md +1 -1
  158. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  159. package/gsd-core/references/planning-config.md +37 -8
  160. package/gsd-core/references/reviewer-instances.md +31 -0
  161. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  162. package/gsd-core/references/tdd.md +1 -3
  163. package/gsd-core/references/ui-brand.md +65 -21
  164. package/gsd-core/references/ui-consideration-probe.md +1 -1
  165. package/gsd-core/references/universal-anti-patterns.md +2 -2
  166. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  167. package/gsd-core/references/verify-mvp-mode.md +1 -1
  168. package/gsd-core/references/workstream-flag.md +11 -11
  169. package/gsd-core/templates/README.md +1 -1
  170. package/gsd-core/templates/SECURITY.md +3 -3
  171. package/gsd-core/templates/UI-SPEC.md +25 -3
  172. package/gsd-core/templates/VALIDATION.md +3 -3
  173. package/gsd-core/templates/phase-prompt.md +3 -0
  174. package/gsd-core/templates/state.md +7 -0
  175. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  176. package/gsd-core/workflows/add-backlog.md +1 -1
  177. package/gsd-core/workflows/add-phase.md +3 -3
  178. package/gsd-core/workflows/add-tests.md +3 -8
  179. package/gsd-core/workflows/add-todo.md +1 -1
  180. package/gsd-core/workflows/ai-integration-phase.md +4 -9
  181. package/gsd-core/workflows/audit-fix.md +12 -3
  182. package/gsd-core/workflows/audit-milestone.md +9 -9
  183. package/gsd-core/workflows/audit-uat.md +17 -2
  184. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  185. package/gsd-core/workflows/autonomous.md +10 -26
  186. package/gsd-core/workflows/check-todos.md +1 -1
  187. package/gsd-core/workflows/cleanup.md +2 -2
  188. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  189. package/gsd-core/workflows/code-review-fix.md +1 -1
  190. package/gsd-core/workflows/code-review.md +121 -40
  191. package/gsd-core/workflows/complete-milestone.md +15 -10
  192. package/gsd-core/workflows/debug.md +5 -3
  193. package/gsd-core/workflows/diagnose-issues.md +12 -6
  194. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  195. package/gsd-core/workflows/discuss-phase/modes/chain.md +3 -7
  196. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  197. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  198. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -2
  199. package/gsd-core/workflows/discuss-phase.md +1 -1
  200. package/gsd-core/workflows/do.md +3 -6
  201. package/gsd-core/workflows/docs-update.md +5 -4
  202. package/gsd-core/workflows/edit-phase.md +1 -1
  203. package/gsd-core/workflows/eval-review.md +4 -9
  204. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  205. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +113 -11
  206. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  207. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  208. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  209. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +22 -4
  210. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  211. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  212. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  213. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  214. package/gsd-core/workflows/execute-phase.md +38 -54
  215. package/gsd-core/workflows/execute-plan.md +17 -12
  216. package/gsd-core/workflows/explore.md +1 -1
  217. package/gsd-core/workflows/extract-learnings.md +1 -1
  218. package/gsd-core/workflows/fast.md +2 -2
  219. package/gsd-core/workflows/forensics.md +1 -1
  220. package/gsd-core/workflows/graduation.md +5 -5
  221. package/gsd-core/workflows/health.md +3 -6
  222. package/gsd-core/workflows/import.md +14 -11
  223. package/gsd-core/workflows/inbox.md +4 -5
  224. package/gsd-core/workflows/ingest-docs.md +44 -11
  225. package/gsd-core/workflows/insert-phase.md +5 -5
  226. package/gsd-core/workflows/list-seeds.md +5 -3
  227. package/gsd-core/workflows/list-workspaces.md +1 -1
  228. package/gsd-core/workflows/manager.md +12 -23
  229. package/gsd-core/workflows/map-codebase.md +1 -1
  230. package/gsd-core/workflows/milestone-summary.md +1 -1
  231. package/gsd-core/workflows/mvp-phase.md +2 -2
  232. package/gsd-core/workflows/new-milestone.md +9 -21
  233. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  234. package/gsd-core/workflows/new-project.md +12 -26
  235. package/gsd-core/workflows/new-workspace.md +1 -1
  236. package/gsd-core/workflows/next.md +2 -2
  237. package/gsd-core/workflows/pause-work.md +1 -1
  238. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  239. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  240. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  241. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  242. package/gsd-core/workflows/plan-phase.md +121 -42
  243. package/gsd-core/workflows/plan-review-convergence.md +46 -9
  244. package/gsd-core/workflows/plant-seed.md +2 -2
  245. package/gsd-core/workflows/pr-branch.md +187 -51
  246. package/gsd-core/workflows/profile-user.md +16 -14
  247. package/gsd-core/workflows/progress.md +27 -12
  248. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  249. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -3
  250. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  251. package/gsd-core/workflows/quick/steps/research-phase.md +2 -4
  252. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  253. package/gsd-core/workflows/quick.md +20 -29
  254. package/gsd-core/workflows/remove-phase.md +4 -4
  255. package/gsd-core/workflows/remove-workspace.md +2 -2
  256. package/gsd-core/workflows/resume-project.md +8 -12
  257. package/gsd-core/workflows/review.md +193 -15
  258. package/gsd-core/workflows/scan.md +1 -1
  259. package/gsd-core/workflows/secure-phase.md +2 -2
  260. package/gsd-core/workflows/settings-advanced.md +7 -9
  261. package/gsd-core/workflows/settings-integrations.md +64 -31
  262. package/gsd-core/workflows/settings.md +3 -5
  263. package/gsd-core/workflows/ship.md +12 -6
  264. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  265. package/gsd-core/workflows/sketch.md +12 -18
  266. package/gsd-core/workflows/smart-entry.md +3 -5
  267. package/gsd-core/workflows/spec-phase.md +23 -1
  268. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  269. package/gsd-core/workflows/spike.md +20 -31
  270. package/gsd-core/workflows/stats.md +2 -2
  271. package/gsd-core/workflows/sync-skills.md +1 -1
  272. package/gsd-core/workflows/thread.md +11 -7
  273. package/gsd-core/workflows/transition.md +5 -5
  274. package/gsd-core/workflows/ui-phase.md +10 -16
  275. package/gsd-core/workflows/ui-review.md +6 -10
  276. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  277. package/gsd-core/workflows/undo.md +8 -16
  278. package/gsd-core/workflows/update.md +6 -10
  279. package/gsd-core/workflows/validate-phase.md +2 -2
  280. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  281. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  282. package/gsd-core/workflows/verify-work.md +57 -18
  283. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  284. package/hooks/dist/gsd-config-reload.js +18 -12
  285. package/hooks/dist/gsd-context-monitor.js +19 -10
  286. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  287. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  288. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  289. package/hooks/dist/gsd-cursor-stop.js +2 -1
  290. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  291. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  292. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  293. package/hooks/dist/gsd-graphify-update.sh +22 -18
  294. package/hooks/dist/gsd-node-runner.sh +76 -0
  295. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  296. package/hooks/dist/gsd-prompt-guard.js +16 -7
  297. package/hooks/dist/gsd-read-guard.js +16 -7
  298. package/hooks/dist/gsd-read-injection-scanner.js +17 -8
  299. package/hooks/dist/gsd-session-state.sh +1 -0
  300. package/hooks/dist/gsd-statusline.js +215 -26
  301. package/hooks/dist/gsd-validate-commit.sh +80 -6
  302. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  303. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  304. package/hooks/dist/gsd-workflow-guard.js +34 -16
  305. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  306. package/hooks/dist/gsd-write-guard.js +35 -25
  307. package/hooks/dist/lib/cli-exit.js +560 -0
  308. package/hooks/dist/lib/exit-code-registry.js +98 -0
  309. package/hooks/dist/lib/git-probe.js +84 -0
  310. package/hooks/dist/lib/hook-exit.js +81 -0
  311. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  312. package/hooks/gsd-agent-isolation-guard.js +77 -38
  313. package/hooks/gsd-config-reload.js +18 -12
  314. package/hooks/gsd-context-monitor.js +19 -10
  315. package/hooks/gsd-cursor-post-tool.js +3 -1
  316. package/hooks/gsd-cursor-pre-tool.js +3 -1
  317. package/hooks/gsd-cursor-session-start.js +2 -1
  318. package/hooks/gsd-cursor-stop.js +2 -1
  319. package/hooks/gsd-cursor-subagent-start.js +28 -23
  320. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  321. package/hooks/gsd-ensure-canonical-path.js +2 -1
  322. package/hooks/gsd-graphify-update.sh +22 -18
  323. package/hooks/gsd-node-runner.sh +76 -0
  324. package/hooks/gsd-phase-boundary.sh +1 -0
  325. package/hooks/gsd-prompt-guard.js +16 -7
  326. package/hooks/gsd-read-guard.js +16 -7
  327. package/hooks/gsd-read-injection-scanner.js +17 -8
  328. package/hooks/gsd-session-state.sh +1 -0
  329. package/hooks/gsd-statusline.js +215 -26
  330. package/hooks/gsd-validate-commit.sh +80 -6
  331. package/hooks/gsd-windsurf-pre-command.js +16 -11
  332. package/hooks/gsd-windsurf-pre-write.js +22 -13
  333. package/hooks/gsd-workflow-guard.js +34 -16
  334. package/hooks/gsd-worktree-path-guard.js +36 -21
  335. package/hooks/gsd-write-guard.js +35 -25
  336. package/hooks/lib/cli-exit.js +560 -0
  337. package/hooks/lib/exit-code-registry.js +98 -0
  338. package/hooks/lib/git-probe.js +84 -0
  339. package/hooks/lib/hook-exit.js +81 -0
  340. package/hooks/managed-hooks-registry.cjs +3 -0
  341. package/package.json +12 -7
  342. package/scripts/base64-scan.sh +74 -12
  343. package/scripts/build-hooks.js +5 -0
  344. package/scripts/check-glossary-refs.cjs +77 -15
  345. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  346. package/scripts/ci-check-job-near-cap.cjs +49 -0
  347. package/scripts/ci-pr-mergeability.cjs +262 -0
  348. package/scripts/ci-test-scope.cjs +45 -12
  349. package/scripts/ci-timeout-report.cjs +230 -0
  350. package/scripts/docs-guard-registry.cjs +396 -0
  351. package/scripts/gen-capability-registry.cjs +8 -6
  352. package/scripts/gen-exit-code-docs.cjs +318 -0
  353. package/scripts/gen-exit-code-registry.cjs +891 -0
  354. package/scripts/gen-features.cjs +836 -0
  355. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  356. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  357. package/scripts/gen-loop-host-contract.cjs +134 -1
  358. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  359. package/scripts/gen-state-md-docs.cjs +727 -0
  360. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  361. package/scripts/lib/ci-job-timing.cjs +72 -0
  362. package/scripts/lib/cli-exit.cjs +546 -44
  363. package/scripts/lib/drift-scan.cjs +32 -2
  364. package/scripts/lib/exit-code-registry.cjs +98 -0
  365. package/scripts/lib/ndjson-reporter.cjs +119 -0
  366. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  367. package/scripts/lint-docs-guard-registration.cjs +495 -0
  368. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  369. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  370. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  371. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  372. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  373. package/scripts/lint-phase-enumeration-drift.cjs +21 -8
  374. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  375. package/scripts/lint-removed-but-needed.cjs +184 -16
  376. package/scripts/lint-seam-enforcement.cjs +182 -0
  377. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  378. package/scripts/lint-source-test-name-collision.cjs +241 -0
  379. package/scripts/lint-state-write-path-drift.cjs +337 -432
  380. package/scripts/lint-test-file-count.allowlist.json +122 -4
  381. package/scripts/lint-test-file-count.cjs +25 -3
  382. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  383. package/scripts/lint-vendored-deps.cjs +208 -35
  384. package/scripts/mutation-matrix.cjs +599 -50
  385. package/scripts/prompt-injection-scan.sh +75 -14
  386. package/scripts/secret-scan.sh +75 -13
  387. package/scripts/select-docs-guards.cjs +56 -0
  388. package/scripts/sync-runtime-launcher.cjs +22 -3
  389. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  390. package/skills/gsd-import/SKILL.md +1 -1
  391. package/skills/gsd-quick/SKILL.md +8 -4
  392. package/vscode/package.json +1 -1
  393. package/bin/lib/ui-safety-gate.cjs +0 -109
  394. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  395. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -15,8 +15,14 @@
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.beginFrontmatterReassembly = beginFrontmatterReassembly;
20
+ exports.getFrontmatterBodySource = getFrontmatterBodySource;
21
+ exports.frontmatterKeyForBodyField = frontmatterKeyForBodyField;
19
22
  exports.getFieldClassification = getFieldClassification;
23
+ exports.getPreserveWhenUnchangedFields = getPreserveWhenUnchangedFields;
24
+ exports.openStateTransaction = openStateTransaction;
25
+ exports.rebuildStateTransaction = rebuildStateTransaction;
20
26
  exports.applyPreserveWhenUnchanged = applyPreserveWhenUnchanged;
21
27
  exports.applyStatePreservation = applyStatePreservation;
22
28
  exports.transitionCore = transitionCore;
@@ -28,7 +34,52 @@ const state_document_cjs_2 = require("./state-document.cjs");
28
34
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
29
35
  const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
30
36
  const pattern_cjs_1 = require("./pattern.cjs");
31
- const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter;
37
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const stateMdSchemaMod = require("./state-md-schema.cjs");
39
+ const { STATE_FIELD_SCHEMA } = stateMdSchemaMod;
40
+ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatter;
41
+ /**
42
+ * ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
43
+ * `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a
44
+ * frontmatter-fenced region exists but failed to parse (malformed YAML, or a
45
+ * refused anchor/alias/merge key)? A plain `Object.keys(existingFm).length >
46
+ * 0` check cannot distinguish that case from "no frontmatter block at all" —
47
+ * both parse to `{}` — so every `hasFrontmatter`-gated reassemble below would
48
+ * silently drop the raw frontmatter block on the next write. The marker is a
49
+ * non-enumerable-to-Object.keys Symbol key, so this check is additive and
50
+ * never fires for the genuinely-empty case.
51
+ */
52
+ function isUnparseableFrontmatter(existingFm) {
53
+ return existingFm[FRONTMATTER_UNPARSEABLE] === true;
54
+ }
55
+ /**
56
+ * ADR-3473 §8.1 (#3881): the exact bytes `stripFrontmatter` removed from the
57
+ * front of `content` to produce `strippedBody` — i.e. `content`'s raw
58
+ * frontmatter-fenced prefix, verbatim, whether or not it parsed. Reassembling
59
+ * with this prefix (instead of dropping it under `hasFrontmatter === false`)
60
+ * is what preserves an UNPARSEABLE frontmatter block across a write; it is a
61
+ * no-op difference from `content` itself when `strippedBody === content`
62
+ * (nothing was stripped).
63
+ */
64
+ function rawFrontmatterPrefix(content, strippedBody) {
65
+ return content.slice(0, content.length - strippedBody.length);
66
+ }
67
+ function beginFrontmatterReassembly(content, sourcePath) {
68
+ const existingFm = extractFrontmatter(content, sourcePath);
69
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
70
+ const body = stripFrontmatter(content);
71
+ // ADR-3473 §8.1 (#3881): computed from the ORIGINAL content/body pair, before any caller
72
+ // reassigns `body` further — the captured prefix is always the exact bytes stripped from
73
+ // the ORIGINAL content, regardless of what the caller does with `body` afterward.
74
+ const fmPrefix = rawFrontmatterPrefix(content, body);
75
+ const unparseableFm = isUnparseableFrontmatter(existingFm);
76
+ const reassemble = (b) => hasFrontmatter
77
+ ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
78
+ : unparseableFm
79
+ ? `${fmPrefix}${b}`
80
+ : b;
81
+ return { existingFm, hasFrontmatter, body, fmPrefix, unparseableFm, reassemble };
82
+ }
32
83
  // Stop predicate for section-body slicing: a level-2+ heading ends the section.
33
84
  const STOP_H2_PLUS = (lv) => lv >= 2;
34
85
  /**
@@ -45,45 +96,150 @@ const STOP_H2_PLUS = (lv) => lv >= 2;
45
96
  * (`FIELD_CLASSIFICATION['toString']` returns undefined, not the inherited
46
97
  * function). Use `getFieldClassification()` for lookups.
47
98
  */
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
- }));
99
+ /**
100
+ * #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
101
+ * (`src/state-md-schema.cts`) rather than hand-maintained here. Byte-identical
102
+ * to the pre-#3873 literal table — same 19 keys, same key ORDER (walks
103
+ * `Object.keys(STATE_FIELD_SCHEMA)` directly; see that module's row-order
104
+ * comment for why this is the one projection allowed to do that), same
105
+ * per-row shape (`{source, preservation, guard?, mergeStrategy?}`, in that
106
+ * key order, `guard`/`mergeStrategy` present only when the schema row carries
107
+ * them — never as an `undefined` own-property), same frozen null-prototype
108
+ * container. Pinned by `tests/state-transition.test.cjs`'s
109
+ * `fieldClassificationProjectionMatchesTodaysTable`, whose comparand is
110
+ * today's literal copied VERBATIM into the test (never re-derived from this
111
+ * schema — see that test's own docstring on why a self-referential parity
112
+ * test proves nothing).
113
+ */
114
+ exports.FIELD_CLASSIFICATION = Object.freeze(Object.keys(STATE_FIELD_SCHEMA).reduce((acc, key) => {
115
+ const row = STATE_FIELD_SCHEMA[key];
116
+ const projected = { source: row.source, preservation: row.preservation };
117
+ if (row.guard !== undefined)
118
+ projected.guard = row.guard;
119
+ if (row.mergeStrategy !== undefined)
120
+ projected.mergeStrategy = row.mergeStrategy;
121
+ acc[key] = projected;
122
+ return acc;
123
+ }, Object.create(null)));
124
+ /**
125
+ * Which BODY field feeds each frontmatter key.
126
+ *
127
+ * `FIELD_CLASSIFICATION` above answers "who wins when frontmatter and body
128
+ * disagree"; this answers "and what is the body one called". They are separate
129
+ * questions and this one is display/routing knowledge, not preservation policy,
130
+ * so it does not widen the ADR-3408-governed table.
131
+ *
132
+ * #3699: `state update stopped_at …` reported `Field "stopped_at" not found in
133
+ * STATE.md` — byte-identical to what a genuinely absent field reports. The key
134
+ * IS present; it is a projection of a body field, and the message pointed away
135
+ * from the route that works. Naming the source is what makes the two cases
136
+ * distinguishable.
137
+ *
138
+ * Transcribed from `buildStateFrontmatter` (`state.cts`), which is the real
139
+ * deriver. That makes this a SECOND copy of knowledge that already exists, so it
140
+ * ships with a parity test asserting this key set equals the body-derived key set
141
+ * the builder actually emits (CLAUDE.md → Generative Fix Divergence). Keys the
142
+ * builder derives from disk, an external file, or the clock have no body source
143
+ * and are deliberately ABSENT here rather than mapped to a lie.
144
+ */
145
+ /**
146
+ * #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
147
+ * (`src/state-md-schema.cts`)'s `bodySource` field, in this EXPLICIT key
148
+ * order. This order is NOT `STATE_FIELD_SCHEMA`'s own row order filtered down
149
+ * to the body-sourced keys — the pre-#3873 literal already put `status`
150
+ * before `stopped_at`/`paused_at` here while `FRONTMATTER_KEY_TO_BODY_LABEL`
151
+ * (`src/state.cts`) put it AFTER them, i.e. the two pre-existing tables
152
+ * disagreed with each other's order too, and this projection must reproduce
153
+ * ITS table's order specifically. Byte-identical to the pre-#3873 literal —
154
+ * same 8 keys, same order, same frozen null-prototype container with frozen
155
+ * per-key arrays. Pinned by `tests/state-transition.test.cjs`'s
156
+ * `bodySourceProjectionMatchesTodaysTable`.
157
+ */
158
+ const FRONTMATTER_BODY_SOURCE_KEY_ORDER = Object.freeze([
159
+ 'current_phase',
160
+ 'current_phase_name',
161
+ 'current_plan',
162
+ 'status',
163
+ 'stopped_at',
164
+ 'paused_at',
165
+ 'last_activity',
166
+ 'last_activity_desc',
167
+ ]);
168
+ exports.FRONTMATTER_BODY_SOURCE = Object.freeze(FRONTMATTER_BODY_SOURCE_KEY_ORDER.reduce((acc, key) => {
169
+ const row = STATE_FIELD_SCHEMA[key];
170
+ acc[key] = Object.freeze([...(row.bodySource ?? [])]);
171
+ return acc;
172
+ }, Object.create(null)));
173
+ /**
174
+ * The frontmatter keys whose body source lives inside `## Session`.
175
+ *
176
+ * #3374 established that these fields must be written where the reader reads
177
+ * them: `buildStateFrontmatter` harvests `Stopped At` / `Paused At` from the
178
+ * session section only, so a whole-body replace "lets a decoy `**Stopped at:**`
179
+ * line in an unrelated (e.g. archive) section absorb the refresh while the
180
+ * harvested session value stays stale" (`stateReplaceFieldInSession`'s own
181
+ * docstring). `updateCore` was still doing the whole-body replace.
182
+ */
183
+ const SESSION_SCOPED_KEYS = new Set(['stopped_at', 'paused_at']);
184
+ /**
185
+ * The `(primary, fallback)` label pair for a session-scoped frontmatter KEY.
186
+ */
187
+ function sessionLabelsForKey(key) {
188
+ if (!SESSION_SCOPED_KEYS.has(key))
189
+ return null;
190
+ const labels = exports.FRONTMATTER_BODY_SOURCE[key];
191
+ return { primary: labels[0], fallback: labels[1] ?? null };
192
+ }
193
+ /**
194
+ * The same pair, resolved from a BODY LABEL the caller named (`Stopped At`,
195
+ * `Stopped at`, `Paused At`). `null` for anything else.
196
+ *
197
+ * Deliberately does NOT accept a frontmatter key. An earlier cut resolved both
198
+ * spellings through one function and used it for the write, which made
199
+ * `state update stopped_at …` write the BODY line through the session writer —
200
+ * silently defeating the "frontmatter keys are not directly writable" contract
201
+ * this whole change exists to state, and reporting `updated: false` while having
202
+ * written. The write may only ever be reached by naming a body field.
203
+ */
204
+ function sessionLabelsForBodyField(field) {
205
+ const key = frontmatterKeyForBodyField(field);
206
+ return key === null ? null : sessionLabelsForKey(key);
207
+ }
208
+ /**
209
+ * Would a session-scoped write actually land? Asks by attempting the real write
210
+ * with a throwaway value and seeing whether anything moved.
211
+ *
212
+ * Deliberately reuses the writer rather than re-deriving "where is the session
213
+ * section" — a separate scope check could disagree with the writer, and a
214
+ * presence check that disagrees with the write it guards is the whole bug class
215
+ * here. `stateReplaceFieldInSession` is replace-only and pure, so probing costs
216
+ * nothing and the result is discarded.
217
+ */
218
+ function sessionSourceExists(body, labels) {
219
+ return (0, state_document_cjs_1.stateReplaceFieldInSession)(body, labels.primary, labels.fallback, '\u0000probe') !== body;
220
+ }
221
+ /**
222
+ * Own-property body-source lookup. `null` for a key with no body source (a
223
+ * disk/external/clock-derived key) and for anything not a frontmatter key.
224
+ */
225
+ function getFrontmatterBodySource(field) {
226
+ if (!Object.prototype.hasOwnProperty.call(exports.FRONTMATTER_BODY_SOURCE, field))
227
+ return null;
228
+ return exports.FRONTMATTER_BODY_SOURCE[field];
229
+ }
230
+ /**
231
+ * Reverse lookup: the frontmatter key a body field feeds, or `null`.
232
+ * Lets a failed body-field update name the frontmatter key that still carries a
233
+ * value (#3699 case D), instead of reporting a bare absence.
234
+ */
235
+ function frontmatterKeyForBodyField(bodyField) {
236
+ const wanted = bodyField.trim().toLowerCase();
237
+ for (const key of Object.keys(exports.FRONTMATTER_BODY_SOURCE)) {
238
+ if (exports.FRONTMATTER_BODY_SOURCE[key].some((f) => f.toLowerCase() === wanted))
239
+ return key;
240
+ }
241
+ return null;
242
+ }
87
243
  /**
88
244
  * Own-property classification lookup. Returns `null` for unknown fields
89
245
  * (including inherited prototype methods like `toString`/`valueOf`).
@@ -93,6 +249,94 @@ function getFieldClassification(field) {
93
249
  return null;
94
250
  return exports.FIELD_CLASSIFICATION[field];
95
251
  }
252
+ /**
253
+ * #3836: the single source of truth for "which frontmatter keys carry the
254
+ * `preserve-when-unchanged` policy" — read straight off `FIELD_CLASSIFICATION`
255
+ * rather than re-typed as a hand-maintained literal array at each consumer.
256
+ * `cmdStateJson` (`state.cts`) previously hardcoded a 6-field list that had
257
+ * already drifted from this table by one row (`last_activity_desc`, #3258) —
258
+ * exactly the "second table parallel to the first" shape ADR-3473 exists to
259
+ * remove. `progress`/`milestone`/`milestone_name` carry a different
260
+ * preservation policy (`preserve-always` / `preserve-if-placeholder`) and are
261
+ * naturally excluded by the filter, not by a separate exclusion list.
262
+ */
263
+ function getPreserveWhenUnchangedFields() {
264
+ return Object.keys(exports.FIELD_CLASSIFICATION).filter((field) => exports.FIELD_CLASSIFICATION[field].preservation === 'preserve-when-unchanged');
265
+ }
266
+ /**
267
+ * Shared constructor body for `openStateTransaction` / `rebuildStateTransaction`
268
+ * (ADR-3473 §8.6 Decision 2/3). Validates `init.snapshot` and freezes the
269
+ * result so nothing downstream can mutate a transaction after construction
270
+ * (this is what makes the aliasing fix in `applyPreserveAlways`'s clone hold:
271
+ * the snapshot a caller passed in cannot be rewritten out from under it).
272
+ *
273
+ * `{}` and a null-prototype object are BOTH legal snapshots (Decision 2 / row
274
+ * 15 of the behavior table): `extractFrontmatter` returns `{}` for a document
275
+ * with no frontmatter or an unterminated one and never returns null or throws
276
+ * (`src/frontmatter.cts`), so `{}` is the honest snapshot of a real document —
277
+ * and `/gsd-health --repair`, which runs precisely when STATE.md is broken,
278
+ * depends on that staying legal. What is NOT legal is the snapshot being
279
+ * ABSENT (`null`/`undefined`/an array/a non-object): that is the caller
280
+ * forgetting to read the pre-write document at all, a construction failure,
281
+ * not a data question. Conflating "absent" with "empty" would turn the repair
282
+ * path's normal case into a hard throw.
283
+ */
284
+ function createStateTransaction(kind, init, ctorName) {
285
+ if (init === null || typeof init !== 'object' || Array.isArray(init)) {
286
+ const err = new Error(`${ctorName}: expected an init object, got ${init === null ? 'null' : typeof init}. ` +
287
+ 'Per ADR-3473 §8.6 / Decision 2, an absent init is a construction failure, distinct from ' +
288
+ 'a legal empty snapshot ({}) — do not "fix" this by tolerating null.');
289
+ err.code = 'STATE_TRANSACTION_SNAPSHOT_REQUIRED';
290
+ err.constructorName = ctorName;
291
+ throw err;
292
+ }
293
+ const snapshot = init.snapshot;
294
+ if (snapshot === null || snapshot === undefined || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
295
+ const err = new Error(`${ctorName}: init.snapshot is required and must be a non-array object (frontmatter map). ` +
296
+ `Per ADR-3473 §8.6 / Decision 2, an ABSENT snapshot is a construction failure — this is NOT ` +
297
+ 'the same as a legal EMPTY snapshot ({}), which every executor accepts and simply finds ' +
298
+ 'nothing to restore from (extractFrontmatter returns {} for a document with no parseable ' +
299
+ 'frontmatter, and /gsd-health --repair depends on that staying legal). Pass {} explicitly ' +
300
+ 'when the document truly has none; do not tolerate null/undefined here.');
301
+ err.code = 'STATE_TRANSACTION_SNAPSHOT_REQUIRED';
302
+ err.constructorName = ctorName;
303
+ throw err;
304
+ }
305
+ return Object.freeze({
306
+ kind,
307
+ snapshot,
308
+ resync: init.resync === true,
309
+ deriveProgressKeys: init.deriveProgressKeys === true,
310
+ bodyDeltas: init.bodyDeltas,
311
+ explicitProgressField: init.explicitProgressField === true,
312
+ });
313
+ }
314
+ /**
315
+ * ADR-3473 §8.6's `open()`: the default write-path transaction. Carries the
316
+ * pre-write snapshot and applies preservation (`applyStatePreservation` runs
317
+ * its full dispatch loop against it) — this is every STATE.md write EXCEPT
318
+ * the two sanctioned exceptions below.
319
+ */
320
+ function openStateTransaction(init) {
321
+ return createStateTransaction('open', init, 'openStateTransaction');
322
+ }
323
+ /**
324
+ * ADR-3473 §8.6's `rebuild()`: the TYPED expression of ADR-3408 §8.3's closed
325
+ * list of sanctioned exceptions to the preservation pipeline. Exactly two
326
+ * callers may construct this: `cmdStateSync` (`state sync` re-derives
327
+ * frontmatter FROM the body per #905 — the body is authoritative and
328
+ * preservation would fight it) and `REGENERATE_STATE` (`/gsd-health --repair`'s
329
+ * factory reset — the whole point is to replace what's there). The snapshot
330
+ * is still carried (for §8.7's reporting) but `applyStatePreservation` skips
331
+ * its dispatch loop entirely for a `rebuild` transaction.
332
+ *
333
+ * This list is NOT debt to be paid down later — it is a closed, deliberate
334
+ * set. Adding a third caller is an amendment to ADR-3408 §8.3, not a call site
335
+ * convenience.
336
+ */
337
+ function rebuildStateTransaction(init) {
338
+ return createStateTransaction('rebuild', init, 'rebuildStateTransaction');
339
+ }
96
340
  /**
97
341
  * ADR-3408 §8.2: an unenforced `preserve-when-unchanged` row throws. Both
98
342
  * ends of this invariant are gsd-core's own source — a declared row the
@@ -146,7 +390,7 @@ function applyPreserveWhenUnchanged(field, cls, ctx) {
146
390
  // 2. Only a real, non-whitespace-only curated string is worth restoring
147
391
  // (#3468: tightened from `.length > 0` to a trimmed check — a whitespace-
148
392
  // only snapshot is not a real curated value).
149
- const snapshot = ctx.preFmSnapshot[field];
393
+ const snapshot = ctx.snapshot[field];
150
394
  if (typeof snapshot !== 'string' || snapshot.trim().length === 0)
151
395
  return;
152
396
  // 3. Closed-vocabulary guard: status's 'unknown' sentinel is never restored.
@@ -163,28 +407,111 @@ function applyPreserveWhenUnchanged(field, cls, ctx) {
163
407
  ctx.postFm[field] = snapshot;
164
408
  ctx.mutated = true;
165
409
  }
410
+ /**
411
+ * The closed set of `progress` keys whose non-zero value means "a real
412
+ * measurement happened" (ADR-3473 §8.6 / #3756).
413
+ */
414
+ const PROGRESS_TOTAL_KEYS = ['total_phases', 'total_plans'];
415
+ /**
416
+ * Did this row's derived (or curated) value represent a REAL measurement?
417
+ *
418
+ * For a `progress-ratchet` row (today, only `progress`): an empty
419
+ * milestone-scoped scan is "nothing was measured", not "zero is done"
420
+ * (#3756, and the convention #3233 established — `computeProgressPercent`
421
+ * already returns `null` for an empty denominator). Only the TOTALS decide:
422
+ * `completed_*` being zero is normal for a real project, so it is
423
+ * deliberately excluded from this check. A non-object / absent / negative /
424
+ * non-numeric total is NOT a measurement, so it degrades TOWARD preservation,
425
+ * never toward deletion — `toFiniteNumber` (not a raw `=== 0`/`> 0` test)
426
+ * because frontmatter scalars arrive as STRINGS (`"0"`, not `0`).
427
+ *
428
+ * For any other row (no `progress-ratchet` strategy) the question is
429
+ * meaningless, so it answers `true` and behavior is unchanged — this
430
+ * function is only ever consulted from inside the `preserve-always` /
431
+ * `progress-ratchet` branch below.
432
+ */
433
+ function scanMeasuredSomething(cls, value) {
434
+ if (cls.mergeStrategy !== 'progress-ratchet')
435
+ return true;
436
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
437
+ return false;
438
+ const rec = value;
439
+ return PROGRESS_TOTAL_KEYS.some((k) => ((0, state_document_cjs_2.toFiniteNumber)(rec[k]) ?? 0) > 0);
440
+ }
441
+ /**
442
+ * Deep-clone a curated value before it re-enters `postFm` (ADR-3473 §8.6,
443
+ * "Defects fixed inline" / aliasing). `structuredClone` is a Node built-in;
444
+ * this repo takes no external deps for it. WHY a clone and not a reference
445
+ * assignment: the transaction's `snapshot` is now the SAME object §8.7's
446
+ * reporting will diff against. Assigning the nested curated object by
447
+ * reference would make `postFm.progress` alias that snapshot, so a later
448
+ * in-place mutation of `postFm` would silently rewrite the snapshot too, and
449
+ * the diff would report "no change" for a field that did change.
450
+ */
451
+ function cloneCurated(value) {
452
+ return structuredClone(value);
453
+ }
454
+ /**
455
+ * Structural equality for a restored value vs. what `postFm` already held
456
+ * (ADR-3473 §8.6, "Defects fixed inline" / #948 no-op-write family).
457
+ * `JSON.stringify` compare when either side is an object (the `progress`
458
+ * block), `===` otherwise. WHY: `applyPreserveAlways` previously set
459
+ * `ctx.mutated = true` unconditionally at its tail, even when it restored a
460
+ * value identical to what was already there — driving a write that changes
461
+ * nothing but still bumps `last_updated` / restamps `state_head`.
462
+ * `applyPreserveWhenUnchanged` already guards this (its step 5); this brings
463
+ * the two executors into agreement.
464
+ */
465
+ function preservedValuesEqual(a, b) {
466
+ if (typeof a === 'object' || typeof b === 'object') {
467
+ return JSON.stringify(a) === JSON.stringify(b);
468
+ }
469
+ return a === b;
470
+ }
166
471
  /**
167
472
  * Executor for `preservation: 'preserve-always'` (ADR-3408 §8.1). Only
168
473
  * `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.
474
+ * semantics byte-for-byte on every row the behavior table marks unchanged;
475
+ * ADR-3473 §8.6 fixes the #3756 defect (a resyncing write that measured
476
+ * nothing must not drop a real curated block) plus the two "Defects fixed
477
+ * inline" no-op-write / aliasing bugs.
173
478
  */
174
479
  function applyPreserveAlways(field, cls, ctx) {
175
- if (ctx.resync || !ctx.preFm || !ctx.preFm[field])
480
+ const curated = ctx.snapshot[field];
481
+ if (!curated)
482
+ return;
483
+ const derived = ctx.postFm[field];
484
+ const derivedMeasured = scanMeasuredSomething(cls, derived);
485
+ const curatedMeasured = scanMeasuredSomething(cls, curated);
486
+ // On a resyncing write the fresh derivation is authoritative — UNLESS it
487
+ // measured nothing while the curated block did (#3756), AND the caller did
488
+ // not explicitly name a progress-affecting field this write. The
489
+ // unmeasured-scan guard exists to stop an INCIDENTAL resync (e.g. `state
490
+ // add-decision`, whose `resync` defaults true for reasons that have
491
+ // nothing to do with `progress`) from dropping a real curated block when a
492
+ // milestone-scoped disk scan measures nothing (#3756's archived-milestone
493
+ // case). It must not also block a write the user pointed AT `progress` on
494
+ // purpose: `preserve-always`'s own contract is "never overwrite unless the
495
+ // caller explicitly names this field" (FIELD_CLASSIFICATION doc comment),
496
+ // and `state update Progress` / `state patch Progress=...` are exactly
497
+ // that naming — the resync they trigger must win even when the disk scan
498
+ // it also drives (e.g. because there are no phase dirs at all) reads as
499
+ // "unmeasured" (tests/frontmatter.test.cjs: "state.update \"Progress\"
500
+ // resyncs progress frontmatter from the updated body", pre-existing, #3242).
501
+ if (ctx.resync && (derivedMeasured || !curatedMeasured || ctx.explicitProgressField))
176
502
  return;
177
- if (cls.mergeStrategy === 'progress-ratchet' && ctx.deriveProgressKeys && ctx.postFm[field]) {
503
+ let next;
504
+ if (cls.mergeStrategy === 'progress-ratchet' && ctx.deriveProgressKeys && derived && derivedMeasured) {
178
505
  // #2440: total_plans and total_phases always take the derived (post-sync)
179
506
  // value even under !resync. This is used by cmdStatePlannedPhase where
180
507
  // total_plans must correct upward after plans are added. For body-only
181
508
  // writes (state.update/patch without the flag), the wholesale restore
182
509
  // below preserves everything as before — the #3242 Bug A protection
183
510
  // stays fully in force.
184
- const curated = ctx.preFm[field];
185
- const derived = (ctx.postFm[field] ?? {});
186
- const merged = { ...derived };
187
- if (curated) {
511
+ const curatedRecord = curated;
512
+ const derivedRecord = (derived ?? {});
513
+ const merged = { ...derivedRecord };
514
+ if (curatedRecord) {
188
515
  // #2440: total_plans and total_phases always take the derived value.
189
516
  // #2969: completed_plans and completed_phases take the derived value
190
517
  // when it is GREATER than the curated value (gap-closure plans that
@@ -195,11 +522,11 @@ function applyPreserveAlways(field, cls, ctx) {
195
522
  // disk counts, and a stale curated percent would be incoherent against
196
523
  // the ratcheted-up completed counts (e.g. 54/54 at 93%).
197
524
  const ratchetUpKeys = new Set(['completed_plans', 'completed_phases']);
198
- for (const [key, value] of Object.entries(curated)) {
525
+ for (const [key, value] of Object.entries(curatedRecord)) {
199
526
  if (key === 'total_plans' || key === 'total_phases' || key === 'percent')
200
527
  continue;
201
528
  if (ratchetUpKeys.has(key)) {
202
- const derivedNum = typeof derived[key] === 'number' ? derived[key] : -Infinity;
529
+ const derivedNum = typeof derivedRecord[key] === 'number' ? derivedRecord[key] : -Infinity;
203
530
  const curatedNum = typeof value === 'number' ? value : -Infinity;
204
531
  // Take the derived value only when it ratchets up (strictly
205
532
  // greater — #2969's `>` not `>=`); else keep curated.
@@ -212,11 +539,14 @@ function applyPreserveAlways(field, cls, ctx) {
212
539
  }
213
540
  }
214
541
  }
215
- ctx.postFm[field] = merged;
542
+ next = merged;
216
543
  }
217
544
  else {
218
- ctx.postFm[field] = ctx.preFm[field];
545
+ next = cloneCurated(curated);
219
546
  }
547
+ if (preservedValuesEqual(ctx.postFm[field], next))
548
+ return;
549
+ ctx.postFm[field] = next;
220
550
  ctx.mutated = true;
221
551
  }
222
552
  /**
@@ -241,7 +571,7 @@ function applyPreserveIfPlaceholder(_field, _cls, ctx) {
241
571
  && derivedName.length > 0
242
572
  && derivedName !== MILESTONE_PLACEHOLDER
243
573
  && !/^[\s—–:-]/.test(derivedName);
244
- const snapshotName = ctx.preFmSnapshot['milestone_name'];
574
+ const snapshotName = ctx.snapshot['milestone_name'];
245
575
  const snapshotNameIsReal = typeof snapshotName === 'string'
246
576
  && snapshotName.length > 0
247
577
  && snapshotName !== MILESTONE_PLACEHOLDER;
@@ -251,7 +581,7 @@ function applyPreserveIfPlaceholder(_field, _cls, ctx) {
251
581
  ctx.postFm['milestone_name'] = snapshotName;
252
582
  ctx.mutated = true;
253
583
  }
254
- const snapshotVersion = ctx.preFmSnapshot['milestone'];
584
+ const snapshotVersion = ctx.snapshot['milestone'];
255
585
  if (typeof snapshotVersion === 'string' && snapshotVersion.length > 0 &&
256
586
  ctx.postFm['milestone'] !== snapshotVersion) {
257
587
  ctx.postFm['milestone'] = snapshotVersion;
@@ -277,14 +607,22 @@ function applyDerive(_field, _cls, _ctx) {
277
607
  * any field was restored.
278
608
  */
279
609
  function applyStatePreservation(input) {
610
+ const { transaction } = input;
611
+ // A `rebuild()` transaction still carries the snapshot (§8.7's reporting
612
+ // needs it) but must not run preservation at all: `state sync` / `REGENERATE_STATE`
613
+ // exist to let the body / factory-reset win, and restoring curated values
614
+ // over that would re-lock exactly what the command was invoked to replace.
615
+ if (transaction.kind === 'rebuild') {
616
+ return { postFm: input.postFm, mutated: false };
617
+ }
280
618
  const ctx = {
281
- preFm: input.preFm,
282
619
  postFm: input.postFm,
283
- preFmSnapshot: input.preFmSnapshot,
284
- resync: input.resync,
285
- deriveProgressKeys: input.deriveProgressKeys === true,
286
- bodyDeltas: input.bodyDeltas,
620
+ snapshot: transaction.snapshot,
621
+ resync: transaction.resync,
622
+ deriveProgressKeys: transaction.deriveProgressKeys === true,
623
+ bodyDeltas: transaction.bodyDeltas,
287
624
  mutated: false,
625
+ explicitProgressField: transaction.explicitProgressField === true,
288
626
  };
289
627
  for (const field of Object.keys(exports.FIELD_CLASSIFICATION)) {
290
628
  const cls = getFieldClassification(field);
@@ -386,12 +724,14 @@ function beginPhaseCore(content, intent, deps) {
386
724
  // #1255: body-field replacements operate on body only (frontmatter stripped),
387
725
  // not on the full content. The YAML `status:` key matches `^Status:\s*`
388
726
  // 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;
727
+ const { reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
728
+ // #3881 review, finding 5: `body` is deliberately a LITERAL `stripFrontmatter(content)`
729
+ // assignment here rather than the helper's own `body` (which the destructure above skips) —
730
+ // scripts/lint-state-write-path-drift.cjs's Axis 3 backward scan is a single-hop textual
731
+ // pattern match, not real dataflow, and only recognizes `body = stripFrontmatter(...)` written
732
+ // out at the call site. `stripFrontmatter` is pure and idempotent, so computing it here (in
733
+ // addition to the helper's own internal call) changes nothing observable.
391
734
  let body = stripFrontmatter(content);
392
- const reassemble = (b) => hasFrontmatter
393
- ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
394
- : b;
395
735
  const today = deps.clock.localToday();
396
736
  // Consult the field-classification table for the frontmatter keys this
397
737
  // transition touches (codex Phase 1 review: "table not consulted by
@@ -708,12 +1048,35 @@ function advancePlanCore(content, deps) {
708
1048
  // not on the full content. The YAML `status:` key matches `^Status:\s*`
709
1049
  // before the body field if full content is passed (codex Phase 2 review:
710
1050
  // 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;
1051
+ const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
1052
+ let body = initialBody;
1053
+ // #3807: refuse a Current Position section carrying more than one `Phase:`
1054
+ // entry BEFORE mutating. The plan fields below come from document-wide
1055
+ // first-match extraction, so in a wave-log style section (one entry per
1056
+ // completed wave) the FIRST entry's plan counter silently advanced — in the
1057
+ // reporting incident, a hard-gated final plan 7→8 of 8 — while the entry
1058
+ // the caller meant sat untouched below it, with advanced:true and no
1059
+ // ambiguity signal. advance-plan now refuses before acting. Scoped via the
1060
+ // #2956 canonical locator (stateCurrentPositionSlice — H2 or H3 heading,
1061
+ // the same one cmdStateAdvancePlan's own milestone read uses); NO whole-body
1062
+ // fallback — a legacy-format document with unrelated `Phase:` history lines
1063
+ // elsewhere has no section to disambiguate and must keep its current
1064
+ // behavior rather than be falsely refused.
1065
+ const positionScope = (0, state_document_cjs_1.stateCurrentPositionSlice)(body);
1066
+ if (positionScope !== null) {
1067
+ const phaseCandidates = (positionScope.match(/^Phase:.*$/gm) || []);
1068
+ if (phaseCandidates.length > 1) {
1069
+ return {
1070
+ content,
1071
+ updated: [],
1072
+ data: {
1073
+ error: true,
1074
+ reason: 'ambiguous_position_phase',
1075
+ phase_candidates: phaseCandidates.map((l) => l.trim()),
1076
+ },
1077
+ };
1078
+ }
1079
+ }
717
1080
  // Parse plan number — legacy first, then compound.
718
1081
  const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan');
719
1082
  const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
@@ -831,12 +1194,8 @@ function completePhaseCore(content, intent, deps) {
831
1194
  }
832
1195
  // #1255: body-field replacements operate on body only (frontmatter stripped),
833
1196
  // 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;
1197
+ const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
1198
+ let body = initialBody;
840
1199
  // Current Phase — preserve the existing `of <total>` shape and the phase name
841
1200
  // in parens (mirrors phase.cts:1675-1697 byte-for-behaviour).
842
1201
  const phaseValue = intent.nextPhaseNum || intent.phaseNum;
@@ -1010,12 +1369,8 @@ function plannedPhaseCore(content, intent, deps) {
1010
1369
  }
1011
1370
  }
1012
1371
  // #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;
1372
+ const { existingFm, hasFrontmatter, body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
1373
+ let body = initialBody;
1019
1374
  const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status'];
1020
1375
  const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
1021
1376
  // Status — template-aware (preserve executor-authored values).
@@ -1258,12 +1613,8 @@ function milestoneCompleteCore(content, intent, deps) {
1258
1613
  }
1259
1614
  }
1260
1615
  // #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;
1616
+ const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
1617
+ let body = initialBody;
1267
1618
  // Status — `<version> milestone complete`.
1268
1619
  const statusAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Status', null, `${version} milestone complete`);
1269
1620
  if (statusAfter !== body) {
@@ -1365,8 +1716,10 @@ function milestoneCompleteCore(content, intent, deps) {
1365
1716
  * `data.updated` / `data.failed` mirror the pre-migration CLI output shape.
1366
1717
  */
1367
1718
  function patchCore(content, intent) {
1368
- const existingFm = extractFrontmatter(content);
1369
- const hasFrontmatter = Object.keys(existingFm).length > 0;
1719
+ const { existingFm, hasFrontmatter, fmPrefix, unparseableFm } = beginFrontmatterReassembly(content);
1720
+ // #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a
1721
+ // literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's
1722
+ // Axis 3 single-hop backward scan.
1370
1723
  let body = stripFrontmatter(content);
1371
1724
  const fm = { ...existingFm };
1372
1725
  const updated = [];
@@ -1413,7 +1766,9 @@ function patchCore(content, intent) {
1413
1766
  }
1414
1767
  const result = hasFrontmatter
1415
1768
  ? `---\n${reconstructFrontmatter(fm)}\n---\n\n${body}`
1416
- : body;
1769
+ : unparseableFm
1770
+ ? `${fmPrefix}${body}`
1771
+ : body;
1417
1772
  return { content: result, updated, data: { updated, failed } };
1418
1773
  }
1419
1774
  // ----------------------------------------------------------------------------
@@ -1428,16 +1783,77 @@ function patchCore(content, intent) {
1428
1783
  * Mirrors the pre-migration body-strip/reassemble contract.
1429
1784
  */
1430
1785
  function updateCore(content, intent) {
1431
- const existingFm = extractFrontmatter(content);
1432
- const hasFrontmatter = Object.keys(existingFm).length > 0;
1786
+ const { existingFm, hasFrontmatter, reassemble } = beginFrontmatterReassembly(content);
1787
+ // #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a
1788
+ // literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's
1789
+ // Axis 3 single-hop backward scan.
1433
1790
  const body = stripFrontmatter(content);
1434
- const result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value);
1791
+ // #3699 review: session-scoped fields are written through the session-scoped
1792
+ // writer. A whole-body `stateReplaceField` matches the FIRST occurrence
1793
+ // anywhere, so with no `Stopped At:` line in `## Session` but a stale one in
1794
+ // `## Session Continuity Archive`, `state update "Stopped At" …` reported
1795
+ // `updated: true` while rewriting the ARCHIVE line and leaving both the session
1796
+ // section and the `stopped_at` frontmatter key untouched — a silent corruption
1797
+ // of a historical record reported as success. #3374 already established this
1798
+ // rule for the other writer; this one had not adopted it.
1799
+ const sessionWriteLabels = sessionLabelsForBodyField(intent.field);
1800
+ let result;
1801
+ if (sessionWriteLabels) {
1802
+ // Replace-only by contract: unchanged content means the field is not in the
1803
+ // session section, which is a miss, not a write.
1804
+ const replaced = (0, state_document_cjs_1.stateReplaceFieldInSession)(body, sessionWriteLabels.primary, sessionWriteLabels.fallback, intent.value);
1805
+ result = replaced === body ? null : replaced;
1806
+ }
1807
+ else {
1808
+ result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value);
1809
+ }
1435
1810
  if (result === null) {
1811
+ // #3699 case D — the frontmatter fallback.
1812
+ //
1813
+ // Normally frontmatter keys are NOT writable here: they are projections, and
1814
+ // `buildStateFrontmatter` re-derives them from the body on every write, so a
1815
+ // direct frontmatter write would be discarded. But when the body source line
1816
+ // is absent entirely, there is nothing to derive FROM: the key's existing
1817
+ // value survives on `preserve-when-unchanged`, and neither the frontmatter
1818
+ // key nor the body field can be updated by any route. That document is
1819
+ // unrepairable through `state update`, which is the gap this closes.
1820
+ //
1821
+ // Deliberately narrow — all three must hold:
1822
+ // (1) the field is a frontmatter key with a known body source,
1823
+ // (2) NO body source line exists, so the body route is genuinely unavailable
1824
+ // (this is what keeps case A, where the body route works, routing to the
1825
+ // body as before), and
1826
+ // (3) the frontmatter already carries the key, so this updates a value that
1827
+ // is really there rather than inventing one.
1828
+ //
1829
+ // The presence check in (2) is UNSCOPED on purpose, unlike the builder's
1830
+ // `## Session` scoping for stopped_at/paused_at. The asymmetry is the safe
1831
+ // direction: any `Stopped at:` line anywhere in the body — including one in an
1832
+ // archive section — suppresses the fallback, so this never writes frontmatter
1833
+ // while a body line the user could edit still exists.
1834
+ const bodySource = getFrontmatterBodySource(intent.field);
1835
+ const frontmatterCarriesKey = hasFrontmatter && Object.prototype.hasOwnProperty.call(existingFm, intent.field);
1836
+ // The presence check asks the same question the WRITE asks, in the same
1837
+ // scope. An earlier cut checked the whole body on the reasoning that any
1838
+ // editable line should suppress the repair — but a line the reader never
1839
+ // reads is not a source, and suppressing on it left the document
1840
+ // unrepairable while pointing the user at a command that would rewrite the
1841
+ // wrong line. Same scope for read, write and probe, or they disagree.
1842
+ const sessionProbeLabels = sessionLabelsForKey(intent.field);
1843
+ const bodySourceExists = sessionProbeLabels
1844
+ ? sessionSourceExists(body, sessionProbeLabels)
1845
+ : (bodySource ?? []).some((f) => (0, state_document_cjs_1.stateExtractField)(body, f) !== null);
1846
+ if (bodySource && frontmatterCarriesKey && !bodySourceExists) {
1847
+ const nextFm = { ...existingFm, [intent.field]: intent.value };
1848
+ return {
1849
+ content: `---\n${reconstructFrontmatter(nextFm)}\n---\n\n${body}`,
1850
+ updated: [intent.field],
1851
+ data: { updated: true, wroteFrontmatter: true },
1852
+ };
1853
+ }
1436
1854
  return { content, updated: [], data: { updated: false } };
1437
1855
  }
1438
- const reassembled = hasFrontmatter
1439
- ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}`
1440
- : result;
1856
+ const reassembled = reassemble(result);
1441
1857
  return { content: reassembled, updated: [intent.field], data: { updated: true } };
1442
1858
  }
1443
1859
  // Stop predicate for prune section slicing: a level-2 OR level-3 heading ends