@opengsd/gsd-core 1.5.0-rc.2 → 1.5.0-rc.4

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 (131) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-advisor-researcher.md +1 -1
  3. package/agents/gsd-assumptions-analyzer.md +1 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-code-reviewer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debugger.md +1 -1
  8. package/agents/gsd-doc-writer.md +1 -1
  9. package/agents/gsd-eval-auditor.md +1 -1
  10. package/agents/gsd-executor.md +1 -1
  11. package/agents/gsd-integration-checker.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +47 -0
  13. package/agents/gsd-nyquist-auditor.md +1 -0
  14. package/agents/gsd-phase-researcher.md +1 -1
  15. package/agents/gsd-plan-checker.md +1 -1
  16. package/agents/gsd-planner.md +1 -1
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +1 -1
  19. package/agents/gsd-roadmapper.md +55 -2
  20. package/agents/gsd-security-auditor.md +1 -0
  21. package/agents/gsd-ui-auditor.md +1 -1
  22. package/agents/gsd-ui-checker.md +1 -1
  23. package/agents/gsd-ui-researcher.md +1 -1
  24. package/agents/gsd-verifier.md +13 -2
  25. package/bin/install.js +61 -64
  26. package/commands/gsd/mempalace-capture.md +71 -0
  27. package/commands/gsd/mempalace-recall.md +102 -0
  28. package/commands/gsd/ns-context.md +4 -2
  29. package/commands/gsd/progress.md +2 -1
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +277 -95
  32. package/gsd-core/bin/lib/active-workstream-store.cjs +6 -0
  33. package/gsd-core/bin/lib/capability-activation.cjs +86 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
  35. package/gsd-core/bin/lib/capability-state.cjs +128 -21
  36. package/gsd-core/bin/lib/capability-writer.cjs +354 -0
  37. package/gsd-core/bin/lib/check-command-router.cjs +328 -1
  38. package/gsd-core/bin/lib/clusters.cjs +2 -0
  39. package/gsd-core/bin/lib/command-roster.cjs +19 -0
  40. package/gsd-core/bin/lib/commands.cjs +33 -10
  41. package/gsd-core/bin/lib/config-loader.cjs +7 -8
  42. package/gsd-core/bin/lib/config-schema.cjs +32 -3
  43. package/gsd-core/bin/lib/config.cjs +81 -26
  44. package/gsd-core/bin/lib/core.cjs +5 -2
  45. package/gsd-core/bin/lib/edge-probe.cjs +25 -2
  46. package/gsd-core/bin/lib/frontmatter.cjs +53 -1
  47. package/gsd-core/bin/lib/git-base-branch.cjs +194 -0
  48. package/gsd-core/bin/lib/init.cjs +36 -11
  49. package/gsd-core/bin/lib/install-profiles.cjs +57 -1
  50. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  51. package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
  52. package/gsd-core/bin/lib/model-resolver.cjs +47 -5
  53. package/gsd-core/bin/lib/phase.cjs +99 -23
  54. package/gsd-core/bin/lib/plan-drift-guard.cjs +117 -0
  55. package/gsd-core/bin/lib/probe-core.cjs +117 -1
  56. package/gsd-core/bin/lib/profile-output.cjs +45 -4
  57. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
  58. package/gsd-core/bin/lib/roadmap-parser.cjs +13 -3
  59. package/gsd-core/bin/lib/roadmap.cjs +97 -7
  60. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1946 -0
  61. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +54 -30
  62. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
  63. package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
  64. package/gsd-core/bin/lib/state-command-router.cjs +15 -3
  65. package/gsd-core/bin/lib/state-document.cjs +46 -1
  66. package/gsd-core/bin/lib/state.cjs +461 -94
  67. package/gsd-core/bin/lib/verify.cjs +92 -8
  68. package/gsd-core/bin/lib/worktree-safety.cjs +2 -1
  69. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
  70. package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
  71. package/gsd-core/bin/shared/model-catalog.json +1 -0
  72. package/gsd-core/references/edge-probe.md +11 -0
  73. package/gsd-core/references/loop-hook-dispatch.md +61 -0
  74. package/gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
  75. package/gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
  76. package/gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
  77. package/gsd-core/references/prohibition-probe.md +248 -0
  78. package/gsd-core/templates/config.json +1 -1
  79. package/gsd-core/templates/spec.md +14 -0
  80. package/gsd-core/workflows/audit-milestone.md +5 -3
  81. package/gsd-core/workflows/autonomous.md +10 -5
  82. package/gsd-core/workflows/code-review-fix.md +9 -7
  83. package/gsd-core/workflows/code-review.md +8 -6
  84. package/gsd-core/workflows/complete-milestone.md +1 -5
  85. package/gsd-core/workflows/discuss-phase.md +14 -0
  86. package/gsd-core/workflows/execute-phase.md +86 -146
  87. package/gsd-core/workflows/execute-plan.md +21 -6
  88. package/gsd-core/workflows/help/modes/full.md +7 -1
  89. package/gsd-core/workflows/new-project.md +3 -3
  90. package/gsd-core/workflows/next.md +50 -2
  91. package/gsd-core/workflows/pause-work.md +7 -1
  92. package/gsd-core/workflows/plan-phase.md +91 -221
  93. package/gsd-core/workflows/plan-review-convergence.md +14 -4
  94. package/gsd-core/workflows/pr-branch.md +4 -2
  95. package/gsd-core/workflows/profile-user.md +3 -1
  96. package/gsd-core/workflows/progress.md +58 -1
  97. package/gsd-core/workflows/quick.md +12 -8
  98. package/gsd-core/workflows/resume-project.md +17 -1
  99. package/gsd-core/workflows/review.md +19 -2
  100. package/gsd-core/workflows/secure-phase.md +4 -2
  101. package/gsd-core/workflows/settings-advanced.md +2 -0
  102. package/gsd-core/workflows/settings.md +27 -1
  103. package/gsd-core/workflows/ship.md +58 -5
  104. package/gsd-core/workflows/spec-phase.md +75 -0
  105. package/gsd-core/workflows/validate-phase.md +4 -2
  106. package/gsd-core/workflows/verify-phase.md +14 -4
  107. package/gsd-core/workflows/verify-work.md +27 -11
  108. package/hooks/dist/gsd-ensure-canonical-path.js +305 -0
  109. package/hooks/dist/gsd-statusline.js +1 -1
  110. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  111. package/hooks/gsd-ensure-canonical-path.js +305 -0
  112. package/hooks/gsd-statusline.js +1 -1
  113. package/hooks/hooks.json +1 -0
  114. package/hooks/managed-hooks-registry.cjs +1 -0
  115. package/package.json +5 -4
  116. package/scripts/affected-tests-lib.cjs +16 -4
  117. package/scripts/build-hooks.js +7 -0
  118. package/scripts/changeset/new.cjs +17 -3
  119. package/scripts/fix-slash-commands.cjs +15 -3
  120. package/scripts/gen-capability-registry.cjs +373 -49
  121. package/scripts/gen-inventory-manifest.cjs +1 -4
  122. package/scripts/gen-loop-host-contract.cjs +55 -0
  123. package/scripts/issue-version-gate.cjs +140 -0
  124. package/scripts/lint-allow-test-rule-refs.allowlist.json +327 -0
  125. package/scripts/lint-allow-test-rule-refs.cjs +162 -0
  126. package/scripts/lint-test-file-count.allowlist.json +14 -0
  127. package/scripts/mutation-matrix.cjs +108 -7
  128. package/scripts/pr-target-policy.cjs +63 -0
  129. package/scripts/release-tarball-smoke.cjs +7 -1
  130. package/scripts/research-profiles.cjs +5 -5
  131. package/scripts/run-tests.cjs +178 -17
@@ -245,34 +245,76 @@ function updateCurrentPositionFields(content, fields) {
245
245
  let posBody = posMatch[2];
246
246
  const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
247
247
  const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
248
- if (fields.status && /^Status:/m.test(posBody)) {
249
- // Only replace when the existing Current Position Status is a known template default.
250
- const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
251
- const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
252
- const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
253
- const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
254
- const isDefault = !existingStatus || isInList || matchesPattern;
255
- if (isDefault) {
256
- posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
257
- }
258
- }
259
- if (fields.lastActivity && /^Last activity:/im.test(posBody)) {
260
- // Only replace when the existing Current Position Last activity is a known template
261
- // default (a bare ISO date). Executor-authored narrative prose is preserved.
262
- const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
263
- const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
264
- // A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
265
- // A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
266
- // was authored by the executor and must be preserved.
267
- const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
268
- const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
269
- const isDefault = !existingActivity || isDateShape || inList;
270
- if (isDefault) {
271
- posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
272
- }
273
- }
274
- if (fields.plan && /^Plan:/m.test(posBody)) {
275
- posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
248
+ if (fields.status) {
249
+ if (/^Status:/m.test(posBody)) {
250
+ // Inline format: Status: value — only replace when the existing value is a
251
+ // known template default (Knuth invariant: preserve executor-authored values).
252
+ const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
253
+ const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
254
+ const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
255
+ const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
256
+ const isDefault = !existingStatus || isInList || matchesPattern;
257
+ if (isDefault) {
258
+ posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
259
+ }
260
+ }
261
+ else {
262
+ // Table format: | Status | value | — apply the same preserve-authored guard
263
+ // as the inline branch: only overwrite a known template default.
264
+ // (Finding 2 code-review: the table branch was unconditional before this fix.)
265
+ const existingStatus = (0, state_document_cjs_1.stateExtractField)(posBody, 'Status');
266
+ const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
267
+ const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
268
+ const isDefault = !existingStatus || isInList || matchesPattern;
269
+ if (isDefault) {
270
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', fields.status);
271
+ if (replaced !== null)
272
+ posBody = replaced;
273
+ }
274
+ }
275
+ }
276
+ if (fields.lastActivity) {
277
+ if (/^Last activity:/im.test(posBody)) {
278
+ // Inline format — only replace when the existing value is a known template
279
+ // default (a bare ISO date). Executor-authored narrative prose is preserved.
280
+ const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
281
+ const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
282
+ // A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
283
+ // A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
284
+ // was authored by the executor and must be preserved.
285
+ const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
286
+ const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
287
+ const isDefault = !existingActivity || isDateShape || inList;
288
+ if (isDefault) {
289
+ posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
290
+ }
291
+ }
292
+ else {
293
+ // Table format — apply the same preserve-authored guard as the inline branch:
294
+ // only overwrite a bare ISO date or a known default; preserve narrative prose.
295
+ // (Finding 2 code-review: the table branch was unconditional before this fix.)
296
+ const existingActivity = (0, state_document_cjs_1.stateExtractField)(posBody, 'Last Activity')
297
+ ?? (0, state_document_cjs_1.stateExtractField)(posBody, 'Last activity');
298
+ const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
299
+ const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
300
+ const isDefault = !existingActivity || isDateShape || inList;
301
+ if (isDefault) {
302
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', fields.lastActivity)
303
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', fields.lastActivity);
304
+ if (replaced !== null)
305
+ posBody = replaced;
306
+ }
307
+ }
308
+ }
309
+ if (fields.plan) {
310
+ if (/^Plan:/m.test(posBody)) {
311
+ posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
312
+ }
313
+ else {
314
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', fields.plan);
315
+ if (replaced !== null)
316
+ posBody = replaced;
317
+ }
276
318
  }
277
319
  return content.replace(posPattern, () => `${posMatch[1]}${posBody}`);
278
320
  }
@@ -560,6 +602,101 @@ function cmdStateAddBlocker(cwd, text, raw) {
560
602
  result['created'] = true;
561
603
  output(result, raw, 'true');
562
604
  }
605
+ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
606
+ const statePath = planningPaths(cwd).state;
607
+ if (!node_fs_1.default.existsSync(statePath)) {
608
+ output({ error: 'STATE.md not found' }, raw, undefined);
609
+ return;
610
+ }
611
+ const { phase, action, after, note, note_file, urgent } = options;
612
+ let noteText = undefined;
613
+ try {
614
+ noteText = readTextArgOrFile(cwd, note, note_file, 'note');
615
+ }
616
+ catch (err) {
617
+ output({ added: false, reason: err.message }, raw, 'false');
618
+ return;
619
+ }
620
+ // Reject missing / empty / whitespace-only notes — an evolution entry with no
621
+ // narrative is meaningless and would corrupt the section with a dangling bullet.
622
+ if (!noteText || !noteText.trim()) {
623
+ output({ error: 'note required' }, raw, undefined);
624
+ return;
625
+ }
626
+ // Flatten line breaks so the entry is always a single Markdown bullet. The
627
+ // dedupe + rendering contract is line-oriented; a multiline --note-file would
628
+ // otherwise spill continuation lines outside the bullet and defeat dedupe.
629
+ // Internal spacing (e.g. dollar columns) is preserved.
630
+ const flatNote = noteText.replace(/\s*[\r\n]+\s*/g, ' ').trim();
631
+ const actionText = (action && action.trim()) || 'changed';
632
+ const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
633
+ const urgentText = urgent ? ' (URGENT)' : '';
634
+ const entry = `- Phase ${phase || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
635
+ let duplicate = false;
636
+ let created = false;
637
+ let subsectionCreated = false;
638
+ // The Roadmap Evolution subsection lives under `## Accumulated Context`. Scope
639
+ // every lookup to that section's body so a `### Roadmap Evolution` heading in an
640
+ // unrelated h2 section (or a fenced example) can never be matched or mutated.
641
+ // The accBody lookahead stops only at the next h2 (`\n##[^#]`), so nested h3
642
+ // subsections stay inside the captured Accumulated Context body.
643
+ // Section boundaries mirror the sibling handlers (add-decision/add-blocker):
644
+ // a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed,
645
+ // so following sections are preserved without data loss (see the CRLF test).
646
+ readModifyWriteStateMd(statePath, (content) => {
647
+ const accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i;
648
+ const accMatch = content.match(accPattern);
649
+ if (accMatch) {
650
+ const accHeader = accMatch[1];
651
+ const accBody = accMatch[2];
652
+ // Find `### Roadmap Evolution` WITHIN the Accumulated Context body only.
653
+ // Bounded by the next h3/h2 or the end of the section body.
654
+ const subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i;
655
+ const subMatch = accBody.match(subPattern);
656
+ if (subMatch) {
657
+ let subBody = subMatch[2];
658
+ // Dedupe: exact (trimmed) line already present is a no-op replay.
659
+ if (subBody.split('\n').some((line) => line.trim() === entry.trim())) {
660
+ duplicate = true;
661
+ return content;
662
+ }
663
+ subBody = subBody.replace(/None yet\.?\s*\n?/gi, '');
664
+ subBody = subBody.trimEnd() + '\n' + entry + '\n';
665
+ const newAccBody = accBody.replace(subPattern, (_m, header) => `${header}${subBody}`);
666
+ return content.replace(accPattern, () => `${accHeader}${newAccBody}`);
667
+ }
668
+ // Subsection missing — append it at the end of the Accumulated Context body.
669
+ subsectionCreated = true;
670
+ const trimmedAcc = accBody.trimEnd();
671
+ const block = `${trimmedAcc ? `${trimmedAcc}\n\n` : ''}### Roadmap Evolution\n\n${entry}\n`;
672
+ return content.replace(accPattern, () => `${accHeader}${block}`);
673
+ }
674
+ // No `## Accumulated Context` — DWIM: create both at end of file.
675
+ // Mirrors the add-decision / add-blocker auto-create behavior.
676
+ created = true;
677
+ subsectionCreated = true;
678
+ const scaffold = [
679
+ '',
680
+ '## Accumulated Context',
681
+ '',
682
+ '### Roadmap Evolution',
683
+ '',
684
+ entry,
685
+ '',
686
+ ].join('\n');
687
+ return content.trimEnd() + '\n' + scaffold;
688
+ }, cwd);
689
+ if (duplicate) {
690
+ output({ added: false, reason: 'duplicate', entry }, raw, 'false');
691
+ return;
692
+ }
693
+ const result = { added: true, entry };
694
+ if (created)
695
+ result['created'] = true;
696
+ if (subsectionCreated)
697
+ result['subsection_created'] = true;
698
+ output(result, raw, 'true');
699
+ }
563
700
  function cmdStateResolveBlocker(cwd, text, raw) {
564
701
  const statePath = planningPaths(cwd).state;
565
702
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -679,7 +816,9 @@ function cmdStateRecordSession(cwd, options, raw) {
679
816
  // newly-written Stopped at / Resume file end up in the second (invisible) block.
680
817
  // Fix: when a `## Session` heading already exists, normalize THAT block in place
681
818
  // (insert / replace canonical bold-label lines within the existing section).
682
- // Only append a brand-new section when NO `## Session` heading exists at all.
819
+ // A `## Session Continuity` heading (bootstrap shape) is handled additively —
820
+ // missing canonical fields are inserted while the heading and any prose are
821
+ // preserved (#1101). Only append a brand-new section when NEITHER heading exists.
683
822
  const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
684
823
  const needsStoppedAt = options.stopped_at && !updated.includes('Stopped At');
685
824
  const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
@@ -689,9 +828,14 @@ function cmdStateRecordSession(cwd, options, raw) {
689
828
  ? options.resume_file
690
829
  : 'None';
691
830
  const stoppedAtValue = options.stopped_at || 'None';
692
- // Determine whether a ## Session heading already exists in the body.
693
- const existingSessionHeading = /^## Session\s*$/im.test(content);
694
- if (existingSessionHeading) {
831
+ // Determine whether a session heading already exists in the body. The
832
+ // canonical normalized form is `## Session`; the bootstrap templates
833
+ // (workstream.cts, gsd2-import.cts, templates/state.md) instead emit
834
+ // `## Session Continuity`. Treat each separately so we never append a
835
+ // duplicate section alongside an existing one.
836
+ const existingCanonicalSession = /^## Session[ \t]*$/im.test(content);
837
+ const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content);
838
+ if (existingCanonicalSession) {
695
839
  // Normalize in place: replace the ENTIRE BODY of the existing ## Session
696
840
  // section (heading + all content up to the next ## heading or EOF) with
697
841
  // canonical bold-label lines. The negative-lookahead per-line pattern
@@ -708,8 +852,31 @@ function cmdStateRecordSession(cwd, options, raw) {
708
852
  '',
709
853
  ].join('\n'));
710
854
  }
855
+ else if (existingSessionContinuity) {
856
+ // #1101: a `## Session Continuity` section already exists (bootstrap
857
+ // shape). Previously this fell through to the append branch and created
858
+ // a SECOND `## Session` block — a duplicate. Instead, insert only the
859
+ // canonical fields that are still missing, right after the heading,
860
+ // preserving the `## Session Continuity` heading and ALL existing lines
861
+ // (e.g. prose like "Next recommended action"). Fields already updated in
862
+ // place above (needs* false) are not re-inserted. A function replacement
863
+ // is used so `$`-bearing caller values are inserted literally (#3454).
864
+ const linesToInsert = [];
865
+ if (needsLastSession)
866
+ linesToInsert.push(`**Last session:** ${now}`);
867
+ if (needsStoppedAt)
868
+ linesToInsert.push(`**Stopped at:** ${stoppedAtValue}`);
869
+ if (needsResumeFile)
870
+ linesToInsert.push(`**Resume file:** ${resumeValue}`);
871
+ if (linesToInsert.length > 0) {
872
+ // Case-insensitive to match the `existingSessionContinuity` detection
873
+ // above (#1101 review F3) — otherwise a lowercase heading would detect
874
+ // but no-op the insert while still reporting the fields as updated.
875
+ content = content.replace(/^(## Session Continuity[ \t]*\n)/im, (_m, heading) => heading + linesToInsert.join('\n') + '\n');
876
+ }
877
+ }
711
878
  else {
712
- // No ## Session heading exists at all — append a new canonical section.
879
+ // No session heading exists at all — append a new canonical section.
713
880
  const scaffold = [
714
881
  '',
715
882
  '## Session',
@@ -741,6 +908,20 @@ function cmdStateRecordSession(cwd, options, raw) {
741
908
  output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
742
909
  }
743
910
  }
911
+ /**
912
+ * Match the session section body from a STATE.md body. #1101: recognise the
913
+ * bootstrap `## Session Continuity` heading but PREFER the normalized `## Session`
914
+ * block when both exist (legacy duplicate files), so the reader agrees with the
915
+ * writer (which updates `## Session` first). `(?:^|\n)` line-anchors (kept out of
916
+ * `/m` so `$` stays end-of-string for the `(?=\n##|$)` section boundary), which
917
+ * excludes an h3 `### Session Continuity`; the trailing-` Archive` boundary still
918
+ * excludes `## Session Continuity Archive` (preserving the #2444 scoping).
919
+ * Returns the match whose group 1 is the section body, or null.
920
+ */
921
+ function matchSessionSection(body) {
922
+ return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i)
923
+ || body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i);
924
+ }
744
925
  function cmdStateSnapshot(cwd, raw) {
745
926
  const statePath = planningPaths(cwd).state;
746
927
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -816,7 +997,9 @@ function cmdStateSnapshot(cwd, raw) {
816
997
  stopped_at: null,
817
998
  resume_file: null,
818
999
  };
819
- const sessionMatch = body.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
1000
+ // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
1001
+ // `## Session Continuity` heading. See matchSessionSection for the anchoring.
1002
+ const sessionMatch = matchSessionSection(body);
820
1003
  if (sessionMatch) {
821
1004
  const sessionSection = sessionMatch[1];
822
1005
  // Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
@@ -872,7 +1055,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
872
1055
  // historical "Stopped at:" prose elsewhere in the body (e.g. in a
873
1056
  // Session Continuity Archive section) never overwrites the current value.
874
1057
  // Fall back to full-body search only when no ## Session section exists.
875
- const sessionSectionMatch = bodyContent.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
1058
+ // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
1059
+ // `## Session Continuity` heading. See matchSessionSection for the anchoring.
1060
+ const sessionSectionMatch = matchSessionSection(bodyContent);
876
1061
  const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent;
877
1062
  const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
878
1063
  const pausedAt = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Paused At');
@@ -1142,6 +1327,19 @@ function acquireStateLock(statePath, clock) {
1142
1327
  const staleThresholdMs = 10000;
1143
1328
  const maxWaitMs = 30000;
1144
1329
  const startedAt = clock.now();
1330
+ // Shared helper: check the time budget then back off with jitter before the
1331
+ // next retry. Both the EEXIST contention path and the recoverable-errno path
1332
+ // must go through this so neither can busy-spin (#1217).
1333
+ const checkBudgetAndSleep = (context) => {
1334
+ if (clock.now() - startedAt >= maxWaitMs) {
1335
+ const e = new Error('acquireStateLock: ' + lockPath + ' ' + context + ' for ' +
1336
+ (clock.now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)');
1337
+ e.lockBudgetExceeded = true;
1338
+ throw e;
1339
+ }
1340
+ const jitter = Math.floor(Math.random() * 50);
1341
+ clock.sleep(retryDelay + jitter);
1342
+ };
1145
1343
  while (true) {
1146
1344
  try {
1147
1345
  const fd = node_fs_1.default.openSync(lockPath, node_fs_1.default.constants.O_CREAT | node_fs_1.default.constants.O_EXCL | node_fs_1.default.constants.O_WRONLY);
@@ -1153,9 +1351,11 @@ function acquireStateLock(statePath, clock) {
1153
1351
  }
1154
1352
  catch (err) {
1155
1353
  // Transient filesystem errors (Docker overlay-fs, NFS, OS signals, AV scanners)
1156
- // are recoverable — retry the acquisition loop rather than propagating.
1354
+ // are recoverable — retry with the same budget + backoff as the EEXIST path so
1355
+ // a permanently-failing errno cannot busy-spin at 100% CPU (#1217).
1157
1356
  // See ACQUIRE_LOCK_RETRY_ERRNOS for the full list and rationale.
1158
1357
  if (ACQUIRE_LOCK_RETRY_ERRNOS.has(err.code)) {
1358
+ checkBudgetAndSleep(err.code + ' persisted');
1159
1359
  continue;
1160
1360
  }
1161
1361
  if (err.code !== 'EEXIST')
@@ -1166,22 +1366,39 @@ function acquireStateLock(statePath, clock) {
1166
1366
  try {
1167
1367
  const stat = node_fs_1.default.statSync(lockPath);
1168
1368
  if ((clock).now() - stat.mtimeMs > staleThresholdMs) {
1369
+ let removed = false;
1169
1370
  try {
1170
1371
  node_fs_1.default.unlinkSync(lockPath);
1372
+ removed = true;
1373
+ }
1374
+ catch { /* swallow: bounded below */ }
1375
+ if (removed) {
1376
+ // Successful steal — retry immediately to grab the just-freed lock.
1377
+ // Must NOT call checkBudgetAndSleep here: a throw-after-delete would
1378
+ // corrupt the filesystem state, and the budget is already bounded on
1379
+ // the next iteration's EEXIST or open attempt (#1217 regression fix).
1380
+ continue;
1171
1381
  }
1172
- catch { /* already gone */ }
1382
+ // Persistent unlinkSync failure — apply budget + backoff so it cannot
1383
+ // busy-spin (#1217).
1384
+ checkBudgetAndSleep('stale lock removal failed');
1173
1385
  continue;
1174
1386
  }
1175
1387
  }
1176
- catch {
1177
- continue; /* released between EEXIST and stat */
1178
- }
1179
- if ((clock).now() - startedAt >= maxWaitMs) {
1180
- throw new Error('acquireStateLock: ' + lockPath + ' held by live process for ' +
1181
- ((clock).now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)');
1388
+ catch (err) {
1389
+ // Re-throw a budget-exceeded error from the unlinkSync failure path above
1390
+ // unchanged — its message already names the real cause ("stale lock removal
1391
+ // failed") and double-wrapping it would replace that with the misleading
1392
+ // "statSync failed after EEXIST" context string (#1217 diagnostic fix).
1393
+ if (err?.lockBudgetExceeded)
1394
+ throw err;
1395
+ // statSync failed — lock was likely released between our EEXIST and this
1396
+ // stat call. Apply budget + backoff so a persistent statSync failure
1397
+ // cannot busy-spin (#1217).
1398
+ checkBudgetAndSleep('statSync failed after EEXIST');
1399
+ continue;
1182
1400
  }
1183
- const jitter = Math.floor(Math.random() * 50);
1184
- (clock).sleep(retryDelay + jitter);
1401
+ checkBudgetAndSleep('held by live process');
1185
1402
  }
1186
1403
  }
1187
1404
  }
@@ -1256,6 +1473,26 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1256
1473
  // Snapshot the existing progress block BEFORE the transform so we can
1257
1474
  // restore it when resync is false.
1258
1475
  const preFm = resync ? null : extractFrontmatter(content);
1476
+ // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
1477
+ // we can detect whether THIS write changed them. syncStateFrontmatter
1478
+ // re-derives frontmatter status/stopped_at from the body on every write;
1479
+ // when the body's source field was NOT changed by the transform, the
1480
+ // existing frontmatter value (e.g. a hand-set 'completed') must win over
1481
+ // the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
1482
+ // Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
1483
+ // above (null when resync:true) — these are independent snapshots.
1484
+ // Strip frontmatter before calling stateExtractField so the YAML `status:`
1485
+ // key in the frontmatter block cannot shadow the body field we are tracking.
1486
+ const preBody = stripFrontmatter(content);
1487
+ const preFmSnapshot = extractFrontmatter(content);
1488
+ const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
1489
+ // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1490
+ // mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172).
1491
+ // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
1492
+ // Archive prose) must not interfere with the delta comparison.
1493
+ const preSessionMatch = matchSessionSection(preBody);
1494
+ const preSessionScope = preSessionMatch ? preSessionMatch[1] : preBody;
1495
+ const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
1259
1496
  const modified = transformFn(content);
1260
1497
  // Bug #948: no-op guard — if the transform produced no change, do NOT write
1261
1498
  // the file. An unconditional write would bump `last_updated`, reset
@@ -1268,14 +1505,53 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1268
1505
  return;
1269
1506
  }
1270
1507
  let synced = syncStateFrontmatter(modified, cwd);
1271
- if (!resync && preFm && preFm['progress']) {
1508
+ // Compute postFm once and apply BOTH the progress-restore (when !resync)
1509
+ // AND the status/stopped_at preservation (#1230) before reconstructing.
1510
+ // This avoids double-wrapping the frontmatter block.
1511
+ const needsProgressRestore = !resync && preFm && preFm['progress'];
1512
+ // Post-transform body source fields used for the delta comparison (#1230).
1513
+ // 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.
1514
+ // Strip frontmatter so the YAML status key cannot shadow the body field.
1515
+ const postBody = stripFrontmatter(modified);
1516
+ const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
1517
+ // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1518
+ // consistent with the pre-transform snapshot above and buildStateFrontmatter.
1519
+ const postSessionMatch = matchSessionSection(postBody);
1520
+ const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
1521
+ const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
1522
+ let mutated = false;
1523
+ const postFm = extractFrontmatter(synced);
1524
+ if (needsProgressRestore) {
1272
1525
  // Re-apply the curated progress block that syncStateFrontmatter just
1273
1526
  // overwrote with disk-derived values. Only restore keys that were present
1274
1527
  // in the snapshot — this preserves any new non-progress frontmatter fields
1275
1528
  // (e.g., status, current_phase) that syncStateFrontmatter legitimately
1276
1529
  // derived from the updated body.
1277
- const postFm = extractFrontmatter(synced);
1278
1530
  postFm['progress'] = preFm['progress'];
1531
+ mutated = true;
1532
+ }
1533
+ // Bug #1230: preserve existing frontmatter status when this write did NOT
1534
+ // change the body's Status field. A write that doesn't touch Status must
1535
+ // not silently revert a hand-set frontmatter status (e.g. 'completed') to
1536
+ // whatever the stale body Status happens to derive (e.g. 'verifying').
1537
+ // Only apply when the existing frontmatter held a real, non-unknown status.
1538
+ if (postBodyStatus === preBodyStatus &&
1539
+ typeof preFmSnapshot['status'] === 'string' &&
1540
+ preFmSnapshot['status'].length > 0 &&
1541
+ preFmSnapshot['status'] !== 'unknown' &&
1542
+ postFm['status'] !== preFmSnapshot['status']) {
1543
+ postFm['status'] = preFmSnapshot['status'];
1544
+ mutated = true;
1545
+ }
1546
+ // Bug #1230: same delta heuristic for stopped_at.
1547
+ if (postBodyStoppedAt === preBodyStoppedAt &&
1548
+ typeof preFmSnapshot['stopped_at'] === 'string' &&
1549
+ preFmSnapshot['stopped_at'].length > 0 &&
1550
+ postFm['stopped_at'] !== preFmSnapshot['stopped_at']) {
1551
+ postFm['stopped_at'] = preFmSnapshot['stopped_at'];
1552
+ mutated = true;
1553
+ }
1554
+ if (mutated) {
1279
1555
  const yamlStr = reconstructFrontmatter(postFm);
1280
1556
  const body = stripFrontmatter(synced);
1281
1557
  synced = `---\n${yamlStr}\n---\n\n${body}`;
@@ -1344,74 +1620,89 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1344
1620
  const today = clock_cjs_1.realClock.today();
1345
1621
  const updated = [];
1346
1622
  readModifyWriteStateMd(statePath, (content) => {
1623
+ // Bug #1255: all body-field replacements must operate on the body only
1624
+ // (frontmatter stripped), not on the full content. When the full content is
1625
+ // passed to stateReplaceField the YAML `status: planning` key matches the
1626
+ // plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
1627
+ // pipe-table `| Status | Planning |` is never updated and syncStateFrontmatter
1628
+ // re-derives 'planning' from the unchanged body — the status never advances.
1629
+ const existingFm = extractFrontmatter(content);
1630
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
1631
+ let body = stripFrontmatter(content);
1632
+ // Helper to reassemble content for field-replacement checks; callers that
1633
+ // only need to test/replace body fields use `body` directly, and the final
1634
+ // return reassembles the frontmatter block with the updated body.
1635
+ const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
1347
1636
  // Idempotency guard (#3127): if the phase is already mid-flight, do NOT
1348
1637
  // overwrite execution-progress fields (Current Plan, plan body line,
1349
1638
  // Last Activity Description). Only update fields that are safe to
1350
1639
  // refresh on resume (Last Activity date, Status if inconsistent).
1351
1640
  // A phase is considered mid-flight when Status contains 'Executing Phase N'
1352
1641
  // for the current phase number.
1353
- const currentStatus = (0, state_document_cjs_1.stateExtractField)(content, 'Status') || '';
1642
+ // #1255: extract from body (not full content) so the YAML `status:` key
1643
+ // cannot shadow the body Status field.
1644
+ const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || '';
1354
1645
  const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`, 'i').test(currentStatus);
1355
- // Update Status field
1646
+ // Update Status field (body only — #1255)
1356
1647
  const statusValue = `Executing Phase ${phaseNumber}`;
1357
- let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Status', statusValue);
1648
+ let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
1358
1649
  if (result) {
1359
- content = result;
1650
+ body = result;
1360
1651
  updated.push('Status');
1361
1652
  }
1362
1653
  // Update Last Activity (safe to update on resume — tracks when execute-phase ran)
1363
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Activity', today);
1654
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
1364
1655
  if (result) {
1365
- content = result;
1656
+ body = result;
1366
1657
  updated.push('Last Activity');
1367
1658
  }
1368
1659
  if (!isAlreadyExecuting) {
1369
1660
  // First-time execution: set all progress fields
1370
1661
  // Update Last Activity Description
1371
1662
  const activityDesc = `Phase ${phaseNumber} execution started`;
1372
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Activity Description', activityDesc);
1663
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
1373
1664
  if (result) {
1374
- content = result;
1665
+ body = result;
1375
1666
  updated.push('Last Activity Description');
1376
1667
  }
1377
1668
  // Update Current Phase
1378
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Current Phase', String(phaseNumber));
1669
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase', String(phaseNumber));
1379
1670
  if (result) {
1380
- content = result;
1671
+ body = result;
1381
1672
  updated.push('Current Phase');
1382
1673
  }
1383
1674
  // Update Current Phase Name
1384
1675
  if (phaseName) {
1385
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Current Phase Name', phaseName);
1676
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase Name', phaseName);
1386
1677
  if (result) {
1387
- content = result;
1678
+ body = result;
1388
1679
  updated.push('Current Phase Name');
1389
1680
  }
1390
1681
  }
1391
1682
  // Update Current Plan to 1 (starting from the first plan)
1392
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Current Plan', '1');
1683
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', '1');
1393
1684
  if (result) {
1394
- content = result;
1685
+ body = result;
1395
1686
  updated.push('Current Plan');
1396
1687
  }
1397
1688
  // Update Total Plans in Phase
1398
1689
  if (planCount) {
1399
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Total Plans in Phase', String(planCount));
1690
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
1400
1691
  if (result) {
1401
- content = result;
1692
+ body = result;
1402
1693
  updated.push('Total Plans in Phase');
1403
1694
  }
1404
1695
  }
1405
1696
  // Update **Current focus:** body text line (#1104)
1406
1697
  const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
1407
1698
  const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
1408
- if (focusPattern.test(content)) {
1409
- content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
1699
+ if (focusPattern.test(body)) {
1700
+ body = body.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
1410
1701
  updated.push('Current focus');
1411
1702
  }
1412
1703
  // Update ## Current Position section (#1104, #1365)
1413
1704
  const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1414
- const positionMatch = content.match(positionPattern);
1705
+ const positionMatch = body.match(positionPattern);
1415
1706
  if (positionMatch) {
1416
1707
  const header = positionMatch[1];
1417
1708
  let posBody = positionMatch[2];
@@ -1421,7 +1712,13 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1421
1712
  posBody = posBody.replace(/^Phase:.*$/m, newPhase);
1422
1713
  }
1423
1714
  else {
1424
- posBody = newPhase + '\n' + posBody;
1715
+ // Pipe-table format in Current Position (#1257): update the | Phase | … |
1716
+ // cell rather than prepending a spurious inline `Phase:` line (which left
1717
+ // the table cell stale). Mirrors the Status/Last-activity table branches.
1718
+ const phaseValue = `${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
1719
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', phaseValue);
1720
+ if (replaced !== null)
1721
+ posBody = replaced;
1425
1722
  }
1426
1723
  // Update or insert Plan line
1427
1724
  const newPlan = `Plan: 1 of ${planCount || '?'}`;
@@ -1429,19 +1726,39 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1429
1726
  posBody = posBody.replace(/^Plan:.*$/m, newPlan);
1430
1727
  }
1431
1728
  else {
1432
- posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
1729
+ // Pipe-table format in Current Position (#1257): update the | Plan | … |
1730
+ // cell rather than appending after a prepended inline line.
1731
+ const planValue = `1 of ${planCount || '?'}`;
1732
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', planValue);
1733
+ if (replaced !== null)
1734
+ posBody = replaced;
1433
1735
  }
1434
1736
  // Update Status line if present
1435
1737
  const newStatus = `Status: Executing Phase ${phaseNumber}`;
1436
1738
  if (/^Status:/m.test(posBody)) {
1437
1739
  posBody = posBody.replace(/^Status:.*$/m, newStatus);
1438
1740
  }
1741
+ else {
1742
+ // Pipe-table format in Current Position (#1255)
1743
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Executing Phase ${phaseNumber}`);
1744
+ if (replaced !== null)
1745
+ posBody = replaced;
1746
+ }
1439
1747
  // Update Last activity line if present
1440
1748
  const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
1441
1749
  if (/^Last activity:/im.test(posBody)) {
1442
1750
  posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
1443
1751
  }
1444
- content = content.replace(positionPattern, () => `${header}${posBody}`);
1752
+ else {
1753
+ // Pipe-table format in Current Position (#1255)
1754
+ // Value must match the inline branch (date + narrative), not bare date.
1755
+ const activityValue = `${today} -- Phase ${phaseNumber} execution started`;
1756
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
1757
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
1758
+ if (replaced !== null)
1759
+ posBody = replaced;
1760
+ }
1761
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
1445
1762
  updated.push('Current Position');
1446
1763
  }
1447
1764
  }
@@ -1449,19 +1766,29 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1449
1766
  // Resume path: only update Last activity timestamp in Current Position
1450
1767
  // (do not touch Plan:, stopped_at, progress.percent, or plan counter)
1451
1768
  const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1452
- const positionMatch = content.match(positionPattern);
1769
+ const positionMatch = body.match(positionPattern);
1453
1770
  if (positionMatch) {
1454
1771
  const header = positionMatch[1];
1455
1772
  let posBody = positionMatch[2];
1456
1773
  const resumeActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution resumed (wave continue)`;
1457
1774
  if (/^Last activity:/im.test(posBody)) {
1458
1775
  posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
1459
- content = content.replace(positionPattern, () => `${header}${posBody}`);
1776
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
1460
1777
  updated.push('Last activity (resume)');
1461
1778
  }
1779
+ else {
1780
+ // Pipe-table format in Current Position (#1255)
1781
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', resumeActivity)
1782
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', resumeActivity);
1783
+ if (replaced !== null) {
1784
+ posBody = replaced;
1785
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
1786
+ updated.push('Last activity (resume)');
1787
+ }
1788
+ }
1462
1789
  }
1463
1790
  }
1464
- return content;
1791
+ return reassemble(body);
1465
1792
  }, cwd);
1466
1793
  output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
1467
1794
  }
@@ -1561,43 +1888,54 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
1561
1888
  // doing so tramples curated/known-good counters. Route through the body-only
1562
1889
  // write contract (resync:false), the same guard state.update uses. (#500 RC1)
1563
1890
  readModifyWriteStateMd(statePath, (content) => {
1891
+ // Bug #1257: all body-field replacements must operate on the body only
1892
+ // (frontmatter stripped), not on the full content. When the full content is
1893
+ // passed to stateReplaceFieldIfTemplate the YAML `status: planning` key matches
1894
+ // the plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
1895
+ // pipe-table `| Status | Planning |` cell is never updated and syncStateFrontmatter
1896
+ // re-derives 'planning' from the unchanged body — the status never advances.
1897
+ // (Mirrors the begin/complete-phase fix from #1255/#1256.)
1898
+ const existingFm = extractFrontmatter(content);
1899
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
1900
+ let body = stripFrontmatter(content);
1901
+ const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
1564
1902
  // Update Status — only when the existing value is a known template default
1565
1903
  // (Knuth invariant: preserve executor-authored values).
1566
- const newContent = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Status', statusDefaults, 'Ready to execute');
1567
- if (newContent !== content) {
1568
- content = newContent;
1904
+ const newBody = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute');
1905
+ if (newBody !== body) {
1906
+ body = newBody;
1569
1907
  updated.push('Status');
1570
1908
  }
1571
1909
  // Update Total Plans in Phase
1572
1910
  if (planCount !== null && planCount !== undefined) {
1573
- const result = (0, state_document_cjs_1.stateReplaceField)(content, 'Total Plans in Phase', String(planCount));
1911
+ const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(planCount));
1574
1912
  if (result) {
1575
- content = result;
1913
+ body = result;
1576
1914
  updated.push('Total Plans in Phase');
1577
1915
  }
1578
1916
  }
1579
1917
  // Update Last Activity — only when the existing value is a known template default
1580
1918
  {
1581
- const after = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Last Activity', lastActivityDefaults, today);
1582
- if (after !== content) {
1583
- content = after;
1919
+ const after = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today);
1920
+ if (after !== body) {
1921
+ body = after;
1584
1922
  updated.push('Last Activity');
1585
1923
  }
1586
1924
  }
1587
1925
  // Update Last Activity Description
1588
1926
  {
1589
- const result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
1927
+ const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
1590
1928
  if (result) {
1591
- content = result;
1929
+ body = result;
1592
1930
  updated.push('Last Activity Description');
1593
1931
  }
1594
1932
  }
1595
1933
  // Update Current Position section
1596
- content = updateCurrentPositionFields(content, {
1934
+ body = updateCurrentPositionFields(body, {
1597
1935
  status: 'Ready to execute',
1598
1936
  lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
1599
1937
  });
1600
- return content;
1938
+ return reassemble(body);
1601
1939
  }, cwd, { resync: false });
1602
1940
  output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
1603
1941
  }
@@ -2078,29 +2416,35 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2078
2416
  const updated = [];
2079
2417
  readModifyWriteStateMd(statePath, (content) => {
2080
2418
  const currentPhase = resolvedPhase;
2081
- // Update Status field
2419
+ // Bug #1255: operate on body only so the YAML frontmatter `status:` key
2420
+ // cannot shadow the body Status field (pipe-table or inline).
2421
+ const existingFm = extractFrontmatter(content);
2422
+ const hasFrontmatter = Object.keys(existingFm).length > 0;
2423
+ let body = stripFrontmatter(content);
2424
+ const reassemble = (b) => hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` : b;
2425
+ // Update Status field (body only — #1255)
2082
2426
  const statusValue = `Phase ${currentPhase} complete`;
2083
- let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Status', statusValue);
2427
+ let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
2084
2428
  if (result) {
2085
- content = result;
2429
+ body = result;
2086
2430
  updated.push('Status');
2087
2431
  }
2088
2432
  // Update Last Activity date
2089
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Activity', today);
2433
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
2090
2434
  if (result) {
2091
- content = result;
2435
+ body = result;
2092
2436
  updated.push('Last Activity');
2093
2437
  }
2094
2438
  // Update Last Activity Description
2095
2439
  const activityDesc = `Phase ${currentPhase} marked complete`;
2096
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Activity Description', activityDesc);
2440
+ result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
2097
2441
  if (result) {
2098
- content = result;
2442
+ body = result;
2099
2443
  updated.push('Last Activity Description');
2100
2444
  }
2101
2445
  // Update ## Current Position section
2102
2446
  const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
2103
- const positionMatch = content.match(positionPattern);
2447
+ const positionMatch = body.match(positionPattern);
2104
2448
  if (positionMatch) {
2105
2449
  const header = positionMatch[1];
2106
2450
  let posBody = positionMatch[2];
@@ -2109,20 +2453,42 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2109
2453
  if (/^Phase:/m.test(posBody)) {
2110
2454
  posBody = posBody.replace(/^Phase:.*$/m, newPhase);
2111
2455
  }
2456
+ else {
2457
+ // Pipe-table format in Current Position (#1255)
2458
+ // Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label.
2459
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Phase', `${currentPhase} — COMPLETE`);
2460
+ if (replaced !== null)
2461
+ posBody = replaced;
2462
+ }
2112
2463
  // Update Status line if present
2113
2464
  const newStatus = `Status: Phase ${currentPhase} complete`;
2114
2465
  if (/^Status:/m.test(posBody)) {
2115
2466
  posBody = posBody.replace(/^Status:.*$/m, newStatus);
2116
2467
  }
2468
+ else {
2469
+ // Pipe-table format in Current Position (#1255)
2470
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', `Phase ${currentPhase} complete`);
2471
+ if (replaced !== null)
2472
+ posBody = replaced;
2473
+ }
2117
2474
  // Update Last activity line if present
2118
2475
  const newActivity = `Last activity: ${today} -- Phase ${currentPhase} marked complete`;
2119
2476
  if (/^Last activity:/im.test(posBody)) {
2120
2477
  posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
2121
2478
  }
2122
- content = content.replace(positionPattern, () => `${header}${posBody}`);
2479
+ else {
2480
+ // Pipe-table format in Current Position (#1255)
2481
+ // Value must match the inline branch (date + narrative), not bare date.
2482
+ const activityValue = `${today} -- Phase ${currentPhase} marked complete`;
2483
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
2484
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
2485
+ if (replaced !== null)
2486
+ posBody = replaced;
2487
+ }
2488
+ body = body.replace(positionPattern, () => `${header}${posBody}`);
2123
2489
  updated.push('Current Position');
2124
2490
  }
2125
- return content;
2491
+ return reassemble(body);
2126
2492
  }, cwd);
2127
2493
  output({ updated, phase: resolvedPhase }, raw, updated.length > 0 ? 'true' : 'false');
2128
2494
  }
@@ -2146,6 +2512,7 @@ module.exports = {
2146
2512
  cmdStateUpdateProgress,
2147
2513
  cmdStateAddDecision,
2148
2514
  cmdStateAddBlocker,
2515
+ cmdStateAddRoadmapEvolution,
2149
2516
  cmdStateResolveBlocker,
2150
2517
  cmdStateRecordSession,
2151
2518
  cmdStateSnapshot,