@opengsd/gsd-core 1.18.0 → 1.20.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 (308) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.opencode/plugins/gsd-core.js +2 -3
  4. package/agents/gsd-code-fixer.compact.md +7 -7
  5. package/agents/gsd-code-fixer.md +13 -7
  6. package/agents/gsd-code-reviewer.compact.md +49 -8
  7. package/agents/gsd-code-reviewer.md +53 -14
  8. package/agents/gsd-executor.md +8 -4
  9. package/agents/gsd-verifier.md +1 -1
  10. package/bin/install.js +422 -103
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +179 -30
  13. package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
  14. package/gsd-core/bin/lib/args-terminator.cjs +114 -0
  15. package/gsd-core/bin/lib/broken-windows.cjs +59 -1
  16. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  17. package/gsd-core/bin/lib/capability-lock.cjs +13 -2
  18. package/gsd-core/bin/lib/capability-registry.cjs +376 -103
  19. package/gsd-core/bin/lib/capability-state.cjs +14 -8
  20. package/gsd-core/bin/lib/capability-validator.cjs +32 -0
  21. package/gsd-core/bin/lib/commands.cjs +27 -17
  22. package/gsd-core/bin/lib/config-loader.cjs +13 -3
  23. package/gsd-core/bin/lib/config.cjs +256 -58
  24. package/gsd-core/bin/lib/core-utils.cjs +45 -0
  25. package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
  26. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  27. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  28. package/gsd-core/bin/lib/gate-context-drift.cjs +9 -4
  29. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
  30. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  33. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  34. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
  35. package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
  36. package/gsd-core/bin/lib/git-info-exclude.cjs +52 -0
  37. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +31 -12
  38. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  39. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  40. package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
  41. package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
  42. package/gsd-core/bin/lib/host-integration.cjs +13 -6
  43. package/gsd-core/bin/lib/init.cjs +245 -68
  44. package/gsd-core/bin/lib/install-engine.cjs +246 -32
  45. package/gsd-core/bin/lib/install-profiles.cjs +9 -4
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
  47. package/gsd-core/bin/lib/io.cjs +6 -0
  48. package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
  49. package/gsd-core/bin/lib/markdown-sectionizer.cjs +147 -1
  50. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  51. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  52. package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
  54. package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
  55. package/gsd-core/bin/lib/milestone.cjs +61 -51
  56. package/gsd-core/bin/lib/model-catalog.cjs +8 -0
  57. package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
  58. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  59. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  60. package/gsd-core/bin/lib/phase-id.cjs +629 -38
  61. package/gsd-core/bin/lib/phase-lifecycle.cjs +12 -44
  62. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  63. package/gsd-core/bin/lib/phase.cjs +3066 -653
  64. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  65. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  66. package/gsd-core/bin/lib/planning-snapshot.cjs +15 -8
  67. package/gsd-core/bin/lib/planning-workspace.cjs +21 -3
  68. package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
  69. package/gsd-core/bin/lib/read-only-git-env.cjs +79 -0
  70. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  71. package/gsd-core/bin/lib/review-lane-descriptor.cjs +57 -2
  72. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  73. package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
  74. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  75. package/gsd-core/bin/lib/roadmap-parser.cjs +735 -137
  76. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  77. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -7
  78. package/gsd-core/bin/lib/roadmap.cjs +383 -123
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
  82. package/gsd-core/bin/lib/shell-command-projection.cjs +151 -19
  83. package/gsd-core/bin/lib/smart-entry.cjs +35 -11
  84. package/gsd-core/bin/lib/state-contract.cjs +39 -15
  85. package/gsd-core/bin/lib/state-document.cjs +344 -27
  86. package/gsd-core/bin/lib/state-transition.cjs +156 -32
  87. package/gsd-core/bin/lib/state.cjs +527 -186
  88. package/gsd-core/bin/lib/surface.cjs +9 -0
  89. package/gsd-core/bin/lib/task-command-router.cjs +5 -25
  90. package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
  91. package/gsd-core/bin/lib/template.cjs +2 -2
  92. package/gsd-core/bin/lib/validate.cjs +3 -2
  93. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  94. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  95. package/gsd-core/bin/lib/verification.cjs +96 -9
  96. package/gsd-core/bin/lib/verify.cjs +4 -2
  97. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  98. package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
  99. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  100. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  101. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  102. package/gsd-core/references/cicd-review-patterns.md +45 -0
  103. package/gsd-core/references/git-integration.md +11 -0
  104. package/gsd-core/references/loop-hook-dispatch.md +13 -6
  105. package/gsd-core/references/necessity-review.md +48 -0
  106. package/gsd-core/references/planning-config.md +2 -0
  107. package/gsd-core/references/tdd.md +2 -2
  108. package/gsd-core/templates/DEBUG.md +16 -0
  109. package/gsd-core/templates/config.json +2 -1
  110. package/gsd-core/templates/verification-report.md +1 -1
  111. package/gsd-core/workflows/audit-fix.md +2 -1
  112. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
  113. package/gsd-core/workflows/code-review-fix.md +46 -5
  114. package/gsd-core/workflows/code-review.md +64 -7
  115. package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
  116. package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
  117. package/gsd-core/workflows/complete-milestone.md +37 -150
  118. package/gsd-core/workflows/diagnose-issues.md +29 -5
  119. package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
  120. package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
  121. package/gsd-core/workflows/discuss-phase-power.md +19 -2
  122. package/gsd-core/workflows/discuss-phase.md +6 -10
  123. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
  124. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
  126. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
  127. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
  128. package/gsd-core/workflows/execute-phase.md +10 -6
  129. package/gsd-core/workflows/execute-plan.md +1 -1
  130. package/gsd-core/workflows/health.md +2 -1
  131. package/gsd-core/workflows/import.md +1 -1
  132. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
  133. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  134. package/gsd-core/workflows/plan-phase.md +14 -14
  135. package/gsd-core/workflows/pr-branch.md +2 -0
  136. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
  137. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
  138. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
  139. package/gsd-core/workflows/quick.md +1 -1
  140. package/gsd-core/workflows/reapply-patches.md +104 -0
  141. package/gsd-core/workflows/review.md +70 -154
  142. package/gsd-core/workflows/section-manifest.json +5 -0
  143. package/gsd-core/workflows/secure-phase.md +1 -1
  144. package/gsd-core/workflows/settings-advanced.md +57 -9
  145. package/gsd-core/workflows/settings.md +1 -0
  146. package/gsd-core/workflows/ship.md +15 -4
  147. package/gsd-core/workflows/spec-phase.md +3 -6
  148. package/gsd-core/workflows/verify-work.md +4 -3
  149. package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
  150. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  151. package/hooks/dist/gsd-graphify-update.sh +6 -0
  152. package/hooks/dist/gsd-path-export.js +95 -0
  153. package/hooks/dist/gsd-secret-read-guard.js +172 -39
  154. package/hooks/dist/gsd-statusline.js +37 -3
  155. package/hooks/dist/gsd-windsurf-pre-write.js +29 -2
  156. package/hooks/dist/gsd-workflow-guard.js +3 -2
  157. package/hooks/dist/gsd-worktree-path-guard.js +29 -2
  158. package/hooks/dist/lib/git-probe.js +16 -0
  159. package/hooks/dist/lib/isolation-sentinel.js +44 -0
  160. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  161. package/hooks/gsd-agent-isolation-guard.js +11 -1
  162. package/hooks/gsd-cursor-subagent-start.js +6 -2
  163. package/hooks/gsd-graphify-update.sh +6 -0
  164. package/hooks/gsd-path-export.js +95 -0
  165. package/hooks/gsd-secret-read-guard.js +172 -39
  166. package/hooks/gsd-statusline.js +37 -3
  167. package/hooks/gsd-windsurf-pre-write.js +29 -2
  168. package/hooks/gsd-workflow-guard.js +3 -2
  169. package/hooks/gsd-worktree-path-guard.js +29 -2
  170. package/hooks/hooks.json +1 -0
  171. package/hooks/lib/git-probe.js +16 -0
  172. package/hooks/lib/isolation-sentinel.js +44 -0
  173. package/hooks/managed-hooks-registry.cjs +1 -0
  174. package/package.json +15 -8
  175. package/pi/gsd.cjs +1047 -10
  176. package/pi/package-agents/gsd-advisor-researcher.md +113 -0
  177. package/pi/package-agents/gsd-ai-researcher.md +111 -0
  178. package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
  179. package/pi/package-agents/gsd-code-fixer.md +774 -0
  180. package/pi/package-agents/gsd-code-reviewer.md +441 -0
  181. package/pi/package-agents/gsd-codebase-mapper.md +850 -0
  182. package/pi/package-agents/gsd-debug-session-manager.md +416 -0
  183. package/pi/package-agents/gsd-debugger.md +1274 -0
  184. package/pi/package-agents/gsd-doc-classifier.md +269 -0
  185. package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
  186. package/pi/package-agents/gsd-doc-verifier.md +212 -0
  187. package/pi/package-agents/gsd-doc-writer.md +626 -0
  188. package/pi/package-agents/gsd-dom-verifier.md +164 -0
  189. package/pi/package-agents/gsd-domain-researcher.md +144 -0
  190. package/pi/package-agents/gsd-eval-auditor.md +185 -0
  191. package/pi/package-agents/gsd-eval-planner.md +149 -0
  192. package/pi/package-agents/gsd-executor.md +898 -0
  193. package/pi/package-agents/gsd-framework-selector.md +159 -0
  194. package/pi/package-agents/gsd-integration-checker.md +476 -0
  195. package/pi/package-agents/gsd-intel-updater.md +338 -0
  196. package/pi/package-agents/gsd-mempalace-curator.md +49 -0
  197. package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
  198. package/pi/package-agents/gsd-pattern-mapper.md +340 -0
  199. package/pi/package-agents/gsd-phase-researcher.md +897 -0
  200. package/pi/package-agents/gsd-plan-checker.md +1077 -0
  201. package/pi/package-agents/gsd-planner.md +1003 -0
  202. package/pi/package-agents/gsd-project-researcher.md +611 -0
  203. package/pi/package-agents/gsd-research-synthesizer.md +259 -0
  204. package/pi/package-agents/gsd-roadmapper.md +779 -0
  205. package/pi/package-agents/gsd-security-auditor.md +171 -0
  206. package/pi/package-agents/gsd-ui-auditor.md +581 -0
  207. package/pi/package-agents/gsd-ui-checker.md +417 -0
  208. package/pi/package-agents/gsd-ui-researcher.md +469 -0
  209. package/pi/package-agents/gsd-user-profiler.md +174 -0
  210. package/pi/package-agents/gsd-verifier.md +963 -0
  211. package/pi/package-agents/manifest.json +39 -0
  212. package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
  213. package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
  214. package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
  215. package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
  216. package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
  217. package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
  218. package/pi/package-skills/gsd-capture/SKILL.md +68 -0
  219. package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
  220. package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
  221. package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
  222. package/pi/package-skills/gsd-config/SKILL.md +60 -0
  223. package/pi/package-skills/gsd-debug/SKILL.md +64 -0
  224. package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
  225. package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
  226. package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
  227. package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
  228. package/pi/package-skills/gsd-explore/SKILL.md +26 -0
  229. package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
  230. package/pi/package-skills/gsd-fast/SKILL.md +33 -0
  231. package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
  232. package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
  233. package/pi/package-skills/gsd-health/SKILL.md +36 -0
  234. package/pi/package-skills/gsd-help/SKILL.md +37 -0
  235. package/pi/package-skills/gsd-import/SKILL.md +46 -0
  236. package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
  237. package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
  238. package/pi/package-skills/gsd-manager/SKILL.md +46 -0
  239. package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
  240. package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
  241. package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
  242. package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
  243. package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
  244. package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
  245. package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
  246. package/pi/package-skills/gsd-next/SKILL.md +34 -0
  247. package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
  248. package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
  249. package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
  250. package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
  251. package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
  252. package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
  253. package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
  254. package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
  255. package/pi/package-skills/gsd-phase/SKILL.md +61 -0
  256. package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
  257. package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
  258. package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
  259. package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
  260. package/pi/package-skills/gsd-progress/SKILL.md +51 -0
  261. package/pi/package-skills/gsd-quick/SKILL.md +179 -0
  262. package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
  263. package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
  264. package/pi/package-skills/gsd-review/SKILL.md +50 -0
  265. package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
  266. package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
  267. package/pi/package-skills/gsd-settings/SKILL.md +30 -0
  268. package/pi/package-skills/gsd-ship/SKILL.md +27 -0
  269. package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
  270. package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
  271. package/pi/package-skills/gsd-spike/SKILL.md +53 -0
  272. package/pi/package-skills/gsd-stats/SKILL.md +22 -0
  273. package/pi/package-skills/gsd-surface/SKILL.md +177 -0
  274. package/pi/package-skills/gsd-thread/SKILL.md +29 -0
  275. package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
  276. package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
  277. package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
  278. package/pi/package-skills/gsd-undo/SKILL.md +38 -0
  279. package/pi/package-skills/gsd-update/SKILL.md +54 -0
  280. package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
  281. package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
  282. package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
  283. package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
  284. package/scripts/build-hooks.js +4 -0
  285. package/scripts/ci-next-health.cjs +246 -23
  286. package/scripts/command-contract-helpers.cjs +1 -1
  287. package/scripts/docs-guard-registry.cjs +22 -7
  288. package/scripts/gen-features.cjs +14 -2
  289. package/scripts/gen-health-docs.cjs +13 -1
  290. package/scripts/gen-pi-package-skills.cjs +217 -0
  291. package/scripts/lib/drift-scan.cjs +27 -0
  292. package/scripts/lib/macos-conformance-tier.generated.cjs +17 -1
  293. package/scripts/lib/platform-conformance-tier.generated.cjs +37 -1
  294. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
  295. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  296. package/scripts/lint-retired-runtime-name.cjs +5 -0
  297. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  298. package/scripts/mutation-matrix.cjs +23 -1
  299. package/scripts/npm-audit-baseline.cjs +41 -7
  300. package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
  301. package/scripts/select-docs-guards.cjs +1 -1
  302. package/scripts/self-test-report-policy.cjs +350 -0
  303. package/scripts/sync-next-version.cjs +38 -6
  304. package/skills/gsd-update/SKILL.md +5 -2
  305. package/vscode/package.json +1 -1
  306. package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
  307. package/scripts/diagnose-validate-commit-phases.cjs +0 -348
  308. package/scripts/xtrace-phase-timing.cjs +0 -205
@@ -29,10 +29,15 @@ const configLoaderMod = require("./config-loader.cjs");
29
29
  const { loadConfig } = configLoaderMod;
30
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
31
  const phaseIdMod = require("./phase-id.cjs");
32
- const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, matchPhaseDirs, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
32
+ const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
33
33
  // #2761 M3: owns the bracket milestone intro and canonical pad2 spelling.
34
34
  bracketMilestoneIntroSrcFor, } = phaseIdMod;
35
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const phaseIdDisplayMod = require("./phase-id-display.cjs");
37
+ // #4304: phaseDisplayFor below renders through this adapter
38
+ // instead of re-deriving the bracket numeric grammar locally.
39
+ const { renderBracketPhaseDisplay } = phaseIdDisplayMod;
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
41
  const roadmapParserMod = require("./roadmap-parser.cjs");
37
42
  // #3642: hasMilestoneSectioning no longer consumed here — its >=2 semantics answered sibling conflation, but this branch asks asserted-vs-section (>=1). It stays exported from roadmap-parser.cjs for its unit pins.
38
43
  const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasAnyMilestoneSection, classifyMilestoneScalar } = roadmapParserMod;
@@ -55,6 +60,29 @@ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateC
55
60
  function isUnparseableFrontmatter(existingFm) {
56
61
  return existingFm[FRONTMATTER_UNPARSEABLE] === true;
57
62
  }
63
+ /**
64
+ * Human-facing phase label for STATE.md descriptions. Identity-bearing fields
65
+ * such as Current Phase and the Current Position Phase row stay bare so their
66
+ * established readers remain byte-compatible; only descriptive text uses this
67
+ * renderer.
68
+ */
69
+ function phaseDisplayFor(cwd, phaseNumber) {
70
+ if (resolvePhaseIdConvention(cwd) !== 'bracket')
71
+ return `Phase ${phaseNumber}`;
72
+ const config = loadConfig(cwd);
73
+ const project = typeof config['project_code'] === 'string' ? config['project_code'].trim() : '';
74
+ if (!project) {
75
+ error('phase_id_convention is "bracket" but project_code is missing in .planning/config.json');
76
+ }
77
+ const info = getMilestoneInfo(cwd);
78
+ const version = info.value?.version ?? '';
79
+ const display = renderBracketPhaseDisplay(version, phaseNumber, project);
80
+ if (display === null) {
81
+ error(`phase_id_convention is "bracket" but phase ${phaseNumber} cannot be rendered ` +
82
+ `(the active milestone ${JSON.stringify(version)} cannot be resolved, or the phase number is invalid)`);
83
+ }
84
+ return display;
85
+ }
58
86
  // eslint-disable-next-line @typescript-eslint/no-require-imports
59
87
  const scanPhasePlans = require("./plan-scan.cjs");
60
88
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -76,7 +104,7 @@ const planningScopeMod = require("./planning-scope.cjs");
76
104
  const { SCOPE } = planningScopeMod;
77
105
  // eslint-disable-next-line @typescript-eslint/no-require-imports
78
106
  const phaseLocatorMod = require("./phase-locator.cjs");
79
- const { listMilestonePhaseDirs } = phaseLocatorMod;
107
+ const { phaseDirectoryOptions, listMilestonePhaseDirs, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup } = phaseLocatorMod;
80
108
  // eslint-disable-next-line @typescript-eslint/no-require-imports
81
109
  const stateTransitionMod = require("./state-transition.cjs");
82
110
  // #3873 (ADR-3473 §8.8): FRONTMATTER_KEY_TO_BODY_LABEL below is now a
@@ -92,6 +120,46 @@ const project_root_cjs_1 = require("./project-root.cjs");
92
120
  // eslint-disable-next-line @typescript-eslint/no-require-imports
93
121
  const milestoneLockMod = require("./milestone-lock.cjs");
94
122
  const { transitionCore, applyStatePreservation, sliceCurrentPositionSection, stateReplaceProgressPercent, formatProgressMachineSegment } = stateTransitionMod;
123
+ function replaceStateBodyField(content, statePath, field, value) {
124
+ const { body, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
125
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
126
+ return replaced === null ? content : reassemble(replaced);
127
+ }
128
+ /**
129
+ * Rewrite a handler-authored field inside Current Position only when it still
130
+ * carries the exact value the transition just emitted. The equality guard
131
+ * preserves executor-authored prose under the transition module's Knuth rule.
132
+ */
133
+ function replaceCurrentPositionFieldValue(content, statePath, field, expected, value) {
134
+ const { body, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
135
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
136
+ const index = headings.findIndex((heading) => heading.level === 2 && /^current\s+position$/i.test(heading.text));
137
+ if (index === -1)
138
+ return content;
139
+ const heading = headings[index];
140
+ const lines = body.split('\n');
141
+ const headingLine = lines[heading.line - 1] ?? '';
142
+ const start = heading.offset + headingLine.length + 1;
143
+ let end = body.length;
144
+ for (let next = index + 1; next < headings.length; next++) {
145
+ if (headings[next].level < 2)
146
+ continue;
147
+ end = headings[next].offset;
148
+ if (end > 0 && body[end - 1] === '\n')
149
+ end -= 1;
150
+ if (end > 0 && body[end - 1] === '\r')
151
+ end -= 1;
152
+ end = Math.max(start, end);
153
+ break;
154
+ }
155
+ const section = body.slice(start, end);
156
+ if ((0, state_document_cjs_1.stateExtractField)(section, field) !== expected)
157
+ return content;
158
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(section, field, value);
159
+ if (replaced === null)
160
+ return content;
161
+ return reassemble(body.slice(0, start) + replaced + body.slice(end));
162
+ }
95
163
  // #3699: the frontmatter-key <-> body-field routing behind `state update`'s
96
164
  // failure explanation, and the classification table it falls back to.
97
165
  const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
@@ -374,6 +442,11 @@ function cmdStatePatch(cwd, patches, raw) {
374
442
  if (!fieldCheck.valid) {
375
443
  error(`state patch: ${fieldCheck.error}`);
376
444
  }
445
+ // #4998: as for `state update` — refused up front rather than listed
446
+ // under `failed` with no reason.
447
+ if (state_document_cjs_1.FIELD_LINE_BREAK_RE.test(`${patches[field]}`)) {
448
+ error(`state patch: the value for "${field}" must be a single line (no CR, LF, U+2028 or U+2029)`);
449
+ }
377
450
  }
378
451
  const statePath = planningPaths(cwd).state;
379
452
  try {
@@ -408,7 +481,20 @@ function cmdStatePatch(cwd, patches, raw) {
408
481
  // `applyStatePreservation` restored that this patch never named at all
409
482
  // (#3345's direction), a case the pre-#3471 version of this command never
410
483
  // covered.
411
- const updated = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields);
484
+ const reconciled = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields, NO_SECTION_TARGETS, true);
485
+ // #4488, as in cmdStateUpdate: a field patchCore matched that already held
486
+ // the requested value changes no byte, so the reconcile above (which asks
487
+ // "what changed") drops it — alone (no snapshot at all) or beside a field
488
+ // that did change. It is updated when the persisted value is the requested
489
+ // one; a value the write pipeline discarded still differs and stays failed
490
+ // (trek-e review 5593733001, Minor 3).
491
+ const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
492
+ const persistedFm = extractFrontmatter(persisted, statePath);
493
+ const persistedBody = stripFrontmatter(persisted);
494
+ const alreadyHeld = precomputed.updated.filter((field) => !reconciled.includes(field)
495
+ && !STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field)
496
+ && readStateTarget(persistedFm, persistedBody, field, NO_SECTION_TARGETS, true)?.trim() === String(patches[field]).trim());
497
+ const updated = [...reconciled, ...alreadyHeld];
412
498
  const updatedSet = new Set(updated);
413
499
  const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
414
500
  const results = { updated, failed };
@@ -476,6 +562,11 @@ function cmdStateUpdate(cwd, field, value) {
476
562
  if (!fieldCheck.valid) {
477
563
  error(`state update: ${fieldCheck.error}`);
478
564
  }
565
+ // #4998: the field writers are single-line and refuse a line break, which
566
+ // the transition would otherwise report as "field not found".
567
+ if (state_document_cjs_1.FIELD_LINE_BREAK_RE.test(value)) {
568
+ error(`state update: the value for "${field}" must be a single line (no CR, LF, U+2028 or U+2029)`);
569
+ }
479
570
  const statePath = planningPaths(cwd).state;
480
571
  try {
481
572
  let updated = false;
@@ -527,7 +618,7 @@ function cmdStateUpdate(cwd, field, value) {
527
618
  // restored during this write that this command never touched at all
528
619
  // (#3345's direction) — reported separately from `updated` because this
529
620
  // command's contract is a single-field boolean, not a per-field array.
530
- const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
621
+ const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields, NO_SECTION_TARGETS, true);
531
622
  // #4488: `updateCore` itself already told us whether it matched the field
532
623
  // (`updated`, captured above `readModifyWriteStateMd` runs it) — that is a
533
624
  // real signal, not a guess. `reconcileReportedFields` answers a DIFFERENT
@@ -711,17 +802,19 @@ function unsummarizedPlansForPositionPhase(cwd, positionPhase) {
711
802
  // as a real answer. This is a lookup of ONE phase token STATE.md names, not
712
803
  // a milestone enumeration, so the unscoped retry is in-contract.
713
804
  const convention = resolvePhaseIdConvention(cwd);
805
+ const lookup = resolvePhaseDirectoryLookup(cwd, positionPhase);
714
806
  const windowed = listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: convention });
715
807
  const candidateDirs = windowed.scope === SCOPE.COMPLETE ? windowed.value : [];
716
808
  // Canonical phase-token → directory matching (phase-id owner, #2562): both
717
809
  // sides of the comparison derived by the same function, never a local regex.
718
- const { matches } = matchPhaseDirs(candidateDirs, positionPhase, convention);
810
+ const directoryContext = phaseDirectoryOptions(cwd);
811
+ const { matches } = matchPhaseDirsForLookup(candidateDirs, lookup, undefined, directoryContext);
719
812
  if (matches.length > 0)
720
813
  return scanOutstanding(phasesDir, matches[0]);
721
814
  const unscoped = listMilestonePhaseDirs(phasesDir);
722
815
  if (unscoped.scope !== SCOPE.COMPLETE)
723
816
  return null;
724
- const retry = matchPhaseDirs(unscoped.value, positionPhase, convention);
817
+ const retry = matchPhaseDirsForLookup(unscoped.value, lookup, undefined, directoryContext);
725
818
  if (retry.matches.length === 0)
726
819
  return null;
727
820
  return scanOutstanding(phasesDir, retry.matches[0]);
@@ -770,7 +863,7 @@ function cmdStateAdvancePlan(cwd, raw) {
770
863
  // silently absorbed.
771
864
  const body = stripFrontmatter(content);
772
865
  const positionScope = matchCurrentPositionSection(body) ?? body;
773
- const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
866
+ const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase'), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)).phase;
774
867
  // #4093: a Current Position section with ZERO labeled fields has no
775
868
  // `Phase:` line either; frontmatter `current_phase` is the documented
776
869
  // survivor of body drift (the reporter's document still carried it, and
@@ -1283,7 +1376,7 @@ function cmdStateAddDecision(cwd, options, raw) {
1283
1376
  if (!phaseId) {
1284
1377
  const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
1285
1378
  const fm = extractFrontmatter(rawState, statePath);
1286
- phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
1379
+ phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)) ?? undefined;
1287
1380
  }
1288
1381
  const entry = `- [Phase ${phaseId || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
1289
1382
  let _added = false;
@@ -1412,8 +1505,14 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
1412
1505
  // Flatten line breaks so the entry is always a single Markdown bullet. The
1413
1506
  // dedupe + rendering contract is line-oriented; a multiline --note-file would
1414
1507
  // otherwise spill continuation lines outside the bullet and defeat dedupe.
1415
- // Internal spacing (e.g. dollar columns) is preserved.
1416
- const flatNote = noteText.replace(/\s*[\r\n]+\s*/g, ' ').trim();
1508
+ // Internal spacing (e.g. dollar columns) is preserved. #4998: every
1509
+ // FIELD_LINE_BREAK_RE character, so a U+2028/U+2029 cannot survive into the
1510
+ // bullet and start a forged `**Field:**` line for the `m`-flag readers.
1511
+ // Split and trimmed rather than one `\s*<break>+\s*` replace, which
1512
+ // backtracks quadratically over a long whitespace run with no break in it
1513
+ // (trek-e review 5608265301, Nit 5); the result is the same.
1514
+ const flatNote = noteText.split(new RegExp(`${state_document_cjs_1.FIELD_LINE_BREAK_RE.source}+`))
1515
+ .map((piece) => piece.trim()).filter(Boolean).join(' ');
1417
1516
  const actionText = (action && action.trim()) || 'changed';
1418
1517
  const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
1419
1518
  const urgentText = urgent ? ' (URGENT)' : '';
@@ -1428,7 +1527,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
1428
1527
  if (!phaseId) {
1429
1528
  const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
1430
1529
  const fm = extractFrontmatter(rawState, statePath);
1431
- phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
1530
+ phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)) ?? undefined;
1432
1531
  }
1433
1532
  const entry = `- Phase ${phaseId || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
1434
1533
  let duplicate = false;
@@ -1598,6 +1697,19 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1598
1697
  declineNoOp(raw, 'resolved', `no blocker matching ${text} found in the Blockers section`, `state resolve-blocker skipped — no blocker matching ${formatDiagnosticToken(text)} found in the Blockers section.`);
1599
1698
  }
1600
1699
  }
1700
+ /**
1701
+ * #4998 review (trek-e 5608265301, Minor 2): at most this many characters of a
1702
+ * wrapped value's continuation are echoed back — it is STATE.md text, and its
1703
+ * length is whatever the file holds.
1704
+ */
1705
+ const SKIPPED_CONTINUATION_MAX_CHARS = 200;
1706
+ function boundedContinuation(text) {
1707
+ if (text.length <= SKIPPED_CONTINUATION_MAX_CHARS)
1708
+ return text;
1709
+ // Never end on half a surrogate pair.
1710
+ const end = /[\uD800-\uDBFF]/.test(text[SKIPPED_CONTINUATION_MAX_CHARS - 1]) ? SKIPPED_CONTINUATION_MAX_CHARS - 1 : SKIPPED_CONTINUATION_MAX_CHARS;
1711
+ return `${text.slice(0, end)}…`;
1712
+ }
1601
1713
  function cmdStateRecordSession(cwd, options, raw) {
1602
1714
  // #4186: a bare invocation is a usage error, not a heartbeat write. The
1603
1715
  // pre-#4186 handler accepted zero arguments and still refreshed
@@ -1611,6 +1723,13 @@ function cmdStateRecordSession(cwd, options, raw) {
1611
1723
  if (!options.stopped_at && (options.resume_file === undefined || options.resume_file === null)) {
1612
1724
  error('stopped-at or resume-file required for state record-session');
1613
1725
  }
1726
+ // #4998: every field this writes is single-line; a line break in a value
1727
+ // would forge sibling lines (U+2028/U+2029 included — the readers' `m`-flag
1728
+ // anchors match there too), so it is refused before anything is touched.
1729
+ for (const [flag, value] of [['--stopped-at', options.stopped_at], ['--resume-file', options.resume_file]]) {
1730
+ if (typeof value === 'string' && state_document_cjs_1.FIELD_LINE_BREAK_RE.test(value))
1731
+ error(`state record-session: ${flag} must be a single line`);
1732
+ }
1614
1733
  const statePath = planningPaths(cwd).state;
1615
1734
  if (!node_fs_1.default.existsSync(statePath)) {
1616
1735
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -1628,31 +1747,95 @@ function cmdStateRecordSession(cwd, options, raw) {
1628
1747
  // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
1629
1748
  // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
1630
1749
  const preWriteState = {};
1750
+ // #4998: fields left untouched because their existing value wraps onto the
1751
+ // next line. The writers are single-line, so replacing such a field rewrote
1752
+ // only its first line and orphaned the rest. Which lines continue a value and
1753
+ // which are a separate note directly below it cannot be told apart, so the
1754
+ // write leaves the field whole and says so rather than deleting either.
1755
+ // Also (`line_separator`): a carried value the appended section leaves out.
1756
+ const skippedFields = [];
1757
+ const skipWrapped = (field, before, after) => {
1758
+ const continuation = (0, state_document_cjs_1.editedLineContinuation)(before, after);
1759
+ if (continuation === null)
1760
+ return false;
1761
+ skippedFields.push({ field, reason: 'wrapped_value', continuation: boundedContinuation(continuation) });
1762
+ return true;
1763
+ };
1631
1764
  readModifyWriteStateMd(statePath, (content) => {
1765
+ // #4998 review (trek-e 5608265301, Major 1): every field written here is read
1766
+ // from the session section when there is one (`## Session`, else `## Session
1767
+ // Continuity` — matchSessionSection, state-snapshot, the frontmatter sync), so
1768
+ // it is written there too. A line outside that section is not the field: a
1769
+ // write that landed on it reported success while the synced value stayed
1770
+ // stale. With no session section only `Stopped At` is read from the whole
1771
+ // body (sessionFieldScope); `state-snapshot` reads Last session and Resume
1772
+ // file from a session section alone, so with none they are not written in
1773
+ // place but in the section appended below, as `state update`/`patch` refuse
1774
+ // them (#4998 review, Minor 3). Located on the masked text (same offsets).
1775
+ const readBodyWide = (fieldName) => fieldName.toLowerCase() === 'stopped at';
1776
+ const sessionSection = () => {
1777
+ const masked = (0, state_document_cjs_1.maskFencedLines)(content, { document: true });
1778
+ return (0, markdown_sectionizer_cjs_1.collectSection)(masked, state_document_cjs_1.isSessionHeading, { levelBounded: true })
1779
+ ?? (0, markdown_sectionizer_cjs_1.collectSection)(masked, state_document_cjs_1.isSessionContinuityHeading, { levelBounded: true });
1780
+ };
1781
+ const inSessionScope = (fieldName, write) => {
1782
+ const section = sessionSection();
1783
+ if (!section)
1784
+ return readBodyWide(fieldName) ? (0, state_document_cjs_1.writeOutsideFences)(content, write, { document: true }) : null;
1785
+ const body = (0, state_document_cjs_1.writeOutsideFences)(content.slice(section.bodyStart, section.bodyEnd), write);
1786
+ return body === null ? null : (0, markdown_sectionizer_cjs_1.replaceSection)(content, section, body);
1787
+ };
1788
+ const sessionScopeText = (fieldName) => {
1789
+ const masked = (0, state_document_cjs_1.maskFencedLines)(content, { document: true });
1790
+ const section = sessionSection();
1791
+ if (!section && !readBodyWide(fieldName))
1792
+ return '';
1793
+ return (0, state_document_cjs_1.readableBoldLabels)(section ? masked.slice(section.bodyStart, section.bodyEnd) : masked);
1794
+ };
1632
1795
  // #4763 (1): read the pre-write session record. The capture mirrors the
1633
- // WRITER, not the snapshot reader: stateReplaceField replaces the FIRST
1634
- // case-insensitive label match anywhere in the document, so the capture is
1635
- // document-wide too — scoping it to ## Session would miss an archive-section
1636
- // line the writer actually displaced. Continuation lines join via
1796
+ // WRITER: the first case-insensitive label match in the scope above, so a
1797
+ // line the writer did not displace is not reported as displaced. Continuation lines join via
1637
1798
  // stateFieldContinuation (the sanctioned joiner — stateExtractField alone is
1638
1799
  // first-line-only), so a wrapped multi-line handoff is surfaced whole.
1800
+ // #4998: a line inside a fenced block is an example, not a field — the
1801
+ // writers below skip fences (`writeOutsideFences`), so the capture reads
1802
+ // the same unfenced text they write.
1803
+ // A `**Label**:` line the writer can replace is read too (readableBoldLabels).
1639
1804
  const capturePrior = (fieldName) => {
1640
- const first = (0, state_document_cjs_1.stateExtractField)(content, fieldName);
1805
+ const visible = sessionScopeText(fieldName);
1806
+ const first = (0, state_document_cjs_1.stateExtractField)(visible, fieldName);
1641
1807
  if (first === null)
1642
1808
  return undefined;
1643
- const cont = (0, state_document_cjs_1.stateFieldContinuation)(content, fieldName);
1809
+ const cont = (0, state_document_cjs_1.stateFieldContinuation)(visible, fieldName);
1644
1810
  return cont ? `${first}\n${cont}` : first;
1645
1811
  };
1646
- priorRecord.stoppedAt = capturePrior('Stopped At');
1812
+ // #4998: with no body line, the frontmatter `stopped_at` is the only copy of
1813
+ // the record, and the sync re-derives it from the line inserted below — a
1814
+ // displacement like any other, so it is the prior record too.
1815
+ const fmStoppedAt = extractFrontmatter(content)['stopped_at'];
1816
+ priorRecord.stoppedAt = capturePrior('Stopped At')
1817
+ ?? (typeof fmStoppedAt === 'string' && fmStoppedAt.trim() ? fmStoppedAt.trim() : undefined);
1647
1818
  priorRecord.resumeFile = capturePrior('Resume File');
1819
+ // #4998: every in-place write skips fenced blocks, as the insertion
1820
+ // below does — a fenced example line is never the occurrence replaced.
1821
+ const replaceField = (fieldName, value) => inSessionScope(fieldName, (visible) => (0, state_document_cjs_1.stateReplaceField)(visible, fieldName, value));
1822
+ // The template check reads the field the way the writer finds it: an
1823
+ // authored `**Resume file**: plan.md` read as absent and was reset to None.
1824
+ const replaceFieldIfTemplate = (fieldName, defaults, value) => {
1825
+ const current = (0, state_document_cjs_1.stateExtractField)(sessionScopeText(fieldName), fieldName);
1826
+ if (current !== null && current.trim() !== ''
1827
+ && !defaults.some((d) => d.toLowerCase() === current.trim().toLowerCase()))
1828
+ return content;
1829
+ return inSessionScope(fieldName, (visible) => (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(visible, fieldName, defaults, value)) ?? content;
1830
+ };
1648
1831
  // Update Last session / Last Date
1649
- let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
1650
- if (result) {
1832
+ let result = replaceField('Last session', now);
1833
+ if (result && !skipWrapped('Last session', content, result)) {
1651
1834
  content = result;
1652
1835
  updated.push('Last session');
1653
1836
  }
1654
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Date', now);
1655
- if (result) {
1837
+ result = replaceField('Last Date', now);
1838
+ if (result && !skipWrapped('Last Date', content, result)) {
1656
1839
  content = result;
1657
1840
  updated.push('Last Date');
1658
1841
  }
@@ -1667,13 +1850,13 @@ function cmdStateRecordSession(cwd, options, raw) {
1667
1850
  // section rewrite would reset an executor-authored resume file to None).
1668
1851
  let stoppedAtMatched = false;
1669
1852
  if (options.stopped_at) {
1670
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped At', options.stopped_at);
1853
+ result = replaceField('Stopped At', options.stopped_at);
1671
1854
  if (!result)
1672
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped at', options.stopped_at);
1855
+ result = replaceField('Stopped at', options.stopped_at);
1673
1856
  if (result) {
1674
1857
  stoppedAtMatched = true;
1675
- if (result !== content) {
1676
- content = result;
1858
+ if (result !== content && !skipWrapped('Stopped At', content, result)) {
1859
+ content = (0, state_document_cjs_1.readableEditedBoldLabel)(content, result);
1677
1860
  updated.push('Stopped At');
1678
1861
  }
1679
1862
  }
@@ -1685,30 +1868,25 @@ function cmdStateRecordSession(cwd, options, raw) {
1685
1868
  const resumeFileDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Resume File'];
1686
1869
  if (options.resume_file !== undefined && options.resume_file !== null) {
1687
1870
  // Caller explicitly passed a value — always honour it.
1688
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Resume File', options.resume_file);
1871
+ result = replaceField('Resume File', options.resume_file);
1689
1872
  if (!result)
1690
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Resume file', options.resume_file);
1691
- if (result) {
1692
- content = result;
1873
+ result = replaceField('Resume file', options.resume_file);
1874
+ if (result && !skipWrapped('Resume File', content, result)) {
1875
+ content = (0, state_document_cjs_1.readableEditedBoldLabel)(content, result);
1693
1876
  updated.push('Resume File');
1694
1877
  }
1695
1878
  }
1696
1879
  else {
1697
1880
  // No explicit value — only set 'None' when existing value is also a known default
1698
1881
  // (i.e. not executor-authored).
1699
- const newRf = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Resume File', resumeFileDefaults, 'None');
1700
- if (newRf !== content) {
1882
+ let newRf = replaceFieldIfTemplate('Resume File', resumeFileDefaults, 'None');
1883
+ // Try alternate capitalisation
1884
+ if (newRf === content)
1885
+ newRf = replaceFieldIfTemplate('Resume file', resumeFileDefaults, 'None');
1886
+ if (newRf !== content && !skipWrapped('Resume File', content, newRf)) {
1701
1887
  content = newRf;
1702
1888
  updated.push('Resume File');
1703
1889
  }
1704
- else {
1705
- // Try alternate capitalisation
1706
- const newRfAlt = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Resume file', resumeFileDefaults, 'None');
1707
- if (newRfAlt !== content) {
1708
- content = newRfAlt;
1709
- updated.push('Resume File');
1710
- }
1711
- }
1712
1890
  }
1713
1891
  // Bug #944: DWIM normalize/auto-create — when the caller supplied --stopped-at or
1714
1892
  // --resume-file but the body lacks the canonical labels (in-place replace
@@ -1735,8 +1913,12 @@ function cmdStateRecordSession(cwd, options, raw) {
1735
1913
  // identical value is already persisted on disk and must not trigger the
1736
1914
  // insertion rewrite below.
1737
1915
  const needsStoppedAt = options.stopped_at && !stoppedAtMatched;
1738
- const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
1739
- const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date');
1916
+ // #4998: a field skipped as wrapped is present, not missing.
1917
+ const skipped = (field) => skippedFields.some((s) => s.field === field);
1918
+ const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null
1919
+ && !updated.includes('Resume File') && !skipped('Resume File');
1920
+ const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date')
1921
+ && !skipped('Last session') && !skipped('Last Date');
1740
1922
  if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
1741
1923
  const resumeValue = (options.resume_file !== undefined && options.resume_file !== null)
1742
1924
  ? options.resume_file
@@ -1747,92 +1929,143 @@ function cmdStateRecordSession(cwd, options, raw) {
1747
1929
  // (workstream.cts, gsd2-import.cts, templates/state.md) instead emit
1748
1930
  // `## Session Continuity`. Treat each separately so we never append a
1749
1931
  // duplicate section alongside an existing one.
1750
- const existingCanonicalSession = /^## Session[ \t]*$/im.test(content);
1751
- const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content);
1932
+ // #4998: a heading inside a fenced example is not a section. Detected
1933
+ // with the locator the writers below and the readers use (#4998 review).
1934
+ const headingScan = (0, state_document_cjs_1.maskFencedLines)(content, { document: true });
1935
+ const existingCanonicalSession = (0, markdown_sectionizer_cjs_1.collectSection)(headingScan, state_document_cjs_1.isSessionHeading, { levelBounded: true }) !== null;
1936
+ const existingSessionContinuity = (0, markdown_sectionizer_cjs_1.collectSection)(headingScan, state_document_cjs_1.isSessionContinuityHeading, { levelBounded: true }) !== null;
1752
1937
  // Track whether the chosen branch's rewrite actually matched. The detector
1753
- // regexes (existingCanonicalSession/existingSessionContinuity) are CRLF-
1754
- // tolerant ($ under /m treats \r as a line terminator); the writer regexes
1755
- // below must be too. If a writer regex silently fails to match (line-ending
1938
+ // and the writer share one locator (CRLF-tolerant: the tokenizer strips
1939
+ // a trailing \r). If the writer still fails to match (line-ending
1756
1940
  // mismatch, unexpected heading shape, ...), do NOT report success — the
1757
1941
  // caller would believe fields were persisted that were silently dropped
1758
1942
  // (#2450). The append branch always sets rewriteMatched=true (it always
1759
1943
  // mutates content).
1760
1944
  let rewriteMatched = false;
1945
+ // `created` means a session section was added, as in record-metric and
1946
+ // add-decision — not a field inserted into an existing one (#4998 review).
1947
+ let sectionAppended = false;
1948
+ // #4998: insert each missing field into `region`,
1949
+ // in the section's own spelling and in template order (Last session →
1950
+ // Stopped at → Resume file), beside the sibling it follows — a bold
1951
+ // `**Stopped at:**` above plain `Last session:` lines is a line the
1952
+ // template does not define. With no sibling present it goes right after
1953
+ // the heading in bold, the pre-#4998 shape. Every existing line is kept.
1954
+ // A heading at EOF with no line terminator gets one first, so its body
1955
+ // exists to insert into rather than a field being glued onto it.
1956
+ // The section is located on the masked text (same offsets): a fenced
1957
+ // `## Session` example is not the section, and a ``` in a frontmatter
1958
+ // block scalar does not hide the real one (Codex review round 2).
1959
+ const insertIntoSection = (isTarget) => {
1960
+ let section = (0, markdown_sectionizer_cjs_1.collectSection)((0, state_document_cjs_1.maskFencedLines)(content, { document: true }), isTarget, { levelBounded: true });
1961
+ if (section && content[section.bodyStart - 1] !== '\n' && section.bodyStart >= content.length) {
1962
+ content += content.includes('\r\n') ? '\r\n' : '\n';
1963
+ section = (0, markdown_sectionizer_cjs_1.collectSection)((0, state_document_cjs_1.maskFencedLines)(content, { document: true }), isTarget, { levelBounded: true });
1964
+ }
1965
+ if (!section || content[section.bodyStart - 1] !== '\n')
1966
+ return;
1967
+ const body = (0, state_document_cjs_1.insertMissingSessionFields)(content.slice(section.bodyStart, section.bodyEnd), [
1968
+ { label: 'Last session', needed: needsLastSession, value: now },
1969
+ { label: 'Stopped at', needed: !!needsStoppedAt, value: stoppedAtValue },
1970
+ { label: 'Resume file', needed: needsResumeFile, value: resumeValue },
1971
+ ]);
1972
+ content = (0, markdown_sectionizer_cjs_1.replaceSection)(content, section, body);
1973
+ rewriteMatched = true;
1974
+ };
1761
1975
  if (existingCanonicalSession) {
1762
- // Normalize in place: replace the ENTIRE BODY of the existing ## Session
1763
- // section (heading + all content up to the next ## heading or EOF) with
1764
- // canonical bold-label lines. The negative-lookahead per-line pattern
1765
- // `(?!^## )[\s\S]` consumes every line that doesn't start with "## ",
1766
- // which correctly stops at the next section boundary without consuming it.
1767
- // A trailing blank line is added so the next ## heading keeps its spacing.
1768
- //
1769
- // CRLF-tolerant (`\r?\n` after `[ \t]*`): the prior literal `\n` could not
1770
- // match a CRLF STATE.md (`---\r\n`), silently no-op'ing the replace while
1771
- // updated.push(...) reported success — #2450. The detector regex on the
1772
- // line above (`/^## Session[ \t]*$/im`) was already CRLF-tolerant, so the
1773
- // asymmetry armed the bug.
1774
- const canonicalReplacement = [
1775
- '## Session',
1776
- '',
1777
- `**Last session:** ${now}`,
1778
- `**Stopped at:** ${stoppedAtValue}`,
1779
- `**Resume file:** ${resumeValue}`,
1780
- '',
1781
- '',
1782
- ].join('\n');
1783
- content = content.replace(/^(## Session[ \t]*\r?\n(?:(?!^## )[\s\S])*)/m, () => {
1784
- rewriteMatched = true;
1785
- return canonicalReplacement;
1786
- });
1976
+ // #4998: insert the missing fields in place. This used to replace the
1977
+ // whole section with three canonical lines, which deleted any wrapped
1978
+ // value or note in it — and reset an authored Resume file to None.
1979
+ insertIntoSection(state_document_cjs_1.isSessionHeading);
1787
1980
  }
1788
1981
  else if (existingSessionContinuity) {
1789
1982
  // #1101: a `## Session Continuity` section already exists (bootstrap
1790
1983
  // shape). Previously this fell through to the append branch and created
1791
1984
  // a SECOND `## Session` block — a duplicate. Instead, insert only the
1792
- // canonical fields that are still missing, right after the heading,
1985
+ // canonical fields that are still missing (#4998: beside their siblings),
1793
1986
  // preserving the `## Session Continuity` heading and ALL existing lines
1794
1987
  // (e.g. prose like "Next recommended action"). Fields already updated in
1795
1988
  // place above (needs* false) are not re-inserted. A function replacement
1796
1989
  // is used so `$`-bearing caller values are inserted literally (#3454).
1797
1990
  //
1798
- // CRLF-tolerant (`\r?\n`): same #2450 fix as the canonical branch above.
1799
- const linesToInsert = [];
1800
- if (needsLastSession)
1801
- linesToInsert.push(`**Last session:** ${now}`);
1802
- if (needsStoppedAt)
1803
- linesToInsert.push(`**Stopped at:** ${stoppedAtValue}`);
1804
- if (needsResumeFile)
1805
- linesToInsert.push(`**Resume file:** ${resumeValue}`);
1806
- if (linesToInsert.length > 0) {
1807
- // Case-insensitive to match the `existingSessionContinuity` detection
1808
- // above (#1101 review F3) — otherwise a lowercase heading would detect
1809
- // but no-op the insert while still reporting the fields as updated.
1810
- content = content.replace(/^(## Session Continuity[ \t]*\r?\n)/im, (_m, heading) => {
1811
- rewriteMatched = true;
1812
- return heading + linesToInsert.join('\n') + '\n';
1813
- });
1814
- }
1815
- // No `else` branch: if linesToInsert.length === 0 the outer guard at
1816
- // :1144 (callerSuppliedValues && (needsStoppedAt || needsResumeFile
1817
- // || needsLastSession)) could not have fired, so this whole block is
1818
- // unreachable. Leaving `rewriteMatched = false` here is the fail-loud
1819
- // posture — a future change to the outer guard or needs* computation
1820
- // that makes this branch reachable will surface as a missing
1821
- // updated[] entry (silent recorded:false) rather than re-arming #2450.
1991
+ // Heading match is case-insensitive and CRLF-tolerant like the
1992
+ // `existingSessionContinuity` detection above (#1101 review F3, #2450):
1993
+ // a detected heading the writer then misses would report fields as
1994
+ // updated that were never written (`rewriteMatched` stays false).
1995
+ insertIntoSection(state_document_cjs_1.isSessionContinuityHeading);
1822
1996
  }
1823
1997
  else {
1824
1998
  // No session heading exists at all — append a new canonical section.
1825
- const scaffold = [
1826
- '',
1827
- '## Session',
1828
- '',
1829
- `**Last session:** ${now}`,
1830
- `**Stopped at:** ${stoppedAtValue}`,
1831
- `**Resume file:** ${resumeValue}`,
1832
- '',
1833
- ].join('\n');
1834
- content = content.trimEnd() + '\n' + scaffold;
1835
- rewriteMatched = true;
1999
+ // #4998: the appended section becomes the one the sync reads, so a
2000
+ // field this call is not setting carries the value the sync reads
2001
+ // today (a body line, a wrapped field's first line, a table row)
2002
+ // rather than None or nothing — which dropped the frontmatter
2003
+ // `stopped_at` and reset an authored Resume file. Later writes then
2004
+ // land on these bold lines first. A field skipped above as wrapped
2005
+ // carries its existing value too — Last session included — so a
2006
+ // skipped field never comes back carrying the new value.
2007
+ // Read from the body, as the sync does — never from frontmatter, and
2008
+ // never from a fenced example.
2009
+ const syncBody = (0, state_document_cjs_1.readableBoldLabels)((0, state_document_cjs_1.maskFencedLines)(stripFrontmatter(content)));
2010
+ const carried = (fields, fallback) => {
2011
+ for (const field of fields) {
2012
+ const value = (0, state_document_cjs_1.stateExtractField)(syncBody, field);
2013
+ if (value !== null)
2014
+ return value;
2015
+ }
2016
+ return fallback;
2017
+ };
2018
+ const lastSessionValue = skipped('Last session') || skipped('Last Date')
2019
+ ? carried(['Last session', 'Last Date'], now)
2020
+ : now;
2021
+ const scaffoldFields = [
2022
+ ['Last session', lastSessionValue],
2023
+ ['Stopped at', needsStoppedAt ? stoppedAtValue : carried(['Stopped At'], stoppedAtValue)],
2024
+ ['Resume file', needsResumeFile ? resumeValue : carried(['Resume File'], resumeValue)],
2025
+ ];
2026
+ // A document that ends inside an unclosed fence would swallow the
2027
+ // appended section — nothing reads it there. Write nothing, and say so.
2028
+ // The whole document is scanned on purpose: whether its tail is fenced
2029
+ // depends on every delimiter before it (frontmatter excluded: YAML, not
2030
+ // Markdown), and `scanFencedBlocks` reports
2031
+ // an unclosed block only as its LAST record (the fence runs to EOF), so
2032
+ // `closeLineIdx === -1` here means exactly "the tail is inside a fence".
2033
+ if ((0, state_document_cjs_1.markdownFencedBlocks)(content.split('\n'), { document: true }).some((b) => b.closeLineIdx === -1)) {
2034
+ // Named only for a field this call writes (Last session always, the
2035
+ // others when passed) and not already written in place or skipped
2036
+ // for another reason (Codex review; trek-e review 5608265301, Minor 3).
2037
+ const fieldKey = (f) => (f === 'Last Date' ? 'last session' : f.toLowerCase());
2038
+ const requested = new Set(['last session']);
2039
+ if (options.stopped_at)
2040
+ requested.add('stopped at');
2041
+ if (options.resume_file !== undefined && options.resume_file !== null)
2042
+ requested.add('resume file');
2043
+ const settled = new Set([...updated, ...skippedFields.map((s) => s.field)].map(fieldKey));
2044
+ // A Stopped at already holding the value needs no write (Codex review).
2045
+ if (stoppedAtMatched)
2046
+ settled.add('stopped at');
2047
+ for (const [label] of scaffoldFields) {
2048
+ const key = fieldKey(label);
2049
+ if (requested.has(key) && !settled.has(key))
2050
+ skippedFields.push({ field: label, reason: 'unclosed_fence' });
2051
+ }
2052
+ }
2053
+ else {
2054
+ // A carried value holding a line separator (a hand-written table cell
2055
+ // can) would forge a line once written as a bold field: leave that
2056
+ // field out, and say so.
2057
+ const scaffoldLines = [];
2058
+ for (const [label, value] of scaffoldFields) {
2059
+ if (state_document_cjs_1.FIELD_LINE_BREAK_RE.test(value))
2060
+ skippedFields.push({ field: label, reason: 'line_separator' });
2061
+ else
2062
+ scaffoldLines.push(`**${label}:** ${value}`);
2063
+ }
2064
+ const scaffold = ['', '## Session', '', ...scaffoldLines, ''].join('\n');
2065
+ content = content.trimEnd() + '\n' + scaffold;
2066
+ rewriteMatched = true;
2067
+ sectionAppended = true;
2068
+ }
1836
2069
  }
1837
2070
  // #2450 defensive invariant: only report sessionCreated/updated when the
1838
2071
  // chosen branch's rewrite actually mutated content. Unreachable when the
@@ -1848,7 +2081,7 @@ function cmdStateRecordSession(cwd, options, raw) {
1848
2081
  // same-line replace (CRLF-agnostic seam), so unconditional push is
1849
2082
  // correct. The class-defect防御 here is for the INSERT path only.
1850
2083
  if (rewriteMatched) {
1851
- sessionCreated = true;
2084
+ sessionCreated = sectionAppended;
1852
2085
  if (needsLastSession)
1853
2086
  updated.push('Last session');
1854
2087
  if (needsStoppedAt)
@@ -1863,22 +2096,48 @@ function cmdStateRecordSession(cwd, options, raw) {
1863
2096
  // bytes actually persisted (fix(#3351) generalized) and fold in any field
1864
2097
  // preservation restored that this transform never touched (#3345's
1865
2098
  // direction).
1866
- const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields);
2099
+ // #4998 review: read back where the fields were written — the session section.
2100
+ const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields, NO_SECTION_TARGETS, true);
2101
+ // #4998: name every field left whole because its value wraps, and how to fix it.
2102
+ const skippedRows = skippedFields;
2103
+ const skippedDisclosure = skippedRows.map(({ field, reason, continuation }) => (reason === 'wrapped_value'
2104
+ ? `state record-session left ${field} unchanged — its value continues onto the next line ` +
2105
+ `(${formatDiagnosticToken(continuation ?? '')}), and a single-line write would leave that line behind. ` +
2106
+ 'Fold the value onto one line, or put a blank line before that line if it is a separate note, then re-run.'
2107
+ : reason === 'line_separator'
2108
+ ? `state record-session left ${field} out of the new ## Session section — its existing value holds a ` +
2109
+ 'line separator (U+2028/U+2029), which would split the line it is written on. Remove the separator, then re-run.'
2110
+ : `state record-session did not write ${field} — STATE.md has no session section and ends inside an unclosed ` +
2111
+ 'code fence, which would swallow a new ## Session section. Close the fence, then re-run.'));
1867
2112
  if (reconciledUpdated.length > 0) {
1868
2113
  const result = { recorded: true, updated: reconciledUpdated };
1869
2114
  if (sessionCreated)
1870
2115
  result['created'] = true;
2116
+ if (skippedRows.length > 0) {
2117
+ result['skipped'] = skippedRows;
2118
+ for (const line of skippedDisclosure)
2119
+ process.stderr.write(`[gsd-tools] WARNING: ${line}\n`);
2120
+ }
1871
2121
  // #4763 (1): surface any non-empty prior record the write displaced. Gated
1872
2122
  // on reconciledUpdated (the fields that actually persisted, post-#3957
1873
2123
  // reconcile) rather than the pre-reconciliation updated[]. Only fields with
1874
2124
  // real prior content differing from the caller's value count — same-value
1875
- // rewrites, the insert path (no prior label), and the #944 template-default
1876
- // DWIM (defaults match case-insensitively, so a case-variant 'none' →
1877
- // 'None' rewrite is normalization, not displacement) are excluded.
1878
- const isResumeTemplateDefault = priorRecord.resumeFile !== undefined
1879
- && state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Resume File'].some((d) => d.toLowerCase() === priorRecord.resumeFile?.toLowerCase());
2125
+ // rewrites, the insert path with no prior record (no body label and, #4998,
2126
+ // no frontmatter `stopped_at`), and the #944 template-default DWIM
2127
+ // (defaults match case-insensitively, so a case-variant 'none' → 'None'
2128
+ // rewrite is normalization, not displacement) are excluded.
2129
+ // #4998: `None` is the same placeholder for Stopped At (the bootstrap
2130
+ // writes it there too, body or frontmatter) — no record was displaced. Nor
2131
+ // is the shipped template's whole-value `[...]` placeholder
2132
+ // (`[Description of last completed action]`; #4998 review, Nit 7).
2133
+ const isPlaceholder = (value) => value !== undefined
2134
+ && (state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Resume File'].some((d) => d.toLowerCase() === value.toLowerCase())
2135
+ || /^\[[^\]\n]*\]$/u.test(value.trim()));
2136
+ const isResumeTemplateDefault = isPlaceholder(priorRecord.resumeFile);
2137
+ const isStoppedAtPlaceholder = isPlaceholder(priorRecord.stoppedAt);
1880
2138
  const replacedRecord = {};
1881
2139
  if (reconciledUpdated.includes('Stopped At') && priorRecord.stoppedAt
2140
+ && !isStoppedAtPlaceholder
1882
2141
  && priorRecord.stoppedAt !== options.stopped_at) {
1883
2142
  replacedRecord['Stopped At'] = priorRecord.stoppedAt;
1884
2143
  }
@@ -1891,6 +2150,10 @@ function cmdStateRecordSession(cwd, options, raw) {
1891
2150
  result['replacedRecord'] = replacedRecord;
1892
2151
  output(result, raw, 'true');
1893
2152
  }
2153
+ else if (skippedRows.length > 0) {
2154
+ // #4998: every field the call would have changed wraps — nothing written.
2155
+ declineNoOp(raw, 'recorded', 'every session field to update was left unchanged — see skipped', skippedDisclosure.join('\n[gsd-tools] WARNING: '), { skipped: skippedRows });
2156
+ }
1894
2157
  else if (updated.length === 0) {
1895
2158
  // Nothing was ever attempted — no --stopped-at/--resume-file supplied
1896
2159
  // and no existing Last session/Last Date/Stopped At/Resume File labels
@@ -1924,12 +2187,20 @@ function cmdStateRecordSession(cwd, options, raw) {
1924
2187
  * Returns the section body, or null.
1925
2188
  */
1926
2189
  function matchSessionSection(body) {
1927
- const isSession = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'session';
1928
- const isSessionContinuity = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'session continuity';
1929
- const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, isSession, { levelBounded: true })
1930
- ?? (0, markdown_sectionizer_cjs_1.collectSection)(body, isSessionContinuity, { levelBounded: true });
2190
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, state_document_cjs_1.isSessionHeading, { levelBounded: true })
2191
+ ?? (0, markdown_sectionizer_cjs_1.collectSection)(body, state_document_cjs_1.isSessionContinuityHeading, { levelBounded: true });
1931
2192
  return section ? section.body : null;
1932
2193
  }
2194
+ /**
2195
+ * #4998: where the session fields (Stopped At, Paused At) are read from — the
2196
+ * session section, else the whole body — with fenced blocks blanked, because
2197
+ * `state record-session` writes them outside fences (`writeOutsideFences`).
2198
+ * A reader that still took a fenced example would sync it into frontmatter
2199
+ * over the field the writer just wrote.
2200
+ */
2201
+ function sessionFieldScope(body) {
2202
+ return (0, state_document_cjs_1.maskFencedLines)(matchSessionSection(body) ?? body);
2203
+ }
1933
2204
  /**
1934
2205
  * Match the "Current Position" section body from a STATE.md body. #2956: this
1935
2206
  * is the Phase analogue of matchSessionSection. `Phase` canonically lives under
@@ -1995,26 +2266,27 @@ function preferNewerLastActivity(existingFm, derivedFm) {
1995
2266
  derivedFm['last_activity'] = exRaw;
1996
2267
  }
1997
2268
  }
1998
- function parseProsePhaseField(value) {
2269
+ function parseProsePhaseField(value, convention, directoryContext) {
1999
2270
  // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this
2000
2271
  // module holds no independent prose phase-id regex. Drives #2111 — the
2001
2272
  // anchored parser returns { phase: null } for a "Milestone vX.Y complete"
2002
2273
  // body line (the old unanchored regex mined the minor-version digit, e.g.
2003
2274
  // v0.5 -> "5"), so syncStateFrontmatter's #905 guard preserves the real
2004
2275
  // current_phase instead of clobbering it.
2005
- return parsePhaseFromProse(value);
2276
+ // #5626: under `milestone-prefixed` the id is the whole `M-NN`; the convention comes from its owner.
2277
+ return parsePhaseFromProse(value, convention, directoryContext);
2006
2278
  }
2007
- function resolveStatePhase(fm, body) {
2279
+ function resolveStatePhase(fm, body, convention, directoryContext) {
2008
2280
  const currentPositionScope = matchCurrentPositionSection(body) ?? body;
2009
2281
  const frontmatterRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', null).value;
2010
2282
  const legacyRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase').value;
2011
2283
  const currentPositionRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Phase').value;
2012
2284
  const sources = {
2013
- frontmatter: parseProsePhaseField(frontmatterRaw).phase,
2014
- legacy_current_phase: parseProsePhaseField(legacyRaw).phase,
2015
- current_position_phase: parseProsePhaseField(currentPositionRaw).phase,
2285
+ frontmatter: parseProsePhaseField(frontmatterRaw, convention, directoryContext).phase,
2286
+ legacy_current_phase: parseProsePhaseField(legacyRaw, convention, directoryContext).phase,
2287
+ current_position_phase: parseProsePhaseField(currentPositionRaw, convention, directoryContext).phase,
2016
2288
  };
2017
- const prosePhase = parseProsePhaseField(currentPositionRaw);
2289
+ const prosePhase = parseProsePhaseField(currentPositionRaw, convention, directoryContext);
2018
2290
  return {
2019
2291
  phase: sources.frontmatter ?? sources.legacy_current_phase ?? sources.current_position_phase,
2020
2292
  name: (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase_name', null).value
@@ -2069,9 +2341,9 @@ function resolveStatePhase(fm, body) {
2069
2341
  * Reconcile the two only by giving `resolveStatePhase` an explicit scope
2070
2342
  * parameter — never by pointing this at it and dropping the difference.
2071
2343
  */
2072
- function resolveCurrentPhaseId(fm, body) {
2344
+ function resolveCurrentPhaseId(fm, body, convention, directoryContext) {
2073
2345
  const positionSection = sliceCurrentPositionSection(body);
2074
- const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value).phase : null;
2346
+ const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value, convention, directoryContext).phase : null;
2075
2347
  return (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ?? prosePhase;
2076
2348
  }
2077
2349
  function parseProseLastActivityField(value) {
@@ -2109,7 +2381,8 @@ function cmdStateSnapshot(cwd, raw) {
2109
2381
  // so it is scopeable exactly like Stopped At under ## Session. Fall back to
2110
2382
  // full-body search only when no ## Current Position section exists, so files
2111
2383
  // with no section heading keep their current behaviour.
2112
- const resolvedPhase = resolveStatePhase(fm, body);
2384
+ const directoryContext = phaseDirectoryOptions(cwd);
2385
+ const resolvedPhase = resolveStatePhase(fm, body, resolvePhaseIdConvention(cwd), directoryContext);
2113
2386
  const currentPhase = resolvedPhase.phase;
2114
2387
  const currentPhaseName = resolvedPhase.name;
2115
2388
  const totalPhasesRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_phases', 'Total Phases').value;
@@ -2125,7 +2398,7 @@ function cmdStateSnapshot(cwd, raw) {
2125
2398
  // preferNewerLastActivity and the write seam in buildStateFrontmatter). The
2126
2399
  // write seam already scopes it to ## Session; this read seam must agree, so a
2127
2400
  // stale "Paused At:" in a Session Continuity Archive cannot win here either.
2128
- const sessionScope = matchSessionSection(body) ?? body;
2401
+ const sessionScope = sessionFieldScope(body);
2129
2402
  const pausedAt = (0, state_document_cjs_1.stateFieldValue)(fm, sessionScope, 'paused_at', 'Paused At').value;
2130
2403
  // Parse numeric fields
2131
2404
  const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
@@ -2168,7 +2441,8 @@ function cmdStateSnapshot(cwd, raw) {
2168
2441
  // `## Session Continuity` heading. See matchSessionSection for the anchoring.
2169
2442
  const sessionMatch = matchSessionSection(body);
2170
2443
  if (sessionMatch !== null) {
2171
- const sessionSection = sessionMatch;
2444
+ // #4998: fenced example lines are not fields (as for sessionFieldScope).
2445
+ const sessionSection = (0, state_document_cjs_1.maskFencedLines)(sessionMatch);
2172
2446
  // Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
2173
2447
  // (the form written by the DWIM auto-create / normalize path added for #944).
2174
2448
  const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
@@ -2279,7 +2553,7 @@ function isMilestoneBounded(roadmapRaw, milestone, convention) {
2279
2553
  * decimal, and project-code IDs are detected alike. Returns canonical keys
2280
2554
  * (see phaseKeyFromToken).
2281
2555
  */
2282
- function extractRetiredPhaseNumbers(scope, convention) {
2556
+ function extractRetiredPhaseNumbers(scope, convention, directoryContext) {
2283
2557
  const retired = new Set();
2284
2558
  const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
2285
2559
  // #612: the retirement filter has to widen with the counter it protects. The
@@ -2305,8 +2579,8 @@ function extractRetiredPhaseNumbers(scope, convention) {
2305
2579
  while ((s = strikeSpan.exec(line)) !== null) {
2306
2580
  const phaseRef = phaseRefRe.exec(s[1]);
2307
2581
  // Require a digit so struck prose like ~~Phase Overview~~ is ignored.
2308
- if (phaseRef && /\d/.test(phaseRef[1]))
2309
- retired.add(phaseKeyFromToken(phaseRef[1]));
2582
+ if (phaseRef && (directoryContext?.phaseNaming === 'custom' || /\d/.test(phaseRef[1])))
2583
+ retired.add(phaseKeyFromToken(phaseRef[1], directoryContext));
2310
2584
  }
2311
2585
  }
2312
2586
  return retired;
@@ -2350,7 +2624,7 @@ function extractRetiredPhaseNumbers(scope, convention) {
2350
2624
  * retain the bracket-id and bare-0 rules. Sharing the implementation therefore
2351
2625
  * cannot silently move either convention's total.
2352
2626
  */
2353
- function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules) {
2627
+ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules, directoryContext) {
2354
2628
  let count = 0;
2355
2629
  if (convention === 'bracket') {
2356
2630
  const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true);
@@ -2381,7 +2655,7 @@ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyCon
2381
2655
  continue;
2382
2656
  // #1514: retired/folded phases are struck through in the ROADMAP;
2383
2657
  // exclude them from the denominator (they can never be completed).
2384
- if (retiredPhaseNums.has(phaseKeyFromToken(token)))
2658
+ if (retiredPhaseNums.has(phaseKeyFromToken(token, directoryContext)))
2385
2659
  continue;
2386
2660
  count++;
2387
2661
  }
@@ -2397,7 +2671,7 @@ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyCon
2397
2671
  continue;
2398
2672
  if (applyConventionTokenSentinelRules && isSentinelPhaseId(token))
2399
2673
  continue;
2400
- if (retiredPhaseNums.has(phaseKeyFromToken(token)))
2674
+ if (retiredPhaseNums.has(phaseKeyFromToken(token, directoryContext)))
2401
2675
  continue;
2402
2676
  count++;
2403
2677
  }
@@ -2425,12 +2699,14 @@ options = {}) {
2425
2699
  let roadmapScope = null;
2426
2700
  let roadmapRaw = null;
2427
2701
  let retiredPhaseNums = new Set();
2702
+ let directoryContext = phaseDirectoryOptions(cwd, undefined, '');
2428
2703
  try {
2429
2704
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
2430
2705
  roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
2431
2706
  if (roadmapRaw !== null) {
2432
2707
  roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
2433
- retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention);
2708
+ directoryContext = { ...directoryContext, roadmap: roadmapRaw };
2709
+ retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention, directoryContext);
2434
2710
  }
2435
2711
  }
2436
2712
  catch { /* fall through: no roadmap scope → no retired exclusion */ }
@@ -2457,7 +2733,7 @@ options = {}) {
2457
2733
  // artifact; drop it from the disk phase set so it counts toward
2458
2734
  // neither the denominator nor the numerator (mirrors the heading
2459
2735
  // exclusion below). Project-code-aware via phaseKeyFromDir.
2460
- if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
2736
+ if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention, directoryContext)))
2461
2737
  continue;
2462
2738
  // #3185: dedup grouping routed through the canonical phaseKeyFromDir
2463
2739
  // (src/phase-id.cts) instead of a local leading-digits regex that
@@ -2466,7 +2742,7 @@ options = {}) {
2466
2742
  // so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
2467
2743
  // multi-segment milestone dirs. Same key surface used two lines
2468
2744
  // above for the retiredPhaseNums exclusion, so both filters agree.
2469
- const key = phaseKeyFromDir(dir, phaseConvention);
2745
+ const key = phaseKeyFromDir(dir, phaseConvention, directoryContext);
2470
2746
  if (!seenPhaseNums.has(key)) {
2471
2747
  seenPhaseNums.set(key, dir);
2472
2748
  }
@@ -2497,6 +2773,7 @@ options = {}) {
2497
2773
  roadmapScope,
2498
2774
  roadmapRaw,
2499
2775
  retiredPhaseNums,
2776
+ directoryContext,
2500
2777
  };
2501
2778
  }
2502
2779
  /**
@@ -2531,7 +2808,7 @@ milestoneExplicitlyNull) {
2531
2808
  // section overwrites current_phase here, and the next read surfaces it. Fall
2532
2809
  // back to full-body search when no ## Current Position section exists.
2533
2810
  const currentPositionScope = matchCurrentPositionSection(bodyContent) ?? bodyContent;
2534
- const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'));
2811
+ const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'), cwd ? resolvePhaseIdConvention(cwd) : null, cwd ? phaseDirectoryOptions(cwd) : undefined);
2535
2812
  const currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase') ?? prosePhase.phase;
2536
2813
  const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name') ?? prosePhase.name;
2537
2814
  const currentPlan = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Plan');
@@ -2549,8 +2826,7 @@ milestoneExplicitlyNull) {
2549
2826
  // Fall back to full-body search only when no ## Session section exists.
2550
2827
  // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
2551
2828
  // `## Session Continuity` heading. See matchSessionSection for the anchoring.
2552
- const sessionSectionMatch = matchSessionSection(bodyContent);
2553
- const sessionBodyScope = sessionSectionMatch ?? bodyContent;
2829
+ const sessionBodyScope = sessionFieldScope(bodyContent);
2554
2830
  const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
2555
2831
  // #2567: Paused At is a session field — scope it to ## Session too so a
2556
2832
  // stale "Paused At:" line in an archive section cannot overwrite the value.
@@ -2630,7 +2906,7 @@ milestoneExplicitlyNull) {
2630
2906
  // #5118 pre-write validation in `phase remove` reads exactly this set.
2631
2907
  // #5038: an explicit-null `storedMilestone` is text, not a version;
2632
2908
  // pass a real null so it never reaches the phase-dir version filter.
2633
- const { phaseDirs, phaseDirScope, roadmapScope, roadmapRaw, retiredPhaseNums, } = scanStatePhaseDirs(cwd, phasesDir, phaseConvention, milestoneExplicitlyNull ? null : storedMilestone);
2909
+ const { phaseDirs, phaseDirScope, roadmapScope, roadmapRaw, retiredPhaseNums, directoryContext, } = scanStatePhaseDirs(cwd, phasesDir, phaseConvention, milestoneExplicitlyNull ? null : storedMilestone);
2634
2910
  let diskTotalPlans = 0;
2635
2911
  let diskTotalSummaries = 0;
2636
2912
  let diskCompletedPhases = 0;
@@ -2668,7 +2944,7 @@ milestoneExplicitlyNull) {
2668
2944
  // fence-aware bracket strategy with #3185's canonical legacy
2669
2945
  // sentinel predicate for this read-path call.
2670
2946
  const roadmapPhaseCount = roadmapScope !== null
2671
- ? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true)
2947
+ ? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true, directoryContext)
2672
2948
  : 0;
2673
2949
  cached = (() => {
2674
2950
  // #1761 read-path: mirror the cmdStateSync guard (#1794). When the
@@ -2799,8 +3075,14 @@ milestoneExplicitlyNull) {
2799
3075
  // — no second ROADMAP parser here. A ROADMAP without a canonical
2800
3076
  // `## Progress` table resolves no table → floor inert (disk count
2801
3077
  // stands), the owner's own answer to "what is countable".
3078
+ // #5165: the milestone-grouped `## Progress` table keeps every
3079
+ // milestone's rows and sits OUTSIDE any narrower roadmapScope, so the
3080
+ // floor names the milestone STATE asserts (assertedMilestoneVersion —
3081
+ // present on every resync path, unlike the optional storedMilestone
3082
+ // parameter) and the owner counts only that milestone's rows. No row
3083
+ // for it => nulls => the floor is inert and the disk count stands.
2802
3084
  const roadmapCompletedPhases = roadmapScope !== null && safeToUseRoadmapCount && !diskCountsWithheld
2803
- ? deriveProgressFromRoadmap(roadmapScope).completedPhases
3085
+ ? deriveProgressFromRoadmap(roadmapScope, assertedMilestoneVersion ?? storedMilestone).completedPhases
2804
3086
  : null;
2805
3087
  const flooredCompletedPhases = roadmapCompletedPhases !== null
2806
3088
  ? Math.max(diskCompletedPhases, roadmapCompletedPhases)
@@ -3227,6 +3509,8 @@ function applyAuthoritativeProgressSubkeys(fm, intent, opts) {
3227
3509
  for (const [subkey, value] of Object.entries(intent)) {
3228
3510
  if (typeof value !== 'number' || !Number.isFinite(value))
3229
3511
  continue;
3512
+ if (subkey === 'percent')
3513
+ continue; // composed below, never applied raw (#5165)
3230
3514
  if (!getFieldClassification(`progress.${subkey}`))
3231
3515
  continue;
3232
3516
  const isCompletedCounter = subkey === 'completed_phases' || subkey === 'completed_plans';
@@ -3244,10 +3528,18 @@ function applyAuthoritativeProgressSubkeys(fm, intent, opts) {
3244
3528
  }
3245
3529
  // percent: applied only when a completed counter moved (coherent with the
3246
3530
  // counters that just landed) or when no percent exists to contradict.
3531
+ // #5165 / ADR-4629 §8.5: `computeProgressPercent` is the one composition
3532
+ // owner (min(plan fraction, phase fraction)) that every resyncing write uses.
3533
+ // When the block carries the counters it needs, the percent is composed by
3534
+ // that owner over the counters just landed, so a later resyncing write
3535
+ // cannot move what this transition wrote. The intent's own percent (a
3536
+ // phase-only fraction) is only the fallback for a block without plan counts.
3247
3537
  const intentPercent = intent['percent'];
3248
3538
  if (typeof intentPercent === 'number' && Number.isFinite(intentPercent) && (completedMoved || (0, state_document_cjs_1.toFiniteNumber)(base['percent']) === null)) {
3249
- if (!Object.is(base['percent'], intentPercent)) {
3250
- base['percent'] = intentPercent;
3539
+ const composed = (0, state_document_cjs_1.computeProgressPercent)((0, state_document_cjs_1.toFiniteNumber)(base['completed_plans']), (0, state_document_cjs_1.toFiniteNumber)(base['total_plans']), (0, state_document_cjs_1.toFiniteNumber)(base['completed_phases']), (0, state_document_cjs_1.toFiniteNumber)(base['total_phases']), SCOPE.COMPLETE);
3540
+ const nextPercent = composed !== null ? composed : intentPercent;
3541
+ if (!Object.is(base['percent'], nextPercent)) {
3542
+ base['percent'] = nextPercent;
3251
3543
  changed = true;
3252
3544
  }
3253
3545
  }
@@ -3831,7 +4123,10 @@ function assertStatePreservationOptions(options, caller) {
3831
4123
  throw err;
3832
4124
  }
3833
4125
  }
3834
- function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options) {
4126
+ function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options,
4127
+ // #5626: the project's phase-id convention, resolved by the caller from its cwd (null: unset). Trailing and
4128
+ // optional so `options` stays the fifth parameter the options guard and its callers rely on.
4129
+ phaseIdConvention = null, directoryContext) {
3835
4130
  assertStatePreservationOptions(options, 'applyPostSyncPreservation');
3836
4131
  const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField, preWriteState } = options;
3837
4132
  // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
@@ -3867,8 +4162,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3867
4162
  // mirroring buildStateFrontmatter's sessionBodyScope logic.
3868
4163
  // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
3869
4164
  // Archive prose) must not interfere with the delta comparison.
3870
- const preSessionMatch = matchSessionSection(preBody);
3871
- const preSessionScope = preSessionMatch ?? preBody;
4165
+ const preSessionScope = sessionFieldScope(preBody);
3872
4166
  const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
3873
4167
  // ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
3874
4168
  // current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
@@ -3888,7 +4182,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3888
4182
  const preCurrentPositionScope = matchCurrentPositionSection(preBody) ?? preBody;
3889
4183
  const preBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Plan');
3890
4184
  const preBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Phase')
3891
- ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase')).phase;
4185
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase'), phaseIdConvention, directoryContext).phase;
3892
4186
  const preBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Paused At');
3893
4187
  const preBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity')
3894
4188
  ?? (0, state_document_cjs_1.stateExtractField)(preBody, 'Last activity');
@@ -3903,8 +4197,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3903
4197
  const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
3904
4198
  // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
3905
4199
  // consistent with the pre-transform snapshot above and buildStateFrontmatter.
3906
- const postSessionMatch = matchSessionSection(postBody);
3907
- const postSessionScope = postSessionMatch ?? postBody;
4200
+ const postSessionScope = sessionFieldScope(postBody);
3908
4201
  const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
3909
4202
  // ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
3910
4203
  // current_phase_name delta comparison.
@@ -3914,7 +4207,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
3914
4207
  const postCurrentPositionScope = matchCurrentPositionSection(postBody) ?? postBody;
3915
4208
  const postBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Plan');
3916
4209
  const postBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Phase')
3917
- ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase')).phase;
4210
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase'), phaseIdConvention, directoryContext).phase;
3918
4211
  const postBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Paused At');
3919
4212
  const postBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity')
3920
4213
  ?? (0, state_document_cjs_1.stateExtractField)(postBody, 'Last activity');
@@ -4131,7 +4424,7 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
4131
4424
  function syncAndPreserveStateMd(originalContent, transformedContent, statePath, cwd, options) {
4132
4425
  assertStatePreservationOptions(options, 'syncAndPreserveStateMd');
4133
4426
  const synced = syncStateFrontmatter(transformedContent, cwd, options.authoritativeFm);
4134
- return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options);
4427
+ return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options, cwd ? resolvePhaseIdConvention(cwd) : null, cwd ? phaseDirectoryOptions(cwd) : undefined);
4135
4428
  }
4136
4429
  /**
4137
4430
  * Atomic read-modify-write for STATE.md.
@@ -4554,7 +4847,7 @@ function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
4554
4847
  * the markdown-sectionizer seam (ADR-1372), never a hand-rolled regex.
4555
4848
  * Existing callers pass no `sectionTargets` and resolve exactly as before.
4556
4849
  */
4557
- function readStateTarget(fm, body, field, sectionTargets) {
4850
+ function readStateTarget(fm, body, field, sectionTargets, sessionScope = false) {
4558
4851
  if (field === 'Current Position') {
4559
4852
  const section = stateTransitionMod.sliceCurrentPositionSection(body);
4560
4853
  return section !== null ? section.trim() : null;
@@ -4563,7 +4856,14 @@ function readStateTarget(fm, body, field, sectionTargets) {
4563
4856
  const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.text === field);
4564
4857
  return section !== null ? section.body.trim() : null;
4565
4858
  }
4566
- const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
4859
+ // #4998 review: no session writer changes a fenced example line, so a session
4860
+ // field is read with fences blanked — an unchanged example above the live
4861
+ // line hid the write and reported it missing. With `sessionScope` (`state
4862
+ // update`, `state patch` and `state record-session`, which write inside the
4863
+ // session section) it is read there; otherwise from the whole document.
4864
+ const bodyValue = stateTransitionMod.isSessionScopedBodyField(field)
4865
+ ? (0, state_document_cjs_1.stateExtractField)((0, state_document_cjs_1.readableBoldLabels)(sessionScope ? sessionFieldScope(body) : (0, state_document_cjs_1.maskFencedLines)(body)), field)
4866
+ : (0, state_document_cjs_1.stateExtractField)(body, field);
4567
4867
  if (bodyValue !== null)
4568
4868
  return bodyValue;
4569
4869
  if (Object.prototype.hasOwnProperty.call(fm, field))
@@ -4591,7 +4891,7 @@ const NO_SECTION_TARGETS = new Set();
4591
4891
  * `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
4592
4892
  * second, parallel absence convention — one rule, reused, not duplicated.
4593
4893
  */
4594
- function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets) {
4894
+ function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets, sessionScope = false) {
4595
4895
  const isDeclaredDerivedLeaf = field.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, field);
4596
4896
  if (isDeclaredDerivedLeaf && !sectionTargets.has(field)) {
4597
4897
  const before = resolveFrontmatterPath(snapshotFm, field);
@@ -4603,8 +4903,8 @@ function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody
4603
4903
  return false;
4604
4904
  return stateFieldValuesDiffer(before, after);
4605
4905
  }
4606
- const before = readStateTarget(snapshotFm, snapshotBody, field, sectionTargets);
4607
- const after = readStateTarget(persistedFm, persistedBody, field, sectionTargets);
4906
+ const before = readStateTarget(snapshotFm, snapshotBody, field, sectionTargets, sessionScope);
4907
+ const after = readStateTarget(persistedFm, persistedBody, field, sectionTargets, sessionScope);
4608
4908
  if (before === null && after === null)
4609
4909
  return false;
4610
4910
  if (before === null || after === null)
@@ -4615,7 +4915,9 @@ function reconcileReportedFields(statePath, preWriteState, reported, divergedFie
4615
4915
  // ADR-4629 §8.2 / Decision 4 (C2): names in `reported` that are declared
4616
4916
  // `target: 'section'` assertions, measured as whole-section deltas. Omitted
4617
4917
  // by every pre-C2 caller, whose output is unchanged.
4618
- sectionTargets = NO_SECTION_TARGETS) {
4918
+ sectionTargets = NO_SECTION_TARGETS,
4919
+ // #4998 review: the caller writes session fields inside the session section.
4920
+ sessionScope = false) {
4619
4921
  void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
4620
4922
  if (preWriteState.fm === undefined || preWriteState.body === undefined)
4621
4923
  return [];
@@ -4624,7 +4926,7 @@ sectionTargets = NO_SECTION_TARGETS) {
4624
4926
  const persistedBody = stripFrontmatter(persisted);
4625
4927
  const snapshotFm = preWriteState.fm;
4626
4928
  const snapshotBody = preWriteState.body;
4627
- const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets);
4929
+ const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets, sessionScope);
4628
4930
  // Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
4629
4931
  // granularity) whose persisted value differs from the snapshot, minus the
4630
4932
  // provenance exclusion. A frontmatter-diff-discovered field is mapped
@@ -4789,8 +5091,9 @@ function stateSectionScopeUnits(units, sectionTargets) {
4789
5091
  * matching `readStateTarget`'s long-standing special case.
4790
5092
  */
4791
5093
  function verifyStateWriteIntent(intent, preContent, postContent, statePath, bodyDeltas) {
4792
- const pre = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, preContent).content;
4793
- const post = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, postContent).content;
5094
+ // #5165: the write lands scoped to what it changed (platformWriteSync reads the file it replaces).
5095
+ const pre = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, preContent, { prior: preContent }).content;
5096
+ const post = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, postContent, { prior: preContent }).content;
4794
5097
  const preFm = extractFrontmatter(pre, statePath);
4795
5098
  const postFm = extractFrontmatter(post, statePath);
4796
5099
  const preBody = stripFrontmatter(pre);
@@ -4989,7 +5292,7 @@ function applyStateWriteIntent(statePath, intent, transformFn, cwd, options = {}
4989
5292
  return unwritten(verification);
4990
5293
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, candidate);
4991
5294
  const reread = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
4992
- if (reread !== (0, shell_command_projection_cjs_1.normalizeContent)(statePath, candidate).content) {
5295
+ if (reread !== (0, shell_command_projection_cjs_1.normalizeContent)(statePath, candidate, { prior: pre }).content) {
4993
5296
  return Object.freeze({
4994
5297
  ok: false,
4995
5298
  reasons: Object.freeze([STATE_WRITE_INTENT_FAILURE.REREAD_MISMATCH]),
@@ -5049,13 +5352,13 @@ function cmdStateJson(cwd, raw) {
5049
5352
  // change untouched — and `milestone`/`milestone_name`
5050
5353
  // (preserve-if-placeholder) are out of D3's scope entirely.
5051
5354
  if (existingFm) {
5052
- const sessionScope = matchSessionSection(body) ?? body;
5355
+ const sessionScope = sessionFieldScope(body);
5053
5356
  const positionScope = matchCurrentPositionSection(body) ?? body;
5054
5357
  const bodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped at');
5055
5358
  const bodyPausedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
5056
5359
  const bodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(body, 'Phase');
5057
5360
  const bodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase')
5058
- ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
5361
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase'), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)).phase;
5059
5362
  const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
5060
5363
  const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
5061
5364
  // #3836: mirrors applyPostSyncPreservation's own derivation (state.cts
@@ -5116,7 +5419,7 @@ function cmdStateJson(cwd, raw) {
5116
5419
  * phase locator's own rule, so this agrees with `init` and `phase-plan-index`.
5117
5420
  */
5118
5421
  function firstPlanToRun(cwd, phaseNumber) {
5119
- const located = phaseLocatorMod.findPhaseInternal(cwd, String(phaseNumber));
5422
+ const located = phaseLocatorMod.findPhaseInternal(cwd, String(phaseNumber), undefined, phaseDirectoryOptions(cwd));
5120
5423
  if (!located || !located.found)
5121
5424
  return null;
5122
5425
  const { plans, incomplete_plans: incomplete } = located;
@@ -5152,6 +5455,10 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
5152
5455
  if (phaseNumber == null || String(phaseNumber).trim() === '') {
5153
5456
  error('phase required (--phase <N>)');
5154
5457
  }
5458
+ if (resolvePhaseIdConvention(cwd) === 'bracket'
5459
+ && (String(phaseNumber).startsWith('[') || String(phaseNumber).includes('-'))) {
5460
+ error('state begin-phase --phase requires a bare phase number such as 02; qualified ids are not accepted.', ERROR_REASON.USAGE);
5461
+ }
5155
5462
  const statePath = planningPaths(cwd).state;
5156
5463
  if (!node_fs_1.default.existsSync(statePath)) {
5157
5464
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -5215,7 +5522,22 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
5215
5522
  if (result.data?.['resumed']) {
5216
5523
  delete rmwOptions.authoritativeFm;
5217
5524
  }
5218
- return result.content;
5525
+ let transitioned = result.content;
5526
+ if (resolvePhaseIdConvention(cwd) === 'bracket') {
5527
+ const display = phaseDisplayFor(cwd, phaseNumber);
5528
+ if (result.updated.includes('Last Activity Description')) {
5529
+ transitioned = replaceStateBodyField(transitioned, statePath, 'Last Activity Description', `${display} execution started`);
5530
+ }
5531
+ if (result.updated.includes('Current focus')) {
5532
+ transitioned = replaceStateBodyField(transitioned, statePath, 'Current focus', intent.phaseName ? `${display} — ${intent.phaseName}` : display);
5533
+ }
5534
+ const today = clock_cjs_1.realClock.localToday();
5535
+ const activitySuffix = result.data?.['resumed']
5536
+ ? 'execution resumed (wave continue)'
5537
+ : 'execution started';
5538
+ transitioned = replaceCurrentPositionFieldValue(transitioned, statePath, 'Last Activity', `${today} — Phase ${phaseNumber} ${activitySuffix}`, `${today} — ${display} ${activitySuffix}`);
5539
+ }
5540
+ return transitioned;
5219
5541
  }, cwd, rmwOptions);
5220
5542
  // ADR-3408 §8.4 (D4): reconcile `beginPhaseCore`'s own success list against
5221
5543
  // the bytes actually persisted (fix(#3351) generalized) and fold in any
@@ -5527,12 +5849,23 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
5527
5849
  if (!intent.phaseName) {
5528
5850
  const preFm = extractFrontmatter(content, statePath);
5529
5851
  const curatedName = preFm['current_phase_name'];
5530
- if (typeof curatedName === 'string' && curatedName.trim().length > 0) {
5852
+ // #5369: a status fragment a pre-fix write left as the name is not curated; do not reassert it.
5853
+ if (typeof curatedName === 'string' && curatedName.trim().length > 0 && !phaseIdMod.isPlannedPhaseStatusPhrase(curatedName)) {
5531
5854
  rmwOptions.authoritativeFm = { current_phase_name: curatedName };
5532
5855
  }
5533
5856
  }
5534
5857
  const result = transitionCore(content, intent, deps);
5535
5858
  precomputedUpdated = result.updated;
5859
+ if (resolvePhaseIdConvention(cwd) === 'bracket') {
5860
+ const display = phaseDisplayFor(cwd, phaseNumber);
5861
+ let transitioned = result.content;
5862
+ if (result.updated.includes('Last Activity Description')) {
5863
+ transitioned = replaceStateBodyField(transitioned, statePath, 'Last Activity Description', `${display} planning complete — ${planCount || '?'} plans ready`);
5864
+ }
5865
+ const today = clock_cjs_1.realClock.localToday();
5866
+ transitioned = replaceCurrentPositionFieldValue(transitioned, statePath, 'Last Activity', `${today} — Phase ${phaseNumber} planning complete`, `${today} — ${display} planning complete`);
5867
+ return transitioned;
5868
+ }
5536
5869
  return result.content;
5537
5870
  }, cwd, rmwOptions);
5538
5871
  // ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
@@ -5756,7 +6089,8 @@ function cmdStateValidate(cwd, raw, opts = {}) {
5756
6089
  const { fm, body, scope: initialScope } = readStateFrontmatterScoped(content, statePath);
5757
6090
  const scope = initialScope;
5758
6091
  const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value || '';
5759
- const resolvedPhase = resolveStatePhase(fm, body);
6092
+ const directoryContext = phaseDirectoryOptions(cwd);
6093
+ const resolvedPhase = resolveStatePhase(fm, body, resolvePhaseIdConvention(cwd), directoryContext);
5760
6094
  const currentPhase = resolvedPhase.phase;
5761
6095
  const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
5762
6096
  const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
@@ -5766,8 +6100,8 @@ function cmdStateValidate(cwd, raw, opts = {}) {
5766
6100
  emit({ valid: false, warnings, scope });
5767
6101
  return;
5768
6102
  }
5769
- const selectedPhaseKey = phaseKeyFromToken(currentPhase);
5770
- if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source) !== selectedPhaseKey)) {
6103
+ const selectedPhaseKey = phaseKeyFromToken(currentPhase, directoryContext);
6104
+ if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source, directoryContext) !== selectedPhaseKey)) {
5771
6105
  warnings.push(stateDiagnostic('S003', SEVERITY.WARNING, `Phase reference conflict: validating authoritative phase ${currentPhase}; align STATE.md phase sources`, 'Align STATE.md phase sources (frontmatter, Current Phase, Current Position Phase) on one phase'));
5772
6106
  }
5773
6107
  if (!node_fs_1.default.existsSync(phasesDir)) {
@@ -5796,7 +6130,7 @@ function cmdStateValidate(cwd, raw, opts = {}) {
5796
6130
  let phaseDirPath;
5797
6131
  try {
5798
6132
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
5799
- const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention) === selectedPhaseKey);
6133
+ const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention, directoryContext) === selectedPhaseKey);
5800
6134
  if (!phaseDir) {
5801
6135
  warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: no phase directory matches phase ${currentPhase}`, 'Create a phase directory matching the current phase or correct current_phase'));
5802
6136
  emit({ valid: false, warnings, scope });
@@ -5954,6 +6288,7 @@ function cmdStateSync(cwd, options, raw) {
5954
6288
  // write branch can surface it identically.
5955
6289
  const existingSyncFm = extractFrontmatter(content, statePath);
5956
6290
  const syncFrontmatterWasUnparseable = isUnparseableFrontmatter(existingSyncFm);
6291
+ let directoryContext = phaseDirectoryOptions(cwd, undefined, '');
5957
6292
  const changes = [];
5958
6293
  let modified = content;
5959
6294
  const phasesDir = planningPaths(cwd).phases;
@@ -5973,8 +6308,9 @@ function cmdStateSync(cwd, options, raw) {
5973
6308
  const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
5974
6309
  if (roadmapRaw !== null) {
5975
6310
  syncRoadmapRaw = roadmapRaw;
6311
+ directoryContext = { ...directoryContext, roadmap: roadmapRaw };
5976
6312
  syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
5977
- syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention);
6313
+ syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention, directoryContext);
5978
6314
  }
5979
6315
  }
5980
6316
  catch { /* fall through: no roadmap scope → no retired exclusion */ }
@@ -6011,7 +6347,7 @@ function cmdStateSync(cwd, options, raw) {
6011
6347
  entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
6012
6348
  .filter(e => e.isDirectory())
6013
6349
  .map(e => e.name)
6014
- .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention))))
6350
+ .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention, directoryContext))))
6015
6351
  .filter(name => syncMilestonePhaseDirs === null || syncMilestonePhaseDirs.has(name))
6016
6352
  .sort();
6017
6353
  }
@@ -6079,7 +6415,7 @@ function cmdStateSync(cwd, options, raw) {
6079
6415
  // pre-existing legacy sync behavior while #3185 remains the read-path owner.
6080
6416
  let syncTotalPhases = null;
6081
6417
  const roadmapPhaseCount = syncRoadmapScope !== null
6082
- ? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket')
6418
+ ? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket', directoryContext)
6083
6419
  : 0;
6084
6420
  if (roadmapPhaseCount > 0) {
6085
6421
  syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
@@ -6210,7 +6546,7 @@ function cmdStatePrune(cwd, options, raw) {
6210
6546
  const body = stripFrontmatter(rawState);
6211
6547
  // Prune needs an integer cutoff, so it parses the resolved id itself; a
6212
6548
  // non-numeric or absent id lands on 0 and prune bails, as before.
6213
- const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body)), 10) || 0;
6549
+ const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body, resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd))), 10) || 0;
6214
6550
  const cutoff = currentPhase - keepRecent;
6215
6551
  if (cutoff <= 0) {
6216
6552
  emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
@@ -6435,7 +6771,7 @@ function cmdStateRebuild(cwd, options, raw) {
6435
6771
  * that the phase execution is finished and the project is ready for the next phase.
6436
6772
  * Implements the `gsd state complete-phase` subcommand (issue #2735).
6437
6773
  */
6438
- function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
6774
+ function resolvePhaseIdForCompletePhase(fm, body, overridePhase, directoryContext) {
6439
6775
  // #3187: route through the single #1760 fallback-chain owner (fm scalar
6440
6776
  // then body field) instead of two raw stateExtractField calls on
6441
6777
  // frontmatter-blind content — a STATE.md whose phase lives only in
@@ -6453,7 +6789,7 @@ function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
6453
6789
  // "Phase 0.5 complete". A canonical token at the start of the value
6454
6790
  // (3, 03, 3A, 3.3, 10.2, "3 of 5", "1 — Setup") is preserved; a milestone
6455
6791
  // closure line yields null, so the caller's "unable to resolve" guard fires.
6456
- return parsePhaseFromProse(candidate).phase;
6792
+ return parsePhaseFromProse(candidate, undefined, directoryContext).phase;
6457
6793
  }
6458
6794
  function cmdStateCompletePhase(cwd, raw, overridePhase) {
6459
6795
  const statePath = planningPaths(cwd).state;
@@ -6485,7 +6821,8 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
6485
6821
  output({ error: 'Unable to read STATE.md frontmatter; refusing to run complete-phase to avoid a destructive rollback (#3489). Fix or remove the malformed frontmatter and retry.' }, raw, undefined);
6486
6822
  return;
6487
6823
  }
6488
- const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase);
6824
+ const directoryContext = phaseDirectoryOptions(cwd);
6825
+ const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase, directoryContext);
6489
6826
  if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
6490
6827
  output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw, undefined);
6491
6828
  return;
@@ -6502,7 +6839,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
6502
6839
  const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value || '';
6503
6840
  // #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two
6504
6841
  // sites cannot diverge on the token they extract.
6505
- const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase;
6842
+ const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw, undefined, directoryContext).phase;
6506
6843
  if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
6507
6844
  output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, raw, 'false');
6508
6845
  return;
@@ -6535,6 +6872,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
6535
6872
  const rmwOptions = { divergedFields, preWriteState };
6536
6873
  const wrote = readModifyWriteStateMd(statePath, (content) => {
6537
6874
  const currentPhase = resolvedPhase;
6875
+ const currentPhaseDisplay = phaseDisplayFor(cwd, currentPhase);
6538
6876
  // Bug #1255: operate on body only so the YAML frontmatter `status:` key
6539
6877
  // cannot shadow the body Status field (pipe-table or inline).
6540
6878
  //
@@ -6564,7 +6902,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
6564
6902
  updated.push({ kind: 'field', name: 'Last Activity' });
6565
6903
  }
6566
6904
  // Update Last Activity Description
6567
- const activityDesc = `Phase ${currentPhase} marked complete`;
6905
+ const activityDesc = `${currentPhaseDisplay} marked complete`;
6568
6906
  result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
6569
6907
  if (result) {
6570
6908
  body = result;
@@ -6613,14 +6951,14 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
6613
6951
  posBody = replaced;
6614
6952
  }
6615
6953
  // Update Last activity line if present
6616
- const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`;
6954
+ const newActivity = `Last activity: ${today} — ${currentPhaseDisplay} marked complete`;
6617
6955
  if (/^Last activity:/im.test(posBody)) {
6618
6956
  posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
6619
6957
  }
6620
6958
  else {
6621
6959
  // Pipe-table format in Current Position (#1255)
6622
6960
  // Value must match the inline branch (date + narrative), not bare date.
6623
- const activityValue = `${today} — Phase ${currentPhase} marked complete`;
6961
+ const activityValue = `${today} — ${currentPhaseDisplay} marked complete`;
6624
6962
  const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
6625
6963
  ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
6626
6964
  if (replaced !== null)
@@ -6729,6 +7067,9 @@ module.exports = {
6729
7067
  cmdStateBeginPhase,
6730
7068
  cmdStatePlannedPhase,
6731
7069
  cmdStateCompletePhase,
7070
+ // #4304: the canonical write-path current-phase ladder, read by the bracket
7071
+ // `phase remove` refusal before it deletes or renumbers that phase.
7072
+ resolveCurrentPhaseId,
6732
7073
  cmdStateValidate,
6733
7074
  cmdStateSync,
6734
7075
  cmdStatePrune,