@opengsd/gsd-core 1.7.0 → 1.9.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 (261) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +45 -1
  4. package/README.md +2 -0
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +78 -4
  8. package/agents/gsd-debugger.md +87 -29
  9. package/agents/gsd-executor.md +49 -9
  10. package/agents/gsd-intel-updater.md +3 -3
  11. package/agents/gsd-phase-researcher.md +4 -2
  12. package/agents/gsd-plan-checker.md +20 -0
  13. package/agents/gsd-planner.md +44 -59
  14. package/agents/gsd-project-researcher.md +2 -2
  15. package/agents/gsd-ui-auditor.md +0 -40
  16. package/agents/gsd-verifier.md +2 -2
  17. package/bin/install.js +1338 -135
  18. package/commands/gsd/ai-integration-phase.md +1 -1
  19. package/commands/gsd/mempalace-capture.md +9 -5
  20. package/commands/gsd/new-milestone.md +1 -1
  21. package/commands/gsd/plan-phase.md +5 -3
  22. package/commands/gsd/plan-review-convergence.md +7 -2
  23. package/gsd-core/bin/gsd-tools.cjs +2690 -2472
  24. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  25. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  26. package/gsd-core/bin/lib/api-coverage.cjs +360 -53
  27. package/gsd-core/bin/lib/audit.cjs +8 -8
  28. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  29. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  30. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  31. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  32. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  33. package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
  34. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  35. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  36. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  37. package/gsd-core/bin/lib/check-command-router.cjs +140 -27
  38. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  39. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
  41. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  42. package/gsd-core/bin/lib/commands.cjs +326 -21
  43. package/gsd-core/bin/lib/config-loader.cjs +214 -30
  44. package/gsd-core/bin/lib/config.cjs +158 -22
  45. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  46. package/gsd-core/bin/lib/decisions.cjs +32 -8
  47. package/gsd-core/bin/lib/docs.cjs +6 -0
  48. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  49. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  50. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  51. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  52. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  53. package/gsd-core/bin/lib/init.cjs +155 -66
  54. package/gsd-core/bin/lib/install-engine.cjs +299 -23
  55. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  56. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  57. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  58. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  59. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  60. package/gsd-core/bin/lib/milestone.cjs +248 -14
  61. package/gsd-core/bin/lib/model-catalog.cjs +69 -4
  62. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  63. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  64. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  65. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  66. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  67. package/gsd-core/bin/lib/phase-id.cjs +304 -9
  68. package/gsd-core/bin/lib/phase.cjs +258 -17
  69. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  70. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  71. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  72. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  73. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  74. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  75. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  76. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  77. package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
  78. package/gsd-core/bin/lib/roadmap.cjs +23 -7
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
  82. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  83. package/gsd-core/bin/lib/smart-entry.cjs +70 -5
  84. package/gsd-core/bin/lib/state-document.cjs +171 -24
  85. package/gsd-core/bin/lib/state-transition.cjs +50 -11
  86. package/gsd-core/bin/lib/state.cjs +206 -32
  87. package/gsd-core/bin/lib/surface.cjs +51 -9
  88. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  89. package/gsd-core/bin/lib/uat.cjs +428 -11
  90. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  91. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  92. package/gsd-core/bin/lib/validate.cjs +44 -8
  93. package/gsd-core/bin/lib/verification.cjs +163 -31
  94. package/gsd-core/bin/lib/verify.cjs +348 -42
  95. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  96. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  97. package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
  98. package/gsd-core/bin/shared/model-catalog.json +5 -0
  99. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  100. package/gsd-core/references/api-coverage.md +37 -7
  101. package/gsd-core/references/checkpoints.md +1 -1
  102. package/gsd-core/references/common-bug-patterns.md +13 -0
  103. package/gsd-core/references/context-budget.md +40 -0
  104. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  105. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  106. package/gsd-core/references/debugger-philosophy.md +1 -0
  107. package/gsd-core/references/debugger-prevention.md +98 -0
  108. package/gsd-core/references/debugger-rca-branching.md +98 -0
  109. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  110. package/gsd-core/references/debugger-sbfl.md +110 -0
  111. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  112. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  113. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  114. package/gsd-core/references/execute-phase-response-language.md +7 -0
  115. package/gsd-core/references/gate-prompts.md +6 -3
  116. package/gsd-core/references/model-profile-resolution.md +64 -13
  117. package/gsd-core/references/offer-next.md +88 -0
  118. package/gsd-core/references/planner-antipatterns.md +6 -0
  119. package/gsd-core/references/planner-mvp-mode.md +12 -13
  120. package/gsd-core/references/planner-preconditions.md +156 -0
  121. package/gsd-core/references/planner-reversibility.md +132 -0
  122. package/gsd-core/references/planning-config.md +2 -1
  123. package/gsd-core/references/reviewer-instances.md +28 -19
  124. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  125. package/gsd-core/references/skeleton-template.md +1 -1
  126. package/gsd-core/references/thinking-models-planning.md +3 -1
  127. package/gsd-core/references/ui-consideration-probe.md +2 -2
  128. package/gsd-core/references/worktree-branch-check.md +4 -4
  129. package/gsd-core/templates/DEBUG.md +5 -3
  130. package/gsd-core/templates/summary-minimal.md +4 -0
  131. package/gsd-core/templates/summary-standard.md +4 -0
  132. package/gsd-core/templates/summary.md +7 -0
  133. package/gsd-core/workflows/add-phase.md +2 -0
  134. package/gsd-core/workflows/add-tests.md +3 -1
  135. package/gsd-core/workflows/add-todo.md +32 -1
  136. package/gsd-core/workflows/ai-integration-phase.md +8 -6
  137. package/gsd-core/workflows/audit-fix.md +6 -2
  138. package/gsd-core/workflows/audit-milestone.md +8 -0
  139. package/gsd-core/workflows/autonomous.md +19 -15
  140. package/gsd-core/workflows/check-todos.md +5 -3
  141. package/gsd-core/workflows/cleanup.md +7 -1
  142. package/gsd-core/workflows/code-review-fix.md +14 -6
  143. package/gsd-core/workflows/code-review.md +93 -24
  144. package/gsd-core/workflows/complete-milestone.md +3 -0
  145. package/gsd-core/workflows/debug.md +35 -7
  146. package/gsd-core/workflows/diagnose-issues.md +5 -1
  147. package/gsd-core/workflows/discovery-phase.md +7 -0
  148. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  149. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  150. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  151. package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
  152. package/gsd-core/workflows/discuss-phase.md +2 -2
  153. package/gsd-core/workflows/do.md +7 -1
  154. package/gsd-core/workflows/docs-update.md +9 -0
  155. package/gsd-core/workflows/eval-review.md +4 -1
  156. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  157. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  158. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  159. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  160. package/gsd-core/workflows/execute-phase.md +110 -149
  161. package/gsd-core/workflows/execute-plan.md +20 -8
  162. package/gsd-core/workflows/explore.md +4 -0
  163. package/gsd-core/workflows/extract-learnings.md +21 -0
  164. package/gsd-core/workflows/graduation.md +3 -0
  165. package/gsd-core/workflows/health.md +7 -1
  166. package/gsd-core/workflows/help/modes/full.md +9 -5
  167. package/gsd-core/workflows/import.md +11 -2
  168. package/gsd-core/workflows/inbox.md +7 -0
  169. package/gsd-core/workflows/ingest-docs.md +19 -10
  170. package/gsd-core/workflows/manager.md +3 -1
  171. package/gsd-core/workflows/map-codebase.md +17 -10
  172. package/gsd-core/workflows/mvp-phase.md +3 -0
  173. package/gsd-core/workflows/new-milestone.md +79 -23
  174. package/gsd-core/workflows/new-project.md +28 -19
  175. package/gsd-core/workflows/new-workspace.md +3 -1
  176. package/gsd-core/workflows/next.md +5 -2
  177. package/gsd-core/workflows/onboard.md +3 -0
  178. package/gsd-core/workflows/plan-phase.md +56 -51
  179. package/gsd-core/workflows/plan-review-convergence.md +61 -12
  180. package/gsd-core/workflows/plant-seed.md +3 -0
  181. package/gsd-core/workflows/profile-user.md +7 -1
  182. package/gsd-core/workflows/progress.md +31 -3
  183. package/gsd-core/workflows/quick.md +33 -10
  184. package/gsd-core/workflows/remove-workspace.md +3 -0
  185. package/gsd-core/workflows/review.md +172 -585
  186. package/gsd-core/workflows/scan.md +10 -2
  187. package/gsd-core/workflows/secure-phase.md +13 -2
  188. package/gsd-core/workflows/settings-integrations.md +3 -0
  189. package/gsd-core/workflows/settings.md +3 -0
  190. package/gsd-core/workflows/ship.md +88 -11
  191. package/gsd-core/workflows/sketch.md +3 -0
  192. package/gsd-core/workflows/smart-entry.md +4 -1
  193. package/gsd-core/workflows/spike.md +7 -1
  194. package/gsd-core/workflows/ui-phase.md +11 -2
  195. package/gsd-core/workflows/ui-review.md +11 -1
  196. package/gsd-core/workflows/undo.md +7 -0
  197. package/gsd-core/workflows/update.md +106 -5
  198. package/gsd-core/workflows/validate-phase.md +13 -2
  199. package/gsd-core/workflows/verify-phase.md +2 -2
  200. package/gsd-core/workflows/verify-work.md +15 -4
  201. package/hooks/dist/gsd-context-monitor.js +27 -9
  202. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  203. package/hooks/dist/gsd-cursor-stop.js +6 -2
  204. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  205. package/hooks/dist/gsd-graphify-update.sh +9 -0
  206. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  207. package/hooks/dist/gsd-prompt-guard.js +101 -2
  208. package/hooks/dist/gsd-read-guard.js +100 -2
  209. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  210. package/hooks/dist/gsd-statusline.js +97 -9
  211. package/hooks/dist/gsd-workflow-guard.js +110 -6
  212. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  213. package/hooks/dist/lib/cursor-workspace.js +74 -0
  214. package/hooks/gsd-context-monitor.js +27 -9
  215. package/hooks/gsd-cursor-session-start.js +6 -2
  216. package/hooks/gsd-cursor-stop.js +6 -2
  217. package/hooks/gsd-cursor-subagent-start.js +6 -2
  218. package/hooks/gsd-graphify-update.sh +9 -0
  219. package/hooks/gsd-phase-boundary.sh +14 -2
  220. package/hooks/gsd-prompt-guard.js +101 -2
  221. package/hooks/gsd-read-guard.js +100 -2
  222. package/hooks/gsd-read-injection-scanner.js +109 -2
  223. package/hooks/gsd-statusline.js +97 -9
  224. package/hooks/gsd-workflow-guard.js +110 -6
  225. package/hooks/gsd-worktree-path-guard.js +132 -8
  226. package/hooks/lib/cursor-workspace.js +74 -0
  227. package/package.json +10 -8
  228. package/pi/gsd.cjs +34 -3
  229. package/scripts/changeset/lint.cjs +1 -0
  230. package/scripts/changeset/parse.cjs +26 -0
  231. package/scripts/check-coverage-gate.cjs +51 -0
  232. package/scripts/check-glossary-refs.cjs +244 -0
  233. package/scripts/ci-rebase-check.cjs +48 -4
  234. package/scripts/ci-test-scope.cjs +67 -17
  235. package/scripts/gen-adr-index.cjs +528 -0
  236. package/scripts/gen-capability-matrix.cjs +26 -2
  237. package/scripts/gen-capability-registry.cjs +132 -34
  238. package/scripts/gen-emitted-baseline.cjs +145 -0
  239. package/scripts/gen-test-timings.cjs +201 -0
  240. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  241. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  242. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  243. package/scripts/lint-portable-timeout.cjs +140 -0
  244. package/scripts/lint-resolution-provenance.cjs +9 -0
  245. package/scripts/lint-test-file-count.allowlist.json +1 -0
  246. package/scripts/mutation-matrix.cjs +4 -0
  247. package/scripts/prompt-injection-scan.sh +6 -0
  248. package/scripts/registry-schema.cjs +57 -8
  249. package/scripts/release-notes/conventional-title.cjs +19 -1
  250. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  251. package/scripts/release-tarball-smoke.cjs +18 -11
  252. package/scripts/run-tests.cjs +420 -58
  253. package/scripts/workflow-size.cjs +16 -8
  254. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  255. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  256. package/skills/gsd-new-milestone/SKILL.md +1 -1
  257. package/skills/gsd-plan-phase/SKILL.md +5 -3
  258. package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
  259. package/vscode/package.json +1 -1
  260. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  261. package/scripts/update-size-baseline.cjs +0 -68
@@ -100,7 +100,28 @@ function applyStatePreservation(input) {
100
100
  !resync &&
101
101
  preFm &&
102
102
  preFm['progress']) {
103
- postFm['progress'] = preFm['progress'];
103
+ // #2440: when the caller opts in (deriveProgressKeys), total_plans and
104
+ // total_phases always take the derived (post-sync) value even under !resync.
105
+ // This is used by cmdStatePlannedPhase where total_plans must correct upward
106
+ // after plans are added. For body-only writes (state.update/patch without
107
+ // the flag), the wholesale restore preserves everything as before — the
108
+ // #3242 Bug A protection stays fully in force.
109
+ if (input.deriveProgressKeys && postFm['progress']) {
110
+ const curated = preFm['progress'];
111
+ const derived = (postFm['progress'] ?? {});
112
+ const merged = { ...derived };
113
+ if (curated) {
114
+ for (const [key, value] of Object.entries(curated)) {
115
+ if (key !== 'total_plans' && key !== 'total_phases') {
116
+ merged[key] = value;
117
+ }
118
+ }
119
+ }
120
+ postFm['progress'] = merged;
121
+ }
122
+ else {
123
+ postFm['progress'] = preFm['progress'];
124
+ }
104
125
  mutated = true;
105
126
  }
106
127
  // status — #1230 body-delta heuristic. Table: preserve-when-unchanged.
@@ -220,7 +241,7 @@ function beginPhaseCore(content, intent, deps) {
220
241
  // #1255: body-field replacements operate on body only (frontmatter stripped),
221
242
  // not on the full content. The YAML `status:` key matches `^Status:\s*`
222
243
  // before the body pipe-table row if full content is passed.
223
- const existingFm = extractFrontmatter(content);
244
+ const existingFm = extractFrontmatter(content, deps.sourcePath);
224
245
  const hasFrontmatter = Object.keys(existingFm).length > 0;
225
246
  let body = stripFrontmatter(content);
226
247
  const reassemble = (b) => hasFrontmatter
@@ -293,7 +314,11 @@ function beginPhaseCore(content, intent, deps) {
293
314
  // (do not touch Plan:, Phase:, Status:, stopped_at, progress.percent).
294
315
  body = mutateCurrentPositionResume(body, intent, today, updated);
295
316
  }
296
- return { content: reassemble(body), updated };
317
+ // #2736: surface the #3127 resume decision so the adapter can drop its
318
+ // intent-first current_phase_name override on a resume — the core just
319
+ // preserved the mid-flight name, and an override would drift frontmatter
320
+ // away from the preserved body value.
321
+ return { content: reassemble(body), updated, data: { resumed: isAlreadyExecuting } };
297
322
  }
298
323
  /**
299
324
  * Find the `## Current Position` section, return its `{start, end}` byte
@@ -502,7 +527,7 @@ function advancePlanCore(content, deps) {
502
527
  // not on the full content. The YAML `status:` key matches `^Status:\s*`
503
528
  // before the body field if full content is passed (codex Phase 2 review:
504
529
  // HIGH blocking finding — same pattern beginPhaseCore already handles).
505
- const existingFm = extractFrontmatter(content);
530
+ const existingFm = extractFrontmatter(content, deps.sourcePath);
506
531
  const hasFrontmatter = Object.keys(existingFm).length > 0;
507
532
  let body = stripFrontmatter(content);
508
533
  const reassemble = (b) => hasFrontmatter
@@ -624,7 +649,7 @@ function completePhaseCore(content, intent, deps) {
624
649
  }
625
650
  // #1255: body-field replacements operate on body only (frontmatter stripped),
626
651
  // so the YAML `status:` / `current_phase:` keys cannot shadow the body fields.
627
- const existingFm = extractFrontmatter(content);
652
+ const existingFm = extractFrontmatter(content, deps.sourcePath);
628
653
  const hasFrontmatter = Object.keys(existingFm).length > 0;
629
654
  let body = stripFrontmatter(content);
630
655
  const reassemble = (b) => hasFrontmatter
@@ -768,7 +793,7 @@ function plannedPhaseCore(content, intent, deps) {
768
793
  }
769
794
  }
770
795
  // #1255: body-field replacements operate on body only.
771
- const existingFm = extractFrontmatter(content);
796
+ const existingFm = extractFrontmatter(content, deps.sourcePath);
772
797
  const hasFrontmatter = Object.keys(existingFm).length > 0;
773
798
  let body = stripFrontmatter(content);
774
799
  const reassemble = (b) => hasFrontmatter
@@ -810,6 +835,18 @@ function plannedPhaseCore(content, intent, deps) {
810
835
  }, statusDefaults, lastActivityDefaults);
811
836
  if (body !== beforePos)
812
837
  updated.push('Current Position');
838
+ // #2400 Bug B: sync progress.total_plans to the frontmatter when a plan count
839
+ // is given. This writes the explicitly-provided count — it is NOT a re-derivation
840
+ // from disk (#500 RC1 is about deriving from a half-planned snapshot, not about
841
+ // refusing to write an explicitly-passed argument).
842
+ if (intent.planCount !== null && intent.planCount !== undefined && hasFrontmatter) {
843
+ const fmProgress = existingFm['progress'] || {};
844
+ if (fmProgress['total_plans'] !== intent.planCount) {
845
+ fmProgress['total_plans'] = intent.planCount;
846
+ existingFm['progress'] = fmProgress;
847
+ updated.push('progress.total_plans');
848
+ }
849
+ }
813
850
  return { content: reassemble(body), updated };
814
851
  }
815
852
  // ----------------------------------------------------------------------------
@@ -847,7 +884,7 @@ function milestoneSwitchCore(content, intent, deps) {
847
884
  'progress',
848
885
  'Current Position',
849
886
  ];
850
- const existingFm = extractFrontmatter(content);
887
+ const existingFm = extractFrontmatter(content, deps.sourcePath);
851
888
  const body = stripFrontmatter(content);
852
889
  const resolvedName = (intent.name && intent.name.trim()) || 'milestone';
853
890
  // ## Current Position reset body (mirrors state.cts:2371-2375).
@@ -992,7 +1029,7 @@ function milestoneCompleteCore(content, intent, deps) {
992
1029
  }
993
1030
  }
994
1031
  // #1255: body-field replacements operate on body only.
995
- const existingFm = extractFrontmatter(content);
1032
+ const existingFm = extractFrontmatter(content, deps.sourcePath);
996
1033
  const hasFrontmatter = Object.keys(existingFm).length > 0;
997
1034
  let body = stripFrontmatter(content);
998
1035
  const reassemble = (b) => hasFrontmatter
@@ -1318,7 +1355,9 @@ function rebuildCore(content, _intent, deps) {
1318
1355
  let modified = content;
1319
1356
  // §2 Decision: re-derive derived sections, preserve others. Order is
1320
1357
  // oldest-section-first so log entries appear in body order.
1321
- modified = reconcileCurrentPosition(modified, timestamp, log);
1358
+ // sourcePath threaded so `state rebuild --dry-run` names the file: that branch reads STATE.md
1359
+ // directly rather than through readModifyWriteStateMd, so nothing upstream has named it yet.
1360
+ modified = reconcileCurrentPosition(modified, timestamp, log, deps.sourcePath);
1322
1361
  modified = reconcileByPhaseTable(modified, deps, timestamp, log);
1323
1362
  modified = stripTemplatePlaceholders(modified, timestamp, log);
1324
1363
  modified = deduplicateSessionArchive(modified, timestamp, log);
@@ -1352,8 +1391,8 @@ function rebuildCore(content, _intent, deps) {
1352
1391
  * the key (Leaky-Abstractions guard — don't synthesize values the canonical
1353
1392
  * source doesn't have).
1354
1393
  */
1355
- function reconcileCurrentPosition(content, timestamp, log) {
1356
- const fm = extractFrontmatter(content);
1394
+ function reconcileCurrentPosition(content, timestamp, log, sourcePath) {
1395
+ const fm = extractFrontmatter(content, sourcePath);
1357
1396
  if (!fm || typeof fm !== 'object')
1358
1397
  return content;
1359
1398
  let modified = content;
@@ -39,6 +39,7 @@ const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } =
39
39
  const state_document_cjs_1 = require("./state-document.cjs");
40
40
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
41
41
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
42
+ const validate_cjs_1 = require("./validate.cjs");
42
43
  const STATE_PROGRESS_RESYNC_FIELDS = new Set([
43
44
  'Progress',
44
45
  'Total Plans in Phase',
@@ -170,6 +171,12 @@ function cmdStateLoad(cwd, raw) {
170
171
  state_exists: stateExists,
171
172
  roadmap_exists: roadmapExists,
172
173
  config_exists: configExists,
174
+ // #2376: absolute (anchored on cwd), not orchestrator-cwd-relative — a
175
+ // spawned subagent's own cwd may differ from the orchestrator's.
176
+ // debug.md has no init.* call of its own; it reads this field from
177
+ // `state load` to build debug_file_path for its gsd-debug-session-manager
178
+ // spawns instead of hardcoding '.planning/debug/{slug}.md'.
179
+ debug_dir: (0, shell_command_projection_cjs_1.toPosixPath)(node_path_1.default.join(planDir, 'debug')),
173
180
  };
174
181
  // For --raw, output a condensed key=value format
175
182
  if (raw) {
@@ -353,6 +360,7 @@ function cmdStateAdvancePlan(cwd, raw) {
353
360
  const deps = {
354
361
  clock: clock_cjs_1.realClock,
355
362
  progressProvider: () => null,
363
+ sourcePath: statePath,
356
364
  };
357
365
  let resultData;
358
366
  readModifyWriteStateMd(statePath, (content) => {
@@ -1003,6 +1011,15 @@ function cmdStateRecordSession(cwd, options, raw) {
1003
1011
  // duplicate section alongside an existing one.
1004
1012
  const existingCanonicalSession = /^## Session[ \t]*$/im.test(content);
1005
1013
  const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content);
1014
+ // Track whether the chosen branch's rewrite actually matched. The detector
1015
+ // regexes (existingCanonicalSession/existingSessionContinuity) are CRLF-
1016
+ // tolerant ($ under /m treats \r as a line terminator); the writer regexes
1017
+ // below must be too. If a writer regex silently fails to match (line-ending
1018
+ // mismatch, unexpected heading shape, ...), do NOT report success — the
1019
+ // caller would believe fields were persisted that were silently dropped
1020
+ // (#2450). The append branch always sets rewriteMatched=true (it always
1021
+ // mutates content).
1022
+ let rewriteMatched = false;
1006
1023
  if (existingCanonicalSession) {
1007
1024
  // Normalize in place: replace the ENTIRE BODY of the existing ## Session
1008
1025
  // section (heading + all content up to the next ## heading or EOF) with
@@ -1010,7 +1027,13 @@ function cmdStateRecordSession(cwd, options, raw) {
1010
1027
  // `(?!^## )[\s\S]` consumes every line that doesn't start with "## ",
1011
1028
  // which correctly stops at the next section boundary without consuming it.
1012
1029
  // A trailing blank line is added so the next ## heading keeps its spacing.
1013
- content = content.replace(/^(## Session[ \t]*\n(?:(?!^## )[\s\S])*)/m, [
1030
+ //
1031
+ // CRLF-tolerant (`\r?\n` after `[ \t]*`): the prior literal `\n` could not
1032
+ // match a CRLF STATE.md (`---\r\n`), silently no-op'ing the replace while
1033
+ // updated.push(...) reported success — #2450. The detector regex on the
1034
+ // line above (`/^## Session[ \t]*$/im`) was already CRLF-tolerant, so the
1035
+ // asymmetry armed the bug.
1036
+ const canonicalReplacement = [
1014
1037
  '## Session',
1015
1038
  '',
1016
1039
  `**Last session:** ${now}`,
@@ -1018,7 +1041,11 @@ function cmdStateRecordSession(cwd, options, raw) {
1018
1041
  `**Resume file:** ${resumeValue}`,
1019
1042
  '',
1020
1043
  '',
1021
- ].join('\n'));
1044
+ ].join('\n');
1045
+ content = content.replace(/^(## Session[ \t]*\r?\n(?:(?!^## )[\s\S])*)/m, () => {
1046
+ rewriteMatched = true;
1047
+ return canonicalReplacement;
1048
+ });
1022
1049
  }
1023
1050
  else if (existingSessionContinuity) {
1024
1051
  // #1101: a `## Session Continuity` section already exists (bootstrap
@@ -1029,6 +1056,8 @@ function cmdStateRecordSession(cwd, options, raw) {
1029
1056
  // (e.g. prose like "Next recommended action"). Fields already updated in
1030
1057
  // place above (needs* false) are not re-inserted. A function replacement
1031
1058
  // is used so `$`-bearing caller values are inserted literally (#3454).
1059
+ //
1060
+ // CRLF-tolerant (`\r?\n`): same #2450 fix as the canonical branch above.
1032
1061
  const linesToInsert = [];
1033
1062
  if (needsLastSession)
1034
1063
  linesToInsert.push(`**Last session:** ${now}`);
@@ -1040,8 +1069,18 @@ function cmdStateRecordSession(cwd, options, raw) {
1040
1069
  // Case-insensitive to match the `existingSessionContinuity` detection
1041
1070
  // above (#1101 review F3) — otherwise a lowercase heading would detect
1042
1071
  // but no-op the insert while still reporting the fields as updated.
1043
- content = content.replace(/^(## Session Continuity[ \t]*\n)/im, (_m, heading) => heading + linesToInsert.join('\n') + '\n');
1072
+ content = content.replace(/^(## Session Continuity[ \t]*\r?\n)/im, (_m, heading) => {
1073
+ rewriteMatched = true;
1074
+ return heading + linesToInsert.join('\n') + '\n';
1075
+ });
1044
1076
  }
1077
+ // No `else` branch: if linesToInsert.length === 0 the outer guard at
1078
+ // :1144 (callerSuppliedValues && (needsStoppedAt || needsResumeFile
1079
+ // || needsLastSession)) could not have fired, so this whole block is
1080
+ // unreachable. Leaving `rewriteMatched = false` here is the fail-loud
1081
+ // posture — a future change to the outer guard or needs* computation
1082
+ // that makes this branch reachable will surface as a missing
1083
+ // updated[] entry (silent recorded:false) rather than re-arming #2450.
1045
1084
  }
1046
1085
  else {
1047
1086
  // No session heading exists at all — append a new canonical section.
@@ -1055,14 +1094,30 @@ function cmdStateRecordSession(cwd, options, raw) {
1055
1094
  '',
1056
1095
  ].join('\n');
1057
1096
  content = content.trimEnd() + '\n' + scaffold;
1097
+ rewriteMatched = true;
1098
+ }
1099
+ // #2450 defensive invariant: only report sessionCreated/updated when the
1100
+ // chosen branch's rewrite actually mutated content. Unreachable when the
1101
+ // writer regexes above stay in sync with the CRLF-tolerant detector —
1102
+ // but unreachable-defensive is the right posture for a silent-success
1103
+ // gate. A no-op replace here means a future line-ending or shape drift
1104
+ // between detector and writer; fail to record rather than claim success.
1105
+ //
1106
+ // Scope limitation (not a regression of this fix): the gate covers only
1107
+ // the section-rewrite block. The earlier in-place stateReplaceField
1108
+ // successes at :1081/:1083/:1089/:1101/:1108/:1114 push to `updated`
1109
+ // unconditionally — those represent fields that DID land on disk via
1110
+ // same-line replace (CRLF-agnostic seam), so unconditional push is
1111
+ // correct. The class-defect防御 here is for the INSERT path only.
1112
+ if (rewriteMatched) {
1113
+ sessionCreated = true;
1114
+ if (needsLastSession)
1115
+ updated.push('Last session');
1116
+ if (needsStoppedAt)
1117
+ updated.push('Stopped At');
1118
+ if (needsResumeFile)
1119
+ updated.push('Resume File');
1058
1120
  }
1059
- sessionCreated = true;
1060
- if (needsLastSession)
1061
- updated.push('Last session');
1062
- if (needsStoppedAt)
1063
- updated.push('Stopped At');
1064
- if (needsResumeFile)
1065
- updated.push('Resume File');
1066
1121
  }
1067
1122
  return content;
1068
1123
  }, cwd);
@@ -1097,6 +1152,38 @@ function matchSessionSection(body) {
1097
1152
  ?? (0, markdown_sectionizer_cjs_1.collectSection)(body, isSessionContinuity, { levelBounded: true });
1098
1153
  return section ? section.body : null;
1099
1154
  }
1155
+ /**
1156
+ * #2567: prevent a stale archive "Last activity:" line from overwriting a
1157
+ * newer frontmatter value. `stateExtractField` matches the first body
1158
+ * occurrence, which may be a historical line in an archive section. Unlike
1159
+ * Stopped At / Paused At (which canonically live in `## Session`), Last
1160
+ * Activity has no single canonical section — it appears in the preamble,
1161
+ * `## Configuration`, and `## Current Position` across STATE.md layouts, so a
1162
+ * section scope cannot reliably exclude archive copies. Guard the
1163
+ * information-losing direction instead: when the body-derived date is OLDER
1164
+ * than the existing frontmatter date, keep the existing value and its
1165
+ * description. Applied at both the write seam (syncStateFrontmatter) and the
1166
+ * read seam (cmdStateJson) so they agree. Date fields only — non-date values
1167
+ * pass through unchanged.
1168
+ */
1169
+ function preferNewerLastActivity(existingFm, derivedFm) {
1170
+ if (!existingFm)
1171
+ return;
1172
+ const exRaw = existingFm['last_activity'];
1173
+ const derRaw = derivedFm['last_activity'];
1174
+ if (typeof exRaw !== 'string' || typeof derRaw !== 'string')
1175
+ return;
1176
+ const exDate = exRaw.slice(0, 10);
1177
+ const derDate = derRaw.slice(0, 10);
1178
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(exDate) || !/^\d{4}-\d{2}-\d{2}$/.test(derDate))
1179
+ return;
1180
+ if (derDate < exDate) {
1181
+ derivedFm['last_activity'] = exRaw;
1182
+ if (existingFm['last_activity_desc'] !== undefined) {
1183
+ derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
1184
+ }
1185
+ }
1186
+ }
1100
1187
  function parseProsePhaseField(value) {
1101
1188
  // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this
1102
1189
  // module holds no independent prose phase-id regex. Drives #2111 — the
@@ -1127,7 +1214,9 @@ function cmdStateSnapshot(cwd, raw) {
1127
1214
  // Bug #3265: prefer YAML frontmatter for canonical scalar fields so that a
1128
1215
  // body table cell containing **Status:** Y cannot shadow the authoritative
1129
1216
  // frontmatter value. Mirrors the fix in sdk/src/query/state.ts.
1130
- const fm = extractFrontmatter(content);
1217
+ // Pass statePath so a truncated STATE.md is named in the #1882 diagnostic rather than
1218
+ // reported under a content digest — STATE.md is one of the artefacts epic #1879 is about.
1219
+ const fm = extractFrontmatter(content, statePath);
1131
1220
  const body = stripFrontmatter(content);
1132
1221
  // Helper: return frontmatter scalar value when present and non-empty.
1133
1222
  // Accepts strings, numbers, and booleans — coercing non-string primitives to
@@ -1310,8 +1399,8 @@ function buildStateFrontmatter(bodyContent, cwd) {
1310
1399
  const proseLastActivity = parseProseLastActivityField(rawLastActivity);
1311
1400
  const lastActivity = proseLastActivity.date ?? rawLastActivity;
1312
1401
  const lastActivityDesc = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last Activity Description') ?? proseLastActivity.description;
1313
- // Bug #2444: scope Stopped At extraction to the ## Session section so that
1314
- // historical "Stopped at:" prose elsewhere in the body (e.g. in a
1402
+ // Bug #2444 / #2567: scope Stopped At AND Paused At extraction to the
1403
+ // ## Session section so historical prose elsewhere in the body (e.g. in a
1315
1404
  // Session Continuity Archive section) never overwrites the current value.
1316
1405
  // Fall back to full-body search only when no ## Session section exists.
1317
1406
  // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
@@ -1319,7 +1408,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
1319
1408
  const sessionSectionMatch = matchSessionSection(bodyContent);
1320
1409
  const sessionBodyScope = sessionSectionMatch ?? bodyContent;
1321
1410
  const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
1322
- const pausedAt = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Paused At');
1411
+ // #2567: Paused At is a session field — scope it to ## Session too so a
1412
+ // stale "Paused At:" line in an archive section cannot overwrite the value.
1413
+ const pausedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Paused At');
1323
1414
  let milestone = null;
1324
1415
  let milestoneName = null;
1325
1416
  if (cwd) {
@@ -1445,10 +1536,20 @@ function buildStateFrontmatter(bodyContent, cwd) {
1445
1536
  const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(String(milestone).trim())}`, 'mi');
1446
1537
  milestoneBounded = versionedHeading.test(roadmapRaw);
1447
1538
  }
1539
+ // #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
1540
+ // at all — only Phase headings) from a MILESTONED-but-unbounded one
1541
+ // (milestone/version headings exist but the asserted one isn't among them).
1542
+ // On a flat roadmap the whole-doc count is correct (no sibling milestones to
1543
+ // conflate); on a sectioned-but-unbounded one it conflates siblings (#1761),
1544
+ // so fall back to phaseDirs.length.
1545
+ const hasMilestoneSectioning = roadmapRaw !== null
1546
+ && /^#{2,3}\s+(?!Phase\s+\S)/mi.test(roadmapRaw);
1547
+ const safeToUseRoadmapCount = milestoneBounded
1548
+ || (roadmapPhaseCount > 0 && !hasMilestoneSectioning);
1448
1549
  return {
1449
- totalPhases: (!milestoneBounded || roadmapPhaseCount === 0)
1450
- ? phaseDirs.length
1451
- : Math.max(phaseDirs.length, roadmapPhaseCount),
1550
+ totalPhases: safeToUseRoadmapCount
1551
+ ? Math.max(phaseDirs.length, roadmapPhaseCount)
1552
+ : phaseDirs.length,
1452
1553
  milestoneBounded,
1453
1554
  completedPhases: diskCompletedPhases,
1454
1555
  totalPlans: diskTotalPlans,
@@ -1525,10 +1626,12 @@ function buildStateFrontmatter(bodyContent, cwd) {
1525
1626
  fm['progress'] = progress;
1526
1627
  return fm;
1527
1628
  }
1528
- function syncStateFrontmatter(content, cwd) {
1629
+ function syncStateFrontmatter(content, cwd, authoritativeFm) {
1529
1630
  // Read existing frontmatter BEFORE stripping — it may contain values
1530
1631
  // that the body no longer has (e.g., Status field removed by an agent).
1531
- const existingFm = extractFrontmatter(content);
1632
+ // `cwd` already identifies the workspace this content came from, so the STATE.md path is
1633
+ // derivable here without widening the signature (#1882).
1634
+ const existingFm = extractFrontmatter(content, cwd ? planningPaths(cwd).state : undefined);
1532
1635
  const body = stripFrontmatter(content);
1533
1636
  const derivedFm = buildStateFrontmatter(body, cwd);
1534
1637
  // Preserve existing frontmatter status when body-derived status is 'unknown'.
@@ -1612,6 +1715,23 @@ function syncStateFrontmatter(content, cwd) {
1612
1715
  derivedFm[key] = existingFm[key];
1613
1716
  }
1614
1717
  }
1718
+ // #2567: guard the information-losing direction — a stale archive
1719
+ // "Last activity:" line must not overwrite a newer frontmatter value.
1720
+ preferNewerLastActivity(existingFm, derivedFm);
1721
+ // #2736: intent-first override, applied last. A transition adapter that
1722
+ // already holds the exact value (completePhase's next-phase display name,
1723
+ // beginPhase's phase name) passes it here, so the body-prose re-derivation
1724
+ // above — which is lossy by construction for names containing a
1725
+ // parenthetical (`Closer-ruling measurement (D1a)` → `D1a`) — never runs
1726
+ // the final word on a field the transition just resolved. The prose parser
1727
+ // remains the fallback for genuinely unknown prose only.
1728
+ if (authoritativeFm) {
1729
+ for (const [key, value] of Object.entries(authoritativeFm)) {
1730
+ if (typeof value === 'string' && value.trim().length > 0) {
1731
+ derivedFm[key] = value;
1732
+ }
1733
+ }
1734
+ }
1615
1735
  const yamlStr = reconstructFrontmatter(derivedFm);
1616
1736
  return `---\n${yamlStr}\n---\n\n${body}`;
1617
1737
  }
@@ -1907,7 +2027,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1907
2027
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
1908
2028
  // Snapshot the existing progress block BEFORE the transform so we can
1909
2029
  // restore it when resync is false.
1910
- const preFm = resync ? null : extractFrontmatter(content);
2030
+ const preFm = resync ? null : extractFrontmatter(content, statePath);
1911
2031
  // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
1912
2032
  // we can detect whether THIS write changed them. syncStateFrontmatter
1913
2033
  // re-derives frontmatter status/stopped_at from the body on every write;
@@ -1919,7 +2039,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1919
2039
  // Strip frontmatter before calling stateExtractField so the YAML `status:`
1920
2040
  // key in the frontmatter block cannot shadow the body field we are tracking.
1921
2041
  const preBody = stripFrontmatter(content);
1922
- const preFmSnapshot = extractFrontmatter(content);
2042
+ const preFmSnapshot = extractFrontmatter(content, statePath);
1923
2043
  const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
1924
2044
  // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1925
2045
  // mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172).
@@ -1946,7 +2066,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1946
2066
  if (modified === content) {
1947
2067
  return;
1948
2068
  }
1949
- let synced = syncStateFrontmatter(modified, cwd);
2069
+ let synced = syncStateFrontmatter(modified, cwd, options?.authoritativeFm);
1950
2070
  // Post-transform body source fields used for the delta comparison (#1230).
1951
2071
  // Use `modified` (not `synced`): syncStateFrontmatter only rewrites the frontmatter block, so the body is identical in both — and we need the body the transform produced.
1952
2072
  // Strip frontmatter so the YAML status key cannot shadow the body field we are tracking.
@@ -1967,14 +2087,29 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1967
2087
  // one policy source, not three drifting encodings. Behavior-identical to
1968
2088
  // the pre-#1796 inline block; this is the absorption ADR-1769 / CONTEXT.md
1969
2089
  // already claimed shipped.
1970
- const postFm = extractFrontmatter(synced);
2090
+ const postFm = extractFrontmatter(synced, statePath);
1971
2091
  const preservation = applyStatePreservation({
1972
2092
  preFm, postFm, preFmSnapshot, resync,
2093
+ deriveProgressKeys: options?.deriveProgressKeys === true,
1973
2094
  preBodyStatus, postBodyStatus,
1974
2095
  preBodyStoppedAt, postBodyStoppedAt,
1975
2096
  preBodyPhaseSource, postBodyPhaseSource,
1976
2097
  });
1977
- if (preservation.mutated) {
2098
+ // #2736: re-assert the intent-first values AFTER preservation. On STATE.md
2099
+ // layouts with no body `Phase:` line, both phase-source snapshots are null
2100
+ // (equal), so the #1695 restore fires and would put the stale pre-transition
2101
+ // name back over the authoritative one. Intent beats both the prose
2102
+ // re-derivation and the curated restore — the transition just resolved it.
2103
+ let authoritativeReasserted = false;
2104
+ if (options?.authoritativeFm) {
2105
+ for (const [key, value] of Object.entries(options.authoritativeFm)) {
2106
+ if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) {
2107
+ preservation.postFm[key] = value;
2108
+ authoritativeReasserted = true;
2109
+ }
2110
+ }
2111
+ }
2112
+ if (preservation.mutated || authoritativeReasserted) {
1978
2113
  const yamlStr = reconstructFrontmatter(preservation.postFm);
1979
2114
  const body = stripFrontmatter(synced);
1980
2115
  synced = `---\n${yamlStr}\n---\n\n${body}`;
@@ -1992,7 +2127,7 @@ function cmdStateJson(cwd, raw) {
1992
2127
  return;
1993
2128
  }
1994
2129
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
1995
- const existingFm = extractFrontmatter(content);
2130
+ const existingFm = extractFrontmatter(content, statePath);
1996
2131
  const body = stripFrontmatter(content);
1997
2132
  // Always rebuild from body + disk so progress counters reflect current state.
1998
2133
  // Returning cached frontmatter directly causes stale percent/completed_plans
@@ -2026,6 +2161,10 @@ function cmdStateJson(cwd, raw) {
2026
2161
  if (existingFm && (0, state_document_cjs_1.shouldPreserveExistingProgress)(existingFm['progress'], built['progress'])) {
2027
2162
  built['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
2028
2163
  }
2164
+ // #2567: guard the information-losing direction — a stale archive
2165
+ // "Last activity:" line must not surface as the current value. Mirrors the
2166
+ // syncStateFrontmatter guard so the read path agrees with the write path.
2167
+ preferNewerLastActivity(existingFm, built);
2029
2168
  output(built, raw, JSON.stringify(built, null, 2));
2030
2169
  }
2031
2170
  /**
@@ -2055,13 +2194,30 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
2055
2194
  const deps = {
2056
2195
  clock: clock_cjs_1.realClock,
2057
2196
  progressProvider: () => null, // beginPhase doesn't consult disk progress; syncStateFrontmatter's scan is authoritative
2197
+ sourcePath: statePath,
2198
+ };
2199
+ // #2736: the transition holds the exact display name; without this the
2200
+ // post-transform sync re-derives current_phase_name from the freshly
2201
+ // written `Phase: N (Name) — EXECUTING` line, which truncates any name
2202
+ // that itself contains a parenthetical. The #1695 delta-gate preservation
2203
+ // still runs after the sync; the override is re-asserted after it inside
2204
+ // readModifyWriteStateMd for layouts with no body `Phase:` line.
2205
+ const rmwOptions = {
2206
+ authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
2058
2207
  };
2059
2208
  let updated = [];
2060
2209
  readModifyWriteStateMd(statePath, (content) => {
2061
2210
  const result = transitionCore(content, intent, deps);
2062
2211
  updated = result.updated;
2212
+ // #3127 resume: the core preserved the mid-flight Current Phase Name, so
2213
+ // the intent-first override must not fire — it would drift frontmatter
2214
+ // away from the preserved body value. Dropping it here is safe because
2215
+ // readModifyWriteStateMd consults options only after this callback returns.
2216
+ if (result.data?.['resumed']) {
2217
+ delete rmwOptions.authoritativeFm;
2218
+ }
2063
2219
  return result.content;
2064
- }, cwd);
2220
+ }, cwd, rmwOptions);
2065
2221
  output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
2066
2222
  }
2067
2223
  /**
@@ -2307,14 +2463,18 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
2307
2463
  const deps = {
2308
2464
  clock: clock_cjs_1.realClock,
2309
2465
  progressProvider: () => null,
2466
+ sourcePath: statePath,
2310
2467
  };
2311
2468
  let updated = [];
2312
2469
  readModifyWriteStateMd(statePath, (content) => {
2313
2470
  const result = transitionCore(content, intent, deps);
2314
2471
  updated = result.updated;
2315
2472
  return result.content;
2316
- }, cwd, { resync: false });
2317
- output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
2473
+ }, cwd, { resync: false, deriveProgressKeys: true });
2474
+ const result = updated.length === 0
2475
+ ? { updated, phase: phaseNumber, plan_count: planCount, warning: 'STATE.md Current Position has no recognized labels — transition was a no-op. Verify STATE.md uses the canonical labeled format (Status:, Total Plans in Phase:, etc.).' }
2476
+ : { updated, phase: phaseNumber, plan_count: planCount };
2477
+ output(result, raw, updated.length > 0 ? 'true' : 'false');
2318
2478
  }
2319
2479
  /**
2320
2480
  * Bug #2630: reset STATE.md for a new milestone cycle.
@@ -2336,7 +2496,7 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
2336
2496
  // milestoneSwitch rebuilds frontmatter directly and must not run the
2337
2497
  // steady-state syncStateFrontmatter post-sync.
2338
2498
  const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
2339
- const deps = { clock: clock_cjs_1.realClock, progressProvider: () => null };
2499
+ const deps = { clock: clock_cjs_1.realClock, progressProvider: () => null, sourcePath: statePath };
2340
2500
  const lockPath = acquireStateLock(statePath);
2341
2501
  try {
2342
2502
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
@@ -2359,6 +2519,14 @@ function cmdStateValidate(cwd, raw) {
2359
2519
  return;
2360
2520
  }
2361
2521
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2522
+ // #2701: fail loud on NUL/binary corruption before drift checks. A corrupt
2523
+ // STATE.md otherwise validates as clean and is silently skipped by recursive
2524
+ // searchers downstream, reading as "absent" rather than "corrupt."
2525
+ const encErr = (0, validate_cjs_1.textEncodingError)(content, 'STATE.md');
2526
+ if (encErr) {
2527
+ output({ valid: false, warnings: [encErr], drift: {} }, raw, undefined);
2528
+ return;
2529
+ }
2362
2530
  const warnings = [];
2363
2531
  const drift = {};
2364
2532
  const status = (0, state_document_cjs_1.stateExtractField)(content, 'Status') || '';
@@ -2536,7 +2704,7 @@ function cmdStateSync(cwd, options, raw) {
2536
2704
  // set — leave Progress untouched (percent=null) rather than silently writing
2537
2705
  // fallback-derived wrong values. Projects without a milestone version (the common
2538
2706
  // sync-test shape) are unaffected: the gate only fires when a version is asserted.
2539
- const fmVersion = extractFrontmatter(content).milestone;
2707
+ const fmVersion = extractFrontmatter(content, statePath).milestone;
2540
2708
  const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
2541
2709
  let milestoneBounded = true;
2542
2710
  if (versionStr !== null && syncRoadmapRaw !== null) {
@@ -2594,7 +2762,7 @@ function cmdStatePrune(cwd, options, raw) {
2594
2762
  // the explicit `Current Phase` field are unambiguous, so they stay document-wide;
2595
2763
  // the shared extractor is not narrowed for any other caller.
2596
2764
  const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
2597
- const fm = extractFrontmatter(rawState);
2765
+ const fm = extractFrontmatter(rawState, statePath);
2598
2766
  const body = stripFrontmatter(rawState);
2599
2767
  // Mirror buildStateFrontmatter's fmScalar: only string/number/boolean
2600
2768
  // frontmatter scalars are usable (an object/array `current_phase` is ignored,
@@ -2735,6 +2903,12 @@ function cmdStateRebuild(cwd, options, raw) {
2735
2903
  progressProvider: () => null,
2736
2904
  clock: clock_cjs_1.realClock,
2737
2905
  phaseInventoryProvider,
2906
+ // Without this, `state rebuild --dry-run` reported a truncated STATE.md anonymously: the
2907
+ // write path is named only because readModifyWriteStateMd parses with the path first, and
2908
+ // the dry-run branch reads the file directly and never does. Dry-run is the read-only mode
2909
+ // an operator reaches for first when they suspect corruption, so it is the one that most
2910
+ // needs to name the file (#1882).
2911
+ sourcePath: statePath,
2738
2912
  };
2739
2913
  const runRebuild = (content) => transitionCore(content, { kind: 'rebuild' }, deps);
2740
2914
  const emitVerboseLog = (log) => {
@@ -2833,7 +3007,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2833
3007
  const currentPhase = resolvedPhase;
2834
3008
  // Bug #1255: operate on body only so the YAML frontmatter `status:` key
2835
3009
  // cannot shadow the body Status field (pipe-table or inline).
2836
- const existingFm = extractFrontmatter(content);
3010
+ const existingFm = extractFrontmatter(content, statePath);
2837
3011
  const hasFrontmatter = Object.keys(existingFm).length > 0;
2838
3012
  let body = stripFrontmatter(content);
2839
3013
  const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;