@opengsd/gsd-core 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.compact.md +5 -5
  4. package/agents/gsd-code-fixer.md +9 -4
  5. package/agents/gsd-code-reviewer.compact.md +49 -8
  6. package/agents/gsd-code-reviewer.md +53 -14
  7. package/agents/gsd-executor.md +8 -4
  8. package/commands/gsd/update.md +5 -2
  9. package/gsd-core/bin/gsd-tools.cjs +73 -23
  10. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  11. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  12. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  13. package/gsd-core/bin/lib/commands.cjs +27 -17
  14. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  15. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  16. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  17. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  18. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  19. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  20. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  21. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  22. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  23. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  24. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  25. package/gsd-core/bin/lib/init.cjs +180 -67
  26. package/gsd-core/bin/lib/io.cjs +6 -0
  27. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  28. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  29. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  30. package/gsd-core/bin/lib/milestone.cjs +18 -50
  31. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  32. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +609 -35
  34. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  35. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  36. package/gsd-core/bin/lib/phase.cjs +2990 -666
  37. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  38. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  39. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  40. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  41. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  42. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  43. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  44. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  45. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  46. package/gsd-core/bin/lib/roadmap-parser.cjs +730 -144
  47. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  48. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  49. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  50. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  51. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  52. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  53. package/gsd-core/bin/lib/state-document.cjs +332 -23
  54. package/gsd-core/bin/lib/state-transition.cjs +39 -10
  55. package/gsd-core/bin/lib/state.cjs +498 -179
  56. package/gsd-core/bin/lib/template.cjs +2 -2
  57. package/gsd-core/bin/lib/validate.cjs +3 -2
  58. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  59. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  60. package/gsd-core/bin/lib/verify.cjs +2 -2
  61. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  62. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  63. package/gsd-core/references/cicd-review-patterns.md +45 -0
  64. package/gsd-core/references/necessity-review.md +48 -0
  65. package/gsd-core/workflows/code-review-fix.md +46 -5
  66. package/gsd-core/workflows/code-review.md +3 -0
  67. package/gsd-core/workflows/execute-phase.md +1 -1
  68. package/gsd-core/workflows/execute-plan.md +1 -1
  69. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  70. package/gsd-core/workflows/plan-phase.md +1 -1
  71. package/gsd-core/workflows/pr-branch.md +2 -0
  72. package/gsd-core/workflows/reapply-patches.md +104 -0
  73. package/gsd-core/workflows/review.md +54 -141
  74. package/gsd-core/workflows/secure-phase.md +1 -1
  75. package/gsd-core/workflows/settings-advanced.md +57 -9
  76. package/gsd-core/workflows/ship.md +12 -0
  77. package/hooks/dist/gsd-graphify-update.sh +6 -0
  78. package/hooks/dist/gsd-statusline.js +3 -2
  79. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  80. package/hooks/dist/gsd-workflow-guard.js +2 -1
  81. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  82. package/hooks/dist/lib/git-probe.js +16 -0
  83. package/hooks/gsd-graphify-update.sh +6 -0
  84. package/hooks/gsd-statusline.js +3 -2
  85. package/hooks/gsd-windsurf-pre-write.js +4 -4
  86. package/hooks/gsd-workflow-guard.js +2 -1
  87. package/hooks/gsd-worktree-path-guard.js +4 -4
  88. package/hooks/lib/git-probe.js +16 -0
  89. package/package.json +3 -3
  90. package/pi/package-agents/gsd-code-fixer.md +9 -4
  91. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  92. package/pi/package-agents/gsd-executor.md +8 -4
  93. package/pi/package-agents/manifest.json +3 -3
  94. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  95. package/scripts/ci-next-health.cjs +246 -23
  96. package/scripts/docs-guard-registry.cjs +2 -0
  97. package/scripts/gen-features.cjs +14 -2
  98. package/scripts/lib/drift-scan.cjs +27 -0
  99. package/scripts/lib/macos-conformance-tier.generated.cjs +6 -0
  100. package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
  101. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  102. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  103. package/scripts/mutation-matrix.cjs +23 -1
  104. package/scripts/sync-next-version.cjs +38 -6
  105. package/skills/gsd-update/SKILL.md +5 -2
  106. package/vscode/package.json +1 -1
@@ -29,10 +29,15 @@ const configLoaderMod = require("./config-loader.cjs");
29
29
  const { loadConfig } = configLoaderMod;
30
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
31
  const phaseIdMod = require("./phase-id.cjs");
32
- const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, matchPhaseDirs, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
32
+ const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
33
33
  // #2761 M3: owns the bracket milestone intro and canonical pad2 spelling.
34
34
  bracketMilestoneIntroSrcFor, } = phaseIdMod;
35
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const phaseIdDisplayMod = require("./phase-id-display.cjs");
37
+ // #4304: phaseDisplayFor below renders through this adapter
38
+ // instead of re-deriving the bracket numeric grammar locally.
39
+ const { renderBracketPhaseDisplay } = phaseIdDisplayMod;
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
41
  const roadmapParserMod = require("./roadmap-parser.cjs");
37
42
  // #3642: hasMilestoneSectioning no longer consumed here — its >=2 semantics answered sibling conflation, but this branch asks asserted-vs-section (>=1). It stays exported from roadmap-parser.cjs for its unit pins.
38
43
  const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasAnyMilestoneSection, classifyMilestoneScalar } = roadmapParserMod;
@@ -55,6 +60,29 @@ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateC
55
60
  function isUnparseableFrontmatter(existingFm) {
56
61
  return existingFm[FRONTMATTER_UNPARSEABLE] === true;
57
62
  }
63
+ /**
64
+ * Human-facing phase label for STATE.md descriptions. Identity-bearing fields
65
+ * such as Current Phase and the Current Position Phase row stay bare so their
66
+ * established readers remain byte-compatible; only descriptive text uses this
67
+ * renderer.
68
+ */
69
+ function phaseDisplayFor(cwd, phaseNumber) {
70
+ if (resolvePhaseIdConvention(cwd) !== 'bracket')
71
+ return `Phase ${phaseNumber}`;
72
+ const config = loadConfig(cwd);
73
+ const project = typeof config['project_code'] === 'string' ? config['project_code'].trim() : '';
74
+ if (!project) {
75
+ error('phase_id_convention is "bracket" but project_code is missing in .planning/config.json');
76
+ }
77
+ const info = getMilestoneInfo(cwd);
78
+ const version = info.value?.version ?? '';
79
+ const display = renderBracketPhaseDisplay(version, phaseNumber, project);
80
+ if (display === null) {
81
+ error(`phase_id_convention is "bracket" but phase ${phaseNumber} cannot be rendered ` +
82
+ `(the active milestone ${JSON.stringify(version)} cannot be resolved, or the phase number is invalid)`);
83
+ }
84
+ return display;
85
+ }
58
86
  // eslint-disable-next-line @typescript-eslint/no-require-imports
59
87
  const scanPhasePlans = require("./plan-scan.cjs");
60
88
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -76,7 +104,7 @@ const planningScopeMod = require("./planning-scope.cjs");
76
104
  const { SCOPE } = planningScopeMod;
77
105
  // eslint-disable-next-line @typescript-eslint/no-require-imports
78
106
  const phaseLocatorMod = require("./phase-locator.cjs");
79
- const { listMilestonePhaseDirs } = phaseLocatorMod;
107
+ const { phaseDirectoryOptions, listMilestonePhaseDirs, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup } = phaseLocatorMod;
80
108
  // eslint-disable-next-line @typescript-eslint/no-require-imports
81
109
  const stateTransitionMod = require("./state-transition.cjs");
82
110
  // #3873 (ADR-3473 §8.8): FRONTMATTER_KEY_TO_BODY_LABEL below is now a
@@ -92,6 +120,46 @@ const project_root_cjs_1 = require("./project-root.cjs");
92
120
  // eslint-disable-next-line @typescript-eslint/no-require-imports
93
121
  const milestoneLockMod = require("./milestone-lock.cjs");
94
122
  const { transitionCore, applyStatePreservation, sliceCurrentPositionSection, stateReplaceProgressPercent, formatProgressMachineSegment } = stateTransitionMod;
123
+ function replaceStateBodyField(content, statePath, field, value) {
124
+ const { body, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
125
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
126
+ return replaced === null ? content : reassemble(replaced);
127
+ }
128
+ /**
129
+ * Rewrite a handler-authored field inside Current Position only when it still
130
+ * carries the exact value the transition just emitted. The equality guard
131
+ * preserves executor-authored prose under the transition module's Knuth rule.
132
+ */
133
+ function replaceCurrentPositionFieldValue(content, statePath, field, expected, value) {
134
+ const { body, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
135
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body);
136
+ const index = headings.findIndex((heading) => heading.level === 2 && /^current\s+position$/i.test(heading.text));
137
+ if (index === -1)
138
+ return content;
139
+ const heading = headings[index];
140
+ const lines = body.split('\n');
141
+ const headingLine = lines[heading.line - 1] ?? '';
142
+ const start = heading.offset + headingLine.length + 1;
143
+ let end = body.length;
144
+ for (let next = index + 1; next < headings.length; next++) {
145
+ if (headings[next].level < 2)
146
+ continue;
147
+ end = headings[next].offset;
148
+ if (end > 0 && body[end - 1] === '\n')
149
+ end -= 1;
150
+ if (end > 0 && body[end - 1] === '\r')
151
+ end -= 1;
152
+ end = Math.max(start, end);
153
+ break;
154
+ }
155
+ const section = body.slice(start, end);
156
+ if ((0, state_document_cjs_1.stateExtractField)(section, field) !== expected)
157
+ return content;
158
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(section, field, value);
159
+ if (replaced === null)
160
+ return content;
161
+ return reassemble(body.slice(0, start) + replaced + body.slice(end));
162
+ }
95
163
  // #3699: the frontmatter-key <-> body-field routing behind `state update`'s
96
164
  // failure explanation, and the classification table it falls back to.
97
165
  const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
@@ -374,6 +442,11 @@ function cmdStatePatch(cwd, patches, raw) {
374
442
  if (!fieldCheck.valid) {
375
443
  error(`state patch: ${fieldCheck.error}`);
376
444
  }
445
+ // #4998: as for `state update` — refused up front rather than listed
446
+ // under `failed` with no reason.
447
+ if (state_document_cjs_1.FIELD_LINE_BREAK_RE.test(`${patches[field]}`)) {
448
+ error(`state patch: the value for "${field}" must be a single line (no CR, LF, U+2028 or U+2029)`);
449
+ }
377
450
  }
378
451
  const statePath = planningPaths(cwd).state;
379
452
  try {
@@ -408,7 +481,20 @@ function cmdStatePatch(cwd, patches, raw) {
408
481
  // `applyStatePreservation` restored that this patch never named at all
409
482
  // (#3345's direction), a case the pre-#3471 version of this command never
410
483
  // covered.
411
- const updated = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields);
484
+ const reconciled = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields, NO_SECTION_TARGETS, true);
485
+ // #4488, as in cmdStateUpdate: a field patchCore matched that already held
486
+ // the requested value changes no byte, so the reconcile above (which asks
487
+ // "what changed") drops it — alone (no snapshot at all) or beside a field
488
+ // that did change. It is updated when the persisted value is the requested
489
+ // one; a value the write pipeline discarded still differs and stays failed
490
+ // (trek-e review 5593733001, Minor 3).
491
+ const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
492
+ const persistedFm = extractFrontmatter(persisted, statePath);
493
+ const persistedBody = stripFrontmatter(persisted);
494
+ const alreadyHeld = precomputed.updated.filter((field) => !reconciled.includes(field)
495
+ && !STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field)
496
+ && readStateTarget(persistedFm, persistedBody, field, NO_SECTION_TARGETS, true)?.trim() === String(patches[field]).trim());
497
+ const updated = [...reconciled, ...alreadyHeld];
412
498
  const updatedSet = new Set(updated);
413
499
  const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
414
500
  const results = { updated, failed };
@@ -476,6 +562,11 @@ function cmdStateUpdate(cwd, field, value) {
476
562
  if (!fieldCheck.valid) {
477
563
  error(`state update: ${fieldCheck.error}`);
478
564
  }
565
+ // #4998: the field writers are single-line and refuse a line break, which
566
+ // the transition would otherwise report as "field not found".
567
+ if (state_document_cjs_1.FIELD_LINE_BREAK_RE.test(value)) {
568
+ error(`state update: the value for "${field}" must be a single line (no CR, LF, U+2028 or U+2029)`);
569
+ }
479
570
  const statePath = planningPaths(cwd).state;
480
571
  try {
481
572
  let updated = false;
@@ -527,7 +618,7 @@ function cmdStateUpdate(cwd, field, value) {
527
618
  // restored during this write that this command never touched at all
528
619
  // (#3345's direction) — reported separately from `updated` because this
529
620
  // command's contract is a single-field boolean, not a per-field array.
530
- const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
621
+ const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields, NO_SECTION_TARGETS, true);
531
622
  // #4488: `updateCore` itself already told us whether it matched the field
532
623
  // (`updated`, captured above `readModifyWriteStateMd` runs it) — that is a
533
624
  // real signal, not a guess. `reconcileReportedFields` answers a DIFFERENT
@@ -711,17 +802,19 @@ function unsummarizedPlansForPositionPhase(cwd, positionPhase) {
711
802
  // as a real answer. This is a lookup of ONE phase token STATE.md names, not
712
803
  // a milestone enumeration, so the unscoped retry is in-contract.
713
804
  const convention = resolvePhaseIdConvention(cwd);
805
+ const lookup = resolvePhaseDirectoryLookup(cwd, positionPhase);
714
806
  const windowed = listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: convention });
715
807
  const candidateDirs = windowed.scope === SCOPE.COMPLETE ? windowed.value : [];
716
808
  // Canonical phase-token → directory matching (phase-id owner, #2562): both
717
809
  // sides of the comparison derived by the same function, never a local regex.
718
- const { matches } = matchPhaseDirs(candidateDirs, positionPhase, convention);
810
+ const directoryContext = phaseDirectoryOptions(cwd);
811
+ const { matches } = matchPhaseDirsForLookup(candidateDirs, lookup, undefined, directoryContext);
719
812
  if (matches.length > 0)
720
813
  return scanOutstanding(phasesDir, matches[0]);
721
814
  const unscoped = listMilestonePhaseDirs(phasesDir);
722
815
  if (unscoped.scope !== SCOPE.COMPLETE)
723
816
  return null;
724
- const retry = matchPhaseDirs(unscoped.value, positionPhase, convention);
817
+ const retry = matchPhaseDirsForLookup(unscoped.value, lookup, undefined, directoryContext);
725
818
  if (retry.matches.length === 0)
726
819
  return null;
727
820
  return scanOutstanding(phasesDir, retry.matches[0]);
@@ -770,7 +863,7 @@ function cmdStateAdvancePlan(cwd, raw) {
770
863
  // silently absorbed.
771
864
  const body = stripFrontmatter(content);
772
865
  const positionScope = matchCurrentPositionSection(body) ?? body;
773
- const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase'), resolvePhaseIdConvention(cwd)).phase;
866
+ const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase'), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)).phase;
774
867
  // #4093: a Current Position section with ZERO labeled fields has no
775
868
  // `Phase:` line either; frontmatter `current_phase` is the documented
776
869
  // survivor of body drift (the reporter's document still carried it, and
@@ -1283,7 +1376,7 @@ function cmdStateAddDecision(cwd, options, raw) {
1283
1376
  if (!phaseId) {
1284
1377
  const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
1285
1378
  const fm = extractFrontmatter(rawState, statePath);
1286
- phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState), resolvePhaseIdConvention(cwd)) ?? undefined;
1379
+ phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)) ?? undefined;
1287
1380
  }
1288
1381
  const entry = `- [Phase ${phaseId || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
1289
1382
  let _added = false;
@@ -1412,8 +1505,14 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
1412
1505
  // Flatten line breaks so the entry is always a single Markdown bullet. The
1413
1506
  // dedupe + rendering contract is line-oriented; a multiline --note-file would
1414
1507
  // otherwise spill continuation lines outside the bullet and defeat dedupe.
1415
- // Internal spacing (e.g. dollar columns) is preserved.
1416
- const flatNote = noteText.replace(/\s*[\r\n]+\s*/g, ' ').trim();
1508
+ // Internal spacing (e.g. dollar columns) is preserved. #4998: every
1509
+ // FIELD_LINE_BREAK_RE character, so a U+2028/U+2029 cannot survive into the
1510
+ // bullet and start a forged `**Field:**` line for the `m`-flag readers.
1511
+ // Split and trimmed rather than one `\s*<break>+\s*` replace, which
1512
+ // backtracks quadratically over a long whitespace run with no break in it
1513
+ // (trek-e review 5608265301, Nit 5); the result is the same.
1514
+ const flatNote = noteText.split(new RegExp(`${state_document_cjs_1.FIELD_LINE_BREAK_RE.source}+`))
1515
+ .map((piece) => piece.trim()).filter(Boolean).join(' ');
1417
1516
  const actionText = (action && action.trim()) || 'changed';
1418
1517
  const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
1419
1518
  const urgentText = urgent ? ' (URGENT)' : '';
@@ -1428,7 +1527,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
1428
1527
  if (!phaseId) {
1429
1528
  const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
1430
1529
  const fm = extractFrontmatter(rawState, statePath);
1431
- phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState), resolvePhaseIdConvention(cwd)) ?? undefined;
1530
+ phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)) ?? undefined;
1432
1531
  }
1433
1532
  const entry = `- Phase ${phaseId || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
1434
1533
  let duplicate = false;
@@ -1598,6 +1697,19 @@ function cmdStateResolveBlocker(cwd, text, raw) {
1598
1697
  declineNoOp(raw, 'resolved', `no blocker matching ${text} found in the Blockers section`, `state resolve-blocker skipped — no blocker matching ${formatDiagnosticToken(text)} found in the Blockers section.`);
1599
1698
  }
1600
1699
  }
1700
+ /**
1701
+ * #4998 review (trek-e 5608265301, Minor 2): at most this many characters of a
1702
+ * wrapped value's continuation are echoed back — it is STATE.md text, and its
1703
+ * length is whatever the file holds.
1704
+ */
1705
+ const SKIPPED_CONTINUATION_MAX_CHARS = 200;
1706
+ function boundedContinuation(text) {
1707
+ if (text.length <= SKIPPED_CONTINUATION_MAX_CHARS)
1708
+ return text;
1709
+ // Never end on half a surrogate pair.
1710
+ const end = /[\uD800-\uDBFF]/.test(text[SKIPPED_CONTINUATION_MAX_CHARS - 1]) ? SKIPPED_CONTINUATION_MAX_CHARS - 1 : SKIPPED_CONTINUATION_MAX_CHARS;
1711
+ return `${text.slice(0, end)}…`;
1712
+ }
1601
1713
  function cmdStateRecordSession(cwd, options, raw) {
1602
1714
  // #4186: a bare invocation is a usage error, not a heartbeat write. The
1603
1715
  // pre-#4186 handler accepted zero arguments and still refreshed
@@ -1611,6 +1723,13 @@ function cmdStateRecordSession(cwd, options, raw) {
1611
1723
  if (!options.stopped_at && (options.resume_file === undefined || options.resume_file === null)) {
1612
1724
  error('stopped-at or resume-file required for state record-session');
1613
1725
  }
1726
+ // #4998: every field this writes is single-line; a line break in a value
1727
+ // would forge sibling lines (U+2028/U+2029 included — the readers' `m`-flag
1728
+ // anchors match there too), so it is refused before anything is touched.
1729
+ for (const [flag, value] of [['--stopped-at', options.stopped_at], ['--resume-file', options.resume_file]]) {
1730
+ if (typeof value === 'string' && state_document_cjs_1.FIELD_LINE_BREAK_RE.test(value))
1731
+ error(`state record-session: ${flag} must be a single line`);
1732
+ }
1614
1733
  const statePath = planningPaths(cwd).state;
1615
1734
  if (!node_fs_1.default.existsSync(statePath)) {
1616
1735
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -1628,31 +1747,95 @@ function cmdStateRecordSession(cwd, options, raw) {
1628
1747
  // ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
1629
1748
  // transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
1630
1749
  const preWriteState = {};
1750
+ // #4998: fields left untouched because their existing value wraps onto the
1751
+ // next line. The writers are single-line, so replacing such a field rewrote
1752
+ // only its first line and orphaned the rest. Which lines continue a value and
1753
+ // which are a separate note directly below it cannot be told apart, so the
1754
+ // write leaves the field whole and says so rather than deleting either.
1755
+ // Also (`line_separator`): a carried value the appended section leaves out.
1756
+ const skippedFields = [];
1757
+ const skipWrapped = (field, before, after) => {
1758
+ const continuation = (0, state_document_cjs_1.editedLineContinuation)(before, after);
1759
+ if (continuation === null)
1760
+ return false;
1761
+ skippedFields.push({ field, reason: 'wrapped_value', continuation: boundedContinuation(continuation) });
1762
+ return true;
1763
+ };
1631
1764
  readModifyWriteStateMd(statePath, (content) => {
1765
+ // #4998 review (trek-e 5608265301, Major 1): every field written here is read
1766
+ // from the session section when there is one (`## Session`, else `## Session
1767
+ // Continuity` — matchSessionSection, state-snapshot, the frontmatter sync), so
1768
+ // it is written there too. A line outside that section is not the field: a
1769
+ // write that landed on it reported success while the synced value stayed
1770
+ // stale. With no session section only `Stopped At` is read from the whole
1771
+ // body (sessionFieldScope); `state-snapshot` reads Last session and Resume
1772
+ // file from a session section alone, so with none they are not written in
1773
+ // place but in the section appended below, as `state update`/`patch` refuse
1774
+ // them (#4998 review, Minor 3). Located on the masked text (same offsets).
1775
+ const readBodyWide = (fieldName) => fieldName.toLowerCase() === 'stopped at';
1776
+ const sessionSection = () => {
1777
+ const masked = (0, state_document_cjs_1.maskFencedLines)(content, { document: true });
1778
+ return (0, markdown_sectionizer_cjs_1.collectSection)(masked, state_document_cjs_1.isSessionHeading, { levelBounded: true })
1779
+ ?? (0, markdown_sectionizer_cjs_1.collectSection)(masked, state_document_cjs_1.isSessionContinuityHeading, { levelBounded: true });
1780
+ };
1781
+ const inSessionScope = (fieldName, write) => {
1782
+ const section = sessionSection();
1783
+ if (!section)
1784
+ return readBodyWide(fieldName) ? (0, state_document_cjs_1.writeOutsideFences)(content, write, { document: true }) : null;
1785
+ const body = (0, state_document_cjs_1.writeOutsideFences)(content.slice(section.bodyStart, section.bodyEnd), write);
1786
+ return body === null ? null : (0, markdown_sectionizer_cjs_1.replaceSection)(content, section, body);
1787
+ };
1788
+ const sessionScopeText = (fieldName) => {
1789
+ const masked = (0, state_document_cjs_1.maskFencedLines)(content, { document: true });
1790
+ const section = sessionSection();
1791
+ if (!section && !readBodyWide(fieldName))
1792
+ return '';
1793
+ return (0, state_document_cjs_1.readableBoldLabels)(section ? masked.slice(section.bodyStart, section.bodyEnd) : masked);
1794
+ };
1632
1795
  // #4763 (1): read the pre-write session record. The capture mirrors the
1633
- // WRITER, not the snapshot reader: stateReplaceField replaces the FIRST
1634
- // case-insensitive label match anywhere in the document, so the capture is
1635
- // document-wide too — scoping it to ## Session would miss an archive-section
1636
- // line the writer actually displaced. Continuation lines join via
1796
+ // WRITER: the first case-insensitive label match in the scope above, so a
1797
+ // line the writer did not displace is not reported as displaced. Continuation lines join via
1637
1798
  // stateFieldContinuation (the sanctioned joiner — stateExtractField alone is
1638
1799
  // first-line-only), so a wrapped multi-line handoff is surfaced whole.
1800
+ // #4998: a line inside a fenced block is an example, not a field — the
1801
+ // writers below skip fences (`writeOutsideFences`), so the capture reads
1802
+ // the same unfenced text they write.
1803
+ // A `**Label**:` line the writer can replace is read too (readableBoldLabels).
1639
1804
  const capturePrior = (fieldName) => {
1640
- const first = (0, state_document_cjs_1.stateExtractField)(content, fieldName);
1805
+ const visible = sessionScopeText(fieldName);
1806
+ const first = (0, state_document_cjs_1.stateExtractField)(visible, fieldName);
1641
1807
  if (first === null)
1642
1808
  return undefined;
1643
- const cont = (0, state_document_cjs_1.stateFieldContinuation)(content, fieldName);
1809
+ const cont = (0, state_document_cjs_1.stateFieldContinuation)(visible, fieldName);
1644
1810
  return cont ? `${first}\n${cont}` : first;
1645
1811
  };
1646
- priorRecord.stoppedAt = capturePrior('Stopped At');
1812
+ // #4998: with no body line, the frontmatter `stopped_at` is the only copy of
1813
+ // the record, and the sync re-derives it from the line inserted below — a
1814
+ // displacement like any other, so it is the prior record too.
1815
+ const fmStoppedAt = extractFrontmatter(content)['stopped_at'];
1816
+ priorRecord.stoppedAt = capturePrior('Stopped At')
1817
+ ?? (typeof fmStoppedAt === 'string' && fmStoppedAt.trim() ? fmStoppedAt.trim() : undefined);
1647
1818
  priorRecord.resumeFile = capturePrior('Resume File');
1819
+ // #4998: every in-place write skips fenced blocks, as the insertion
1820
+ // below does — a fenced example line is never the occurrence replaced.
1821
+ const replaceField = (fieldName, value) => inSessionScope(fieldName, (visible) => (0, state_document_cjs_1.stateReplaceField)(visible, fieldName, value));
1822
+ // The template check reads the field the way the writer finds it: an
1823
+ // authored `**Resume file**: plan.md` read as absent and was reset to None.
1824
+ const replaceFieldIfTemplate = (fieldName, defaults, value) => {
1825
+ const current = (0, state_document_cjs_1.stateExtractField)(sessionScopeText(fieldName), fieldName);
1826
+ if (current !== null && current.trim() !== ''
1827
+ && !defaults.some((d) => d.toLowerCase() === current.trim().toLowerCase()))
1828
+ return content;
1829
+ return inSessionScope(fieldName, (visible) => (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(visible, fieldName, defaults, value)) ?? content;
1830
+ };
1648
1831
  // Update Last session / Last Date
1649
- let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
1650
- if (result) {
1832
+ let result = replaceField('Last session', now);
1833
+ if (result && !skipWrapped('Last session', content, result)) {
1651
1834
  content = result;
1652
1835
  updated.push('Last session');
1653
1836
  }
1654
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last Date', now);
1655
- if (result) {
1837
+ result = replaceField('Last Date', now);
1838
+ if (result && !skipWrapped('Last Date', content, result)) {
1656
1839
  content = result;
1657
1840
  updated.push('Last Date');
1658
1841
  }
@@ -1667,13 +1850,13 @@ function cmdStateRecordSession(cwd, options, raw) {
1667
1850
  // section rewrite would reset an executor-authored resume file to None).
1668
1851
  let stoppedAtMatched = false;
1669
1852
  if (options.stopped_at) {
1670
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped At', options.stopped_at);
1853
+ result = replaceField('Stopped At', options.stopped_at);
1671
1854
  if (!result)
1672
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped at', options.stopped_at);
1855
+ result = replaceField('Stopped at', options.stopped_at);
1673
1856
  if (result) {
1674
1857
  stoppedAtMatched = true;
1675
- if (result !== content) {
1676
- content = result;
1858
+ if (result !== content && !skipWrapped('Stopped At', content, result)) {
1859
+ content = (0, state_document_cjs_1.readableEditedBoldLabel)(content, result);
1677
1860
  updated.push('Stopped At');
1678
1861
  }
1679
1862
  }
@@ -1685,30 +1868,25 @@ function cmdStateRecordSession(cwd, options, raw) {
1685
1868
  const resumeFileDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Resume File'];
1686
1869
  if (options.resume_file !== undefined && options.resume_file !== null) {
1687
1870
  // Caller explicitly passed a value — always honour it.
1688
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Resume File', options.resume_file);
1871
+ result = replaceField('Resume File', options.resume_file);
1689
1872
  if (!result)
1690
- result = (0, state_document_cjs_1.stateReplaceField)(content, 'Resume file', options.resume_file);
1691
- if (result) {
1692
- content = result;
1873
+ result = replaceField('Resume file', options.resume_file);
1874
+ if (result && !skipWrapped('Resume File', content, result)) {
1875
+ content = (0, state_document_cjs_1.readableEditedBoldLabel)(content, result);
1693
1876
  updated.push('Resume File');
1694
1877
  }
1695
1878
  }
1696
1879
  else {
1697
1880
  // No explicit value — only set 'None' when existing value is also a known default
1698
1881
  // (i.e. not executor-authored).
1699
- const newRf = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Resume File', resumeFileDefaults, 'None');
1700
- if (newRf !== content) {
1882
+ let newRf = replaceFieldIfTemplate('Resume File', resumeFileDefaults, 'None');
1883
+ // Try alternate capitalisation
1884
+ if (newRf === content)
1885
+ newRf = replaceFieldIfTemplate('Resume file', resumeFileDefaults, 'None');
1886
+ if (newRf !== content && !skipWrapped('Resume File', content, newRf)) {
1701
1887
  content = newRf;
1702
1888
  updated.push('Resume File');
1703
1889
  }
1704
- else {
1705
- // Try alternate capitalisation
1706
- const newRfAlt = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(content, 'Resume file', resumeFileDefaults, 'None');
1707
- if (newRfAlt !== content) {
1708
- content = newRfAlt;
1709
- updated.push('Resume File');
1710
- }
1711
- }
1712
1890
  }
1713
1891
  // Bug #944: DWIM normalize/auto-create — when the caller supplied --stopped-at or
1714
1892
  // --resume-file but the body lacks the canonical labels (in-place replace
@@ -1735,8 +1913,12 @@ function cmdStateRecordSession(cwd, options, raw) {
1735
1913
  // identical value is already persisted on disk and must not trigger the
1736
1914
  // insertion rewrite below.
1737
1915
  const needsStoppedAt = options.stopped_at && !stoppedAtMatched;
1738
- const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
1739
- const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date');
1916
+ // #4998: a field skipped as wrapped is present, not missing.
1917
+ const skipped = (field) => skippedFields.some((s) => s.field === field);
1918
+ const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null
1919
+ && !updated.includes('Resume File') && !skipped('Resume File');
1920
+ const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date')
1921
+ && !skipped('Last session') && !skipped('Last Date');
1740
1922
  if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
1741
1923
  const resumeValue = (options.resume_file !== undefined && options.resume_file !== null)
1742
1924
  ? options.resume_file
@@ -1747,92 +1929,143 @@ function cmdStateRecordSession(cwd, options, raw) {
1747
1929
  // (workstream.cts, gsd2-import.cts, templates/state.md) instead emit
1748
1930
  // `## Session Continuity`. Treat each separately so we never append a
1749
1931
  // duplicate section alongside an existing one.
1750
- const existingCanonicalSession = /^## Session[ \t]*$/im.test(content);
1751
- const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content);
1932
+ // #4998: a heading inside a fenced example is not a section. Detected
1933
+ // with the locator the writers below and the readers use (#4998 review).
1934
+ const headingScan = (0, state_document_cjs_1.maskFencedLines)(content, { document: true });
1935
+ const existingCanonicalSession = (0, markdown_sectionizer_cjs_1.collectSection)(headingScan, state_document_cjs_1.isSessionHeading, { levelBounded: true }) !== null;
1936
+ const existingSessionContinuity = (0, markdown_sectionizer_cjs_1.collectSection)(headingScan, state_document_cjs_1.isSessionContinuityHeading, { levelBounded: true }) !== null;
1752
1937
  // Track whether the chosen branch's rewrite actually matched. The detector
1753
- // regexes (existingCanonicalSession/existingSessionContinuity) are CRLF-
1754
- // tolerant ($ under /m treats \r as a line terminator); the writer regexes
1755
- // below must be too. If a writer regex silently fails to match (line-ending
1938
+ // and the writer share one locator (CRLF-tolerant: the tokenizer strips
1939
+ // a trailing \r). If the writer still fails to match (line-ending
1756
1940
  // mismatch, unexpected heading shape, ...), do NOT report success — the
1757
1941
  // caller would believe fields were persisted that were silently dropped
1758
1942
  // (#2450). The append branch always sets rewriteMatched=true (it always
1759
1943
  // mutates content).
1760
1944
  let rewriteMatched = false;
1945
+ // `created` means a session section was added, as in record-metric and
1946
+ // add-decision — not a field inserted into an existing one (#4998 review).
1947
+ let sectionAppended = false;
1948
+ // #4998: insert each missing field into `region`,
1949
+ // in the section's own spelling and in template order (Last session →
1950
+ // Stopped at → Resume file), beside the sibling it follows — a bold
1951
+ // `**Stopped at:**` above plain `Last session:` lines is a line the
1952
+ // template does not define. With no sibling present it goes right after
1953
+ // the heading in bold, the pre-#4998 shape. Every existing line is kept.
1954
+ // A heading at EOF with no line terminator gets one first, so its body
1955
+ // exists to insert into rather than a field being glued onto it.
1956
+ // The section is located on the masked text (same offsets): a fenced
1957
+ // `## Session` example is not the section, and a ``` in a frontmatter
1958
+ // block scalar does not hide the real one (Codex review round 2).
1959
+ const insertIntoSection = (isTarget) => {
1960
+ let section = (0, markdown_sectionizer_cjs_1.collectSection)((0, state_document_cjs_1.maskFencedLines)(content, { document: true }), isTarget, { levelBounded: true });
1961
+ if (section && content[section.bodyStart - 1] !== '\n' && section.bodyStart >= content.length) {
1962
+ content += content.includes('\r\n') ? '\r\n' : '\n';
1963
+ section = (0, markdown_sectionizer_cjs_1.collectSection)((0, state_document_cjs_1.maskFencedLines)(content, { document: true }), isTarget, { levelBounded: true });
1964
+ }
1965
+ if (!section || content[section.bodyStart - 1] !== '\n')
1966
+ return;
1967
+ const body = (0, state_document_cjs_1.insertMissingSessionFields)(content.slice(section.bodyStart, section.bodyEnd), [
1968
+ { label: 'Last session', needed: needsLastSession, value: now },
1969
+ { label: 'Stopped at', needed: !!needsStoppedAt, value: stoppedAtValue },
1970
+ { label: 'Resume file', needed: needsResumeFile, value: resumeValue },
1971
+ ]);
1972
+ content = (0, markdown_sectionizer_cjs_1.replaceSection)(content, section, body);
1973
+ rewriteMatched = true;
1974
+ };
1761
1975
  if (existingCanonicalSession) {
1762
- // Normalize in place: replace the ENTIRE BODY of the existing ## Session
1763
- // section (heading + all content up to the next ## heading or EOF) with
1764
- // canonical bold-label lines. The negative-lookahead per-line pattern
1765
- // `(?!^## )[\s\S]` consumes every line that doesn't start with "## ",
1766
- // which correctly stops at the next section boundary without consuming it.
1767
- // A trailing blank line is added so the next ## heading keeps its spacing.
1768
- //
1769
- // CRLF-tolerant (`\r?\n` after `[ \t]*`): the prior literal `\n` could not
1770
- // match a CRLF STATE.md (`---\r\n`), silently no-op'ing the replace while
1771
- // updated.push(...) reported success — #2450. The detector regex on the
1772
- // line above (`/^## Session[ \t]*$/im`) was already CRLF-tolerant, so the
1773
- // asymmetry armed the bug.
1774
- const canonicalReplacement = [
1775
- '## Session',
1776
- '',
1777
- `**Last session:** ${now}`,
1778
- `**Stopped at:** ${stoppedAtValue}`,
1779
- `**Resume file:** ${resumeValue}`,
1780
- '',
1781
- '',
1782
- ].join('\n');
1783
- content = content.replace(/^(## Session[ \t]*\r?\n(?:(?!^## )[\s\S])*)/m, () => {
1784
- rewriteMatched = true;
1785
- return canonicalReplacement;
1786
- });
1976
+ // #4998: insert the missing fields in place. This used to replace the
1977
+ // whole section with three canonical lines, which deleted any wrapped
1978
+ // value or note in it — and reset an authored Resume file to None.
1979
+ insertIntoSection(state_document_cjs_1.isSessionHeading);
1787
1980
  }
1788
1981
  else if (existingSessionContinuity) {
1789
1982
  // #1101: a `## Session Continuity` section already exists (bootstrap
1790
1983
  // shape). Previously this fell through to the append branch and created
1791
1984
  // a SECOND `## Session` block — a duplicate. Instead, insert only the
1792
- // canonical fields that are still missing, right after the heading,
1985
+ // canonical fields that are still missing (#4998: beside their siblings),
1793
1986
  // preserving the `## Session Continuity` heading and ALL existing lines
1794
1987
  // (e.g. prose like "Next recommended action"). Fields already updated in
1795
1988
  // place above (needs* false) are not re-inserted. A function replacement
1796
1989
  // is used so `$`-bearing caller values are inserted literally (#3454).
1797
1990
  //
1798
- // CRLF-tolerant (`\r?\n`): same #2450 fix as the canonical branch above.
1799
- const linesToInsert = [];
1800
- if (needsLastSession)
1801
- linesToInsert.push(`**Last session:** ${now}`);
1802
- if (needsStoppedAt)
1803
- linesToInsert.push(`**Stopped at:** ${stoppedAtValue}`);
1804
- if (needsResumeFile)
1805
- linesToInsert.push(`**Resume file:** ${resumeValue}`);
1806
- if (linesToInsert.length > 0) {
1807
- // Case-insensitive to match the `existingSessionContinuity` detection
1808
- // above (#1101 review F3) — otherwise a lowercase heading would detect
1809
- // but no-op the insert while still reporting the fields as updated.
1810
- content = content.replace(/^(## Session Continuity[ \t]*\r?\n)/im, (_m, heading) => {
1811
- rewriteMatched = true;
1812
- return heading + linesToInsert.join('\n') + '\n';
1813
- });
1814
- }
1815
- // No `else` branch: if linesToInsert.length === 0 the outer guard at
1816
- // :1144 (callerSuppliedValues && (needsStoppedAt || needsResumeFile
1817
- // || needsLastSession)) could not have fired, so this whole block is
1818
- // unreachable. Leaving `rewriteMatched = false` here is the fail-loud
1819
- // posture — a future change to the outer guard or needs* computation
1820
- // that makes this branch reachable will surface as a missing
1821
- // updated[] entry (silent recorded:false) rather than re-arming #2450.
1991
+ // Heading match is case-insensitive and CRLF-tolerant like the
1992
+ // `existingSessionContinuity` detection above (#1101 review F3, #2450):
1993
+ // a detected heading the writer then misses would report fields as
1994
+ // updated that were never written (`rewriteMatched` stays false).
1995
+ insertIntoSection(state_document_cjs_1.isSessionContinuityHeading);
1822
1996
  }
1823
1997
  else {
1824
1998
  // No session heading exists at all — append a new canonical section.
1825
- const scaffold = [
1826
- '',
1827
- '## Session',
1828
- '',
1829
- `**Last session:** ${now}`,
1830
- `**Stopped at:** ${stoppedAtValue}`,
1831
- `**Resume file:** ${resumeValue}`,
1832
- '',
1833
- ].join('\n');
1834
- content = content.trimEnd() + '\n' + scaffold;
1835
- rewriteMatched = true;
1999
+ // #4998: the appended section becomes the one the sync reads, so a
2000
+ // field this call is not setting carries the value the sync reads
2001
+ // today (a body line, a wrapped field's first line, a table row)
2002
+ // rather than None or nothing — which dropped the frontmatter
2003
+ // `stopped_at` and reset an authored Resume file. Later writes then
2004
+ // land on these bold lines first. A field skipped above as wrapped
2005
+ // carries its existing value too — Last session included — so a
2006
+ // skipped field never comes back carrying the new value.
2007
+ // Read from the body, as the sync does — never from frontmatter, and
2008
+ // never from a fenced example.
2009
+ const syncBody = (0, state_document_cjs_1.readableBoldLabels)((0, state_document_cjs_1.maskFencedLines)(stripFrontmatter(content)));
2010
+ const carried = (fields, fallback) => {
2011
+ for (const field of fields) {
2012
+ const value = (0, state_document_cjs_1.stateExtractField)(syncBody, field);
2013
+ if (value !== null)
2014
+ return value;
2015
+ }
2016
+ return fallback;
2017
+ };
2018
+ const lastSessionValue = skipped('Last session') || skipped('Last Date')
2019
+ ? carried(['Last session', 'Last Date'], now)
2020
+ : now;
2021
+ const scaffoldFields = [
2022
+ ['Last session', lastSessionValue],
2023
+ ['Stopped at', needsStoppedAt ? stoppedAtValue : carried(['Stopped At'], stoppedAtValue)],
2024
+ ['Resume file', needsResumeFile ? resumeValue : carried(['Resume File'], resumeValue)],
2025
+ ];
2026
+ // A document that ends inside an unclosed fence would swallow the
2027
+ // appended section — nothing reads it there. Write nothing, and say so.
2028
+ // The whole document is scanned on purpose: whether its tail is fenced
2029
+ // depends on every delimiter before it (frontmatter excluded: YAML, not
2030
+ // Markdown), and `scanFencedBlocks` reports
2031
+ // an unclosed block only as its LAST record (the fence runs to EOF), so
2032
+ // `closeLineIdx === -1` here means exactly "the tail is inside a fence".
2033
+ if ((0, state_document_cjs_1.markdownFencedBlocks)(content.split('\n'), { document: true }).some((b) => b.closeLineIdx === -1)) {
2034
+ // Named only for a field this call writes (Last session always, the
2035
+ // others when passed) and not already written in place or skipped
2036
+ // for another reason (Codex review; trek-e review 5608265301, Minor 3).
2037
+ const fieldKey = (f) => (f === 'Last Date' ? 'last session' : f.toLowerCase());
2038
+ const requested = new Set(['last session']);
2039
+ if (options.stopped_at)
2040
+ requested.add('stopped at');
2041
+ if (options.resume_file !== undefined && options.resume_file !== null)
2042
+ requested.add('resume file');
2043
+ const settled = new Set([...updated, ...skippedFields.map((s) => s.field)].map(fieldKey));
2044
+ // A Stopped at already holding the value needs no write (Codex review).
2045
+ if (stoppedAtMatched)
2046
+ settled.add('stopped at');
2047
+ for (const [label] of scaffoldFields) {
2048
+ const key = fieldKey(label);
2049
+ if (requested.has(key) && !settled.has(key))
2050
+ skippedFields.push({ field: label, reason: 'unclosed_fence' });
2051
+ }
2052
+ }
2053
+ else {
2054
+ // A carried value holding a line separator (a hand-written table cell
2055
+ // can) would forge a line once written as a bold field: leave that
2056
+ // field out, and say so.
2057
+ const scaffoldLines = [];
2058
+ for (const [label, value] of scaffoldFields) {
2059
+ if (state_document_cjs_1.FIELD_LINE_BREAK_RE.test(value))
2060
+ skippedFields.push({ field: label, reason: 'line_separator' });
2061
+ else
2062
+ scaffoldLines.push(`**${label}:** ${value}`);
2063
+ }
2064
+ const scaffold = ['', '## Session', '', ...scaffoldLines, ''].join('\n');
2065
+ content = content.trimEnd() + '\n' + scaffold;
2066
+ rewriteMatched = true;
2067
+ sectionAppended = true;
2068
+ }
1836
2069
  }
1837
2070
  // #2450 defensive invariant: only report sessionCreated/updated when the
1838
2071
  // chosen branch's rewrite actually mutated content. Unreachable when the
@@ -1848,7 +2081,7 @@ function cmdStateRecordSession(cwd, options, raw) {
1848
2081
  // same-line replace (CRLF-agnostic seam), so unconditional push is
1849
2082
  // correct. The class-defect防御 here is for the INSERT path only.
1850
2083
  if (rewriteMatched) {
1851
- sessionCreated = true;
2084
+ sessionCreated = sectionAppended;
1852
2085
  if (needsLastSession)
1853
2086
  updated.push('Last session');
1854
2087
  if (needsStoppedAt)
@@ -1863,22 +2096,48 @@ function cmdStateRecordSession(cwd, options, raw) {
1863
2096
  // bytes actually persisted (fix(#3351) generalized) and fold in any field
1864
2097
  // preservation restored that this transform never touched (#3345's
1865
2098
  // direction).
1866
- const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields);
2099
+ // #4998 review: read back where the fields were written — the session section.
2100
+ const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields, NO_SECTION_TARGETS, true);
2101
+ // #4998: name every field left whole because its value wraps, and how to fix it.
2102
+ const skippedRows = skippedFields;
2103
+ const skippedDisclosure = skippedRows.map(({ field, reason, continuation }) => (reason === 'wrapped_value'
2104
+ ? `state record-session left ${field} unchanged — its value continues onto the next line ` +
2105
+ `(${formatDiagnosticToken(continuation ?? '')}), and a single-line write would leave that line behind. ` +
2106
+ 'Fold the value onto one line, or put a blank line before that line if it is a separate note, then re-run.'
2107
+ : reason === 'line_separator'
2108
+ ? `state record-session left ${field} out of the new ## Session section — its existing value holds a ` +
2109
+ 'line separator (U+2028/U+2029), which would split the line it is written on. Remove the separator, then re-run.'
2110
+ : `state record-session did not write ${field} — STATE.md has no session section and ends inside an unclosed ` +
2111
+ 'code fence, which would swallow a new ## Session section. Close the fence, then re-run.'));
1867
2112
  if (reconciledUpdated.length > 0) {
1868
2113
  const result = { recorded: true, updated: reconciledUpdated };
1869
2114
  if (sessionCreated)
1870
2115
  result['created'] = true;
2116
+ if (skippedRows.length > 0) {
2117
+ result['skipped'] = skippedRows;
2118
+ for (const line of skippedDisclosure)
2119
+ process.stderr.write(`[gsd-tools] WARNING: ${line}\n`);
2120
+ }
1871
2121
  // #4763 (1): surface any non-empty prior record the write displaced. Gated
1872
2122
  // on reconciledUpdated (the fields that actually persisted, post-#3957
1873
2123
  // reconcile) rather than the pre-reconciliation updated[]. Only fields with
1874
2124
  // real prior content differing from the caller's value count — same-value
1875
- // rewrites, the insert path (no prior label), and the #944 template-default
1876
- // DWIM (defaults match case-insensitively, so a case-variant 'none' →
1877
- // 'None' rewrite is normalization, not displacement) are excluded.
1878
- const isResumeTemplateDefault = priorRecord.resumeFile !== undefined
1879
- && state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Resume File'].some((d) => d.toLowerCase() === priorRecord.resumeFile?.toLowerCase());
2125
+ // rewrites, the insert path with no prior record (no body label and, #4998,
2126
+ // no frontmatter `stopped_at`), and the #944 template-default DWIM
2127
+ // (defaults match case-insensitively, so a case-variant 'none' → 'None'
2128
+ // rewrite is normalization, not displacement) are excluded.
2129
+ // #4998: `None` is the same placeholder for Stopped At (the bootstrap
2130
+ // writes it there too, body or frontmatter) — no record was displaced. Nor
2131
+ // is the shipped template's whole-value `[...]` placeholder
2132
+ // (`[Description of last completed action]`; #4998 review, Nit 7).
2133
+ const isPlaceholder = (value) => value !== undefined
2134
+ && (state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Resume File'].some((d) => d.toLowerCase() === value.toLowerCase())
2135
+ || /^\[[^\]\n]*\]$/u.test(value.trim()));
2136
+ const isResumeTemplateDefault = isPlaceholder(priorRecord.resumeFile);
2137
+ const isStoppedAtPlaceholder = isPlaceholder(priorRecord.stoppedAt);
1880
2138
  const replacedRecord = {};
1881
2139
  if (reconciledUpdated.includes('Stopped At') && priorRecord.stoppedAt
2140
+ && !isStoppedAtPlaceholder
1882
2141
  && priorRecord.stoppedAt !== options.stopped_at) {
1883
2142
  replacedRecord['Stopped At'] = priorRecord.stoppedAt;
1884
2143
  }
@@ -1891,6 +2150,10 @@ function cmdStateRecordSession(cwd, options, raw) {
1891
2150
  result['replacedRecord'] = replacedRecord;
1892
2151
  output(result, raw, 'true');
1893
2152
  }
2153
+ else if (skippedRows.length > 0) {
2154
+ // #4998: every field the call would have changed wraps — nothing written.
2155
+ declineNoOp(raw, 'recorded', 'every session field to update was left unchanged — see skipped', skippedDisclosure.join('\n[gsd-tools] WARNING: '), { skipped: skippedRows });
2156
+ }
1894
2157
  else if (updated.length === 0) {
1895
2158
  // Nothing was ever attempted — no --stopped-at/--resume-file supplied
1896
2159
  // and no existing Last session/Last Date/Stopped At/Resume File labels
@@ -1924,12 +2187,20 @@ function cmdStateRecordSession(cwd, options, raw) {
1924
2187
  * Returns the section body, or null.
1925
2188
  */
1926
2189
  function matchSessionSection(body) {
1927
- const isSession = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'session';
1928
- const isSessionContinuity = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'session continuity';
1929
- const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, isSession, { levelBounded: true })
1930
- ?? (0, markdown_sectionizer_cjs_1.collectSection)(body, isSessionContinuity, { levelBounded: true });
2190
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, state_document_cjs_1.isSessionHeading, { levelBounded: true })
2191
+ ?? (0, markdown_sectionizer_cjs_1.collectSection)(body, state_document_cjs_1.isSessionContinuityHeading, { levelBounded: true });
1931
2192
  return section ? section.body : null;
1932
2193
  }
2194
+ /**
2195
+ * #4998: where the session fields (Stopped At, Paused At) are read from — the
2196
+ * session section, else the whole body — with fenced blocks blanked, because
2197
+ * `state record-session` writes them outside fences (`writeOutsideFences`).
2198
+ * A reader that still took a fenced example would sync it into frontmatter
2199
+ * over the field the writer just wrote.
2200
+ */
2201
+ function sessionFieldScope(body) {
2202
+ return (0, state_document_cjs_1.maskFencedLines)(matchSessionSection(body) ?? body);
2203
+ }
1933
2204
  /**
1934
2205
  * Match the "Current Position" section body from a STATE.md body. #2956: this
1935
2206
  * is the Phase analogue of matchSessionSection. `Phase` canonically lives under
@@ -1995,7 +2266,7 @@ function preferNewerLastActivity(existingFm, derivedFm) {
1995
2266
  derivedFm['last_activity'] = exRaw;
1996
2267
  }
1997
2268
  }
1998
- function parseProsePhaseField(value, convention) {
2269
+ function parseProsePhaseField(value, convention, directoryContext) {
1999
2270
  // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this
2000
2271
  // module holds no independent prose phase-id regex. Drives #2111 — the
2001
2272
  // anchored parser returns { phase: null } for a "Milestone vX.Y complete"
@@ -2003,19 +2274,19 @@ function parseProsePhaseField(value, convention) {
2003
2274
  // v0.5 -> "5"), so syncStateFrontmatter's #905 guard preserves the real
2004
2275
  // current_phase instead of clobbering it.
2005
2276
  // #5626: under `milestone-prefixed` the id is the whole `M-NN`; the convention comes from its owner.
2006
- return parsePhaseFromProse(value, convention);
2277
+ return parsePhaseFromProse(value, convention, directoryContext);
2007
2278
  }
2008
- function resolveStatePhase(fm, body, convention) {
2279
+ function resolveStatePhase(fm, body, convention, directoryContext) {
2009
2280
  const currentPositionScope = matchCurrentPositionSection(body) ?? body;
2010
2281
  const frontmatterRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', null).value;
2011
2282
  const legacyRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase').value;
2012
2283
  const currentPositionRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Phase').value;
2013
2284
  const sources = {
2014
- frontmatter: parseProsePhaseField(frontmatterRaw, convention).phase,
2015
- legacy_current_phase: parseProsePhaseField(legacyRaw, convention).phase,
2016
- current_position_phase: parseProsePhaseField(currentPositionRaw, convention).phase,
2285
+ frontmatter: parseProsePhaseField(frontmatterRaw, convention, directoryContext).phase,
2286
+ legacy_current_phase: parseProsePhaseField(legacyRaw, convention, directoryContext).phase,
2287
+ current_position_phase: parseProsePhaseField(currentPositionRaw, convention, directoryContext).phase,
2017
2288
  };
2018
- const prosePhase = parseProsePhaseField(currentPositionRaw, convention);
2289
+ const prosePhase = parseProsePhaseField(currentPositionRaw, convention, directoryContext);
2019
2290
  return {
2020
2291
  phase: sources.frontmatter ?? sources.legacy_current_phase ?? sources.current_position_phase,
2021
2292
  name: (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase_name', null).value
@@ -2070,9 +2341,9 @@ function resolveStatePhase(fm, body, convention) {
2070
2341
  * Reconcile the two only by giving `resolveStatePhase` an explicit scope
2071
2342
  * parameter — never by pointing this at it and dropping the difference.
2072
2343
  */
2073
- function resolveCurrentPhaseId(fm, body, convention) {
2344
+ function resolveCurrentPhaseId(fm, body, convention, directoryContext) {
2074
2345
  const positionSection = sliceCurrentPositionSection(body);
2075
- const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value, convention).phase : null;
2346
+ const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value, convention, directoryContext).phase : null;
2076
2347
  return (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ?? prosePhase;
2077
2348
  }
2078
2349
  function parseProseLastActivityField(value) {
@@ -2110,7 +2381,8 @@ function cmdStateSnapshot(cwd, raw) {
2110
2381
  // so it is scopeable exactly like Stopped At under ## Session. Fall back to
2111
2382
  // full-body search only when no ## Current Position section exists, so files
2112
2383
  // with no section heading keep their current behaviour.
2113
- const resolvedPhase = resolveStatePhase(fm, body, resolvePhaseIdConvention(cwd));
2384
+ const directoryContext = phaseDirectoryOptions(cwd);
2385
+ const resolvedPhase = resolveStatePhase(fm, body, resolvePhaseIdConvention(cwd), directoryContext);
2114
2386
  const currentPhase = resolvedPhase.phase;
2115
2387
  const currentPhaseName = resolvedPhase.name;
2116
2388
  const totalPhasesRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_phases', 'Total Phases').value;
@@ -2126,7 +2398,7 @@ function cmdStateSnapshot(cwd, raw) {
2126
2398
  // preferNewerLastActivity and the write seam in buildStateFrontmatter). The
2127
2399
  // write seam already scopes it to ## Session; this read seam must agree, so a
2128
2400
  // stale "Paused At:" in a Session Continuity Archive cannot win here either.
2129
- const sessionScope = matchSessionSection(body) ?? body;
2401
+ const sessionScope = sessionFieldScope(body);
2130
2402
  const pausedAt = (0, state_document_cjs_1.stateFieldValue)(fm, sessionScope, 'paused_at', 'Paused At').value;
2131
2403
  // Parse numeric fields
2132
2404
  const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
@@ -2169,7 +2441,8 @@ function cmdStateSnapshot(cwd, raw) {
2169
2441
  // `## Session Continuity` heading. See matchSessionSection for the anchoring.
2170
2442
  const sessionMatch = matchSessionSection(body);
2171
2443
  if (sessionMatch !== null) {
2172
- const sessionSection = sessionMatch;
2444
+ // #4998: fenced example lines are not fields (as for sessionFieldScope).
2445
+ const sessionSection = (0, state_document_cjs_1.maskFencedLines)(sessionMatch);
2173
2446
  // Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
2174
2447
  // (the form written by the DWIM auto-create / normalize path added for #944).
2175
2448
  const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
@@ -2280,7 +2553,7 @@ function isMilestoneBounded(roadmapRaw, milestone, convention) {
2280
2553
  * decimal, and project-code IDs are detected alike. Returns canonical keys
2281
2554
  * (see phaseKeyFromToken).
2282
2555
  */
2283
- function extractRetiredPhaseNumbers(scope, convention) {
2556
+ function extractRetiredPhaseNumbers(scope, convention, directoryContext) {
2284
2557
  const retired = new Set();
2285
2558
  const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
2286
2559
  // #612: the retirement filter has to widen with the counter it protects. The
@@ -2306,8 +2579,8 @@ function extractRetiredPhaseNumbers(scope, convention) {
2306
2579
  while ((s = strikeSpan.exec(line)) !== null) {
2307
2580
  const phaseRef = phaseRefRe.exec(s[1]);
2308
2581
  // Require a digit so struck prose like ~~Phase Overview~~ is ignored.
2309
- if (phaseRef && /\d/.test(phaseRef[1]))
2310
- retired.add(phaseKeyFromToken(phaseRef[1]));
2582
+ if (phaseRef && (directoryContext?.phaseNaming === 'custom' || /\d/.test(phaseRef[1])))
2583
+ retired.add(phaseKeyFromToken(phaseRef[1], directoryContext));
2311
2584
  }
2312
2585
  }
2313
2586
  return retired;
@@ -2351,7 +2624,7 @@ function extractRetiredPhaseNumbers(scope, convention) {
2351
2624
  * retain the bracket-id and bare-0 rules. Sharing the implementation therefore
2352
2625
  * cannot silently move either convention's total.
2353
2626
  */
2354
- function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules) {
2627
+ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules, directoryContext) {
2355
2628
  let count = 0;
2356
2629
  if (convention === 'bracket') {
2357
2630
  const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true);
@@ -2382,7 +2655,7 @@ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyCon
2382
2655
  continue;
2383
2656
  // #1514: retired/folded phases are struck through in the ROADMAP;
2384
2657
  // exclude them from the denominator (they can never be completed).
2385
- if (retiredPhaseNums.has(phaseKeyFromToken(token)))
2658
+ if (retiredPhaseNums.has(phaseKeyFromToken(token, directoryContext)))
2386
2659
  continue;
2387
2660
  count++;
2388
2661
  }
@@ -2398,7 +2671,7 @@ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyCon
2398
2671
  continue;
2399
2672
  if (applyConventionTokenSentinelRules && isSentinelPhaseId(token))
2400
2673
  continue;
2401
- if (retiredPhaseNums.has(phaseKeyFromToken(token)))
2674
+ if (retiredPhaseNums.has(phaseKeyFromToken(token, directoryContext)))
2402
2675
  continue;
2403
2676
  count++;
2404
2677
  }
@@ -2426,12 +2699,14 @@ options = {}) {
2426
2699
  let roadmapScope = null;
2427
2700
  let roadmapRaw = null;
2428
2701
  let retiredPhaseNums = new Set();
2702
+ let directoryContext = phaseDirectoryOptions(cwd, undefined, '');
2429
2703
  try {
2430
2704
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
2431
2705
  roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
2432
2706
  if (roadmapRaw !== null) {
2433
2707
  roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
2434
- retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention);
2708
+ directoryContext = { ...directoryContext, roadmap: roadmapRaw };
2709
+ retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention, directoryContext);
2435
2710
  }
2436
2711
  }
2437
2712
  catch { /* fall through: no roadmap scope → no retired exclusion */ }
@@ -2458,7 +2733,7 @@ options = {}) {
2458
2733
  // artifact; drop it from the disk phase set so it counts toward
2459
2734
  // neither the denominator nor the numerator (mirrors the heading
2460
2735
  // exclusion below). Project-code-aware via phaseKeyFromDir.
2461
- if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
2736
+ if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention, directoryContext)))
2462
2737
  continue;
2463
2738
  // #3185: dedup grouping routed through the canonical phaseKeyFromDir
2464
2739
  // (src/phase-id.cts) instead of a local leading-digits regex that
@@ -2467,7 +2742,7 @@ options = {}) {
2467
2742
  // so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
2468
2743
  // multi-segment milestone dirs. Same key surface used two lines
2469
2744
  // above for the retiredPhaseNums exclusion, so both filters agree.
2470
- const key = phaseKeyFromDir(dir, phaseConvention);
2745
+ const key = phaseKeyFromDir(dir, phaseConvention, directoryContext);
2471
2746
  if (!seenPhaseNums.has(key)) {
2472
2747
  seenPhaseNums.set(key, dir);
2473
2748
  }
@@ -2498,6 +2773,7 @@ options = {}) {
2498
2773
  roadmapScope,
2499
2774
  roadmapRaw,
2500
2775
  retiredPhaseNums,
2776
+ directoryContext,
2501
2777
  };
2502
2778
  }
2503
2779
  /**
@@ -2532,7 +2808,7 @@ milestoneExplicitlyNull) {
2532
2808
  // section overwrites current_phase here, and the next read surfaces it. Fall
2533
2809
  // back to full-body search when no ## Current Position section exists.
2534
2810
  const currentPositionScope = matchCurrentPositionSection(bodyContent) ?? bodyContent;
2535
- const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'), cwd ? resolvePhaseIdConvention(cwd) : null);
2811
+ const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'), cwd ? resolvePhaseIdConvention(cwd) : null, cwd ? phaseDirectoryOptions(cwd) : undefined);
2536
2812
  const currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase') ?? prosePhase.phase;
2537
2813
  const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name') ?? prosePhase.name;
2538
2814
  const currentPlan = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Plan');
@@ -2550,8 +2826,7 @@ milestoneExplicitlyNull) {
2550
2826
  // Fall back to full-body search only when no ## Session section exists.
2551
2827
  // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
2552
2828
  // `## Session Continuity` heading. See matchSessionSection for the anchoring.
2553
- const sessionSectionMatch = matchSessionSection(bodyContent);
2554
- const sessionBodyScope = sessionSectionMatch ?? bodyContent;
2829
+ const sessionBodyScope = sessionFieldScope(bodyContent);
2555
2830
  const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
2556
2831
  // #2567: Paused At is a session field — scope it to ## Session too so a
2557
2832
  // stale "Paused At:" line in an archive section cannot overwrite the value.
@@ -2631,7 +2906,7 @@ milestoneExplicitlyNull) {
2631
2906
  // #5118 pre-write validation in `phase remove` reads exactly this set.
2632
2907
  // #5038: an explicit-null `storedMilestone` is text, not a version;
2633
2908
  // pass a real null so it never reaches the phase-dir version filter.
2634
- const { phaseDirs, phaseDirScope, roadmapScope, roadmapRaw, retiredPhaseNums, } = scanStatePhaseDirs(cwd, phasesDir, phaseConvention, milestoneExplicitlyNull ? null : storedMilestone);
2909
+ const { phaseDirs, phaseDirScope, roadmapScope, roadmapRaw, retiredPhaseNums, directoryContext, } = scanStatePhaseDirs(cwd, phasesDir, phaseConvention, milestoneExplicitlyNull ? null : storedMilestone);
2635
2910
  let diskTotalPlans = 0;
2636
2911
  let diskTotalSummaries = 0;
2637
2912
  let diskCompletedPhases = 0;
@@ -2669,7 +2944,7 @@ milestoneExplicitlyNull) {
2669
2944
  // fence-aware bracket strategy with #3185's canonical legacy
2670
2945
  // sentinel predicate for this read-path call.
2671
2946
  const roadmapPhaseCount = roadmapScope !== null
2672
- ? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true)
2947
+ ? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true, directoryContext)
2673
2948
  : 0;
2674
2949
  cached = (() => {
2675
2950
  // #1761 read-path: mirror the cmdStateSync guard (#1794). When the
@@ -3851,7 +4126,7 @@ function assertStatePreservationOptions(options, caller) {
3851
4126
  function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options,
3852
4127
  // #5626: the project's phase-id convention, resolved by the caller from its cwd (null: unset). Trailing and
3853
4128
  // optional so `options` stays the fifth parameter the options guard and its callers rely on.
3854
- phaseIdConvention = null) {
4129
+ phaseIdConvention = null, directoryContext) {
3855
4130
  assertStatePreservationOptions(options, 'applyPostSyncPreservation');
3856
4131
  const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField, preWriteState } = options;
3857
4132
  // Bug #1230: delta heuristic — snapshot pre-transform body source fields so
@@ -3887,8 +4162,7 @@ phaseIdConvention = null) {
3887
4162
  // mirroring buildStateFrontmatter's sessionBodyScope logic.
3888
4163
  // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
3889
4164
  // Archive prose) must not interfere with the delta comparison.
3890
- const preSessionMatch = matchSessionSection(preBody);
3891
- const preSessionScope = preSessionMatch ?? preBody;
4165
+ const preSessionScope = sessionFieldScope(preBody);
3892
4166
  const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
3893
4167
  // ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
3894
4168
  // current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
@@ -3908,7 +4182,7 @@ phaseIdConvention = null) {
3908
4182
  const preCurrentPositionScope = matchCurrentPositionSection(preBody) ?? preBody;
3909
4183
  const preBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Plan');
3910
4184
  const preBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Phase')
3911
- ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase'), phaseIdConvention).phase;
4185
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase'), phaseIdConvention, directoryContext).phase;
3912
4186
  const preBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Paused At');
3913
4187
  const preBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity')
3914
4188
  ?? (0, state_document_cjs_1.stateExtractField)(preBody, 'Last activity');
@@ -3923,8 +4197,7 @@ phaseIdConvention = null) {
3923
4197
  const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
3924
4198
  // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
3925
4199
  // consistent with the pre-transform snapshot above and buildStateFrontmatter.
3926
- const postSessionMatch = matchSessionSection(postBody);
3927
- const postSessionScope = postSessionMatch ?? postBody;
4200
+ const postSessionScope = sessionFieldScope(postBody);
3928
4201
  const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
3929
4202
  // ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
3930
4203
  // current_phase_name delta comparison.
@@ -3934,7 +4207,7 @@ phaseIdConvention = null) {
3934
4207
  const postCurrentPositionScope = matchCurrentPositionSection(postBody) ?? postBody;
3935
4208
  const postBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Plan');
3936
4209
  const postBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Phase')
3937
- ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase'), phaseIdConvention).phase;
4210
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase'), phaseIdConvention, directoryContext).phase;
3938
4211
  const postBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Paused At');
3939
4212
  const postBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity')
3940
4213
  ?? (0, state_document_cjs_1.stateExtractField)(postBody, 'Last activity');
@@ -4151,7 +4424,7 @@ phaseIdConvention = null) {
4151
4424
  function syncAndPreserveStateMd(originalContent, transformedContent, statePath, cwd, options) {
4152
4425
  assertStatePreservationOptions(options, 'syncAndPreserveStateMd');
4153
4426
  const synced = syncStateFrontmatter(transformedContent, cwd, options.authoritativeFm);
4154
- return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options, cwd ? resolvePhaseIdConvention(cwd) : null);
4427
+ return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options, cwd ? resolvePhaseIdConvention(cwd) : null, cwd ? phaseDirectoryOptions(cwd) : undefined);
4155
4428
  }
4156
4429
  /**
4157
4430
  * Atomic read-modify-write for STATE.md.
@@ -4574,7 +4847,7 @@ function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
4574
4847
  * the markdown-sectionizer seam (ADR-1372), never a hand-rolled regex.
4575
4848
  * Existing callers pass no `sectionTargets` and resolve exactly as before.
4576
4849
  */
4577
- function readStateTarget(fm, body, field, sectionTargets) {
4850
+ function readStateTarget(fm, body, field, sectionTargets, sessionScope = false) {
4578
4851
  if (field === 'Current Position') {
4579
4852
  const section = stateTransitionMod.sliceCurrentPositionSection(body);
4580
4853
  return section !== null ? section.trim() : null;
@@ -4583,7 +4856,14 @@ function readStateTarget(fm, body, field, sectionTargets) {
4583
4856
  const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.text === field);
4584
4857
  return section !== null ? section.body.trim() : null;
4585
4858
  }
4586
- const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
4859
+ // #4998 review: no session writer changes a fenced example line, so a session
4860
+ // field is read with fences blanked — an unchanged example above the live
4861
+ // line hid the write and reported it missing. With `sessionScope` (`state
4862
+ // update`, `state patch` and `state record-session`, which write inside the
4863
+ // session section) it is read there; otherwise from the whole document.
4864
+ const bodyValue = stateTransitionMod.isSessionScopedBodyField(field)
4865
+ ? (0, state_document_cjs_1.stateExtractField)((0, state_document_cjs_1.readableBoldLabels)(sessionScope ? sessionFieldScope(body) : (0, state_document_cjs_1.maskFencedLines)(body)), field)
4866
+ : (0, state_document_cjs_1.stateExtractField)(body, field);
4587
4867
  if (bodyValue !== null)
4588
4868
  return bodyValue;
4589
4869
  if (Object.prototype.hasOwnProperty.call(fm, field))
@@ -4611,7 +4891,7 @@ const NO_SECTION_TARGETS = new Set();
4611
4891
  * `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
4612
4892
  * second, parallel absence convention — one rule, reused, not duplicated.
4613
4893
  */
4614
- function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets) {
4894
+ function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets, sessionScope = false) {
4615
4895
  const isDeclaredDerivedLeaf = field.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, field);
4616
4896
  if (isDeclaredDerivedLeaf && !sectionTargets.has(field)) {
4617
4897
  const before = resolveFrontmatterPath(snapshotFm, field);
@@ -4623,8 +4903,8 @@ function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody
4623
4903
  return false;
4624
4904
  return stateFieldValuesDiffer(before, after);
4625
4905
  }
4626
- const before = readStateTarget(snapshotFm, snapshotBody, field, sectionTargets);
4627
- const after = readStateTarget(persistedFm, persistedBody, field, sectionTargets);
4906
+ const before = readStateTarget(snapshotFm, snapshotBody, field, sectionTargets, sessionScope);
4907
+ const after = readStateTarget(persistedFm, persistedBody, field, sectionTargets, sessionScope);
4628
4908
  if (before === null && after === null)
4629
4909
  return false;
4630
4910
  if (before === null || after === null)
@@ -4635,7 +4915,9 @@ function reconcileReportedFields(statePath, preWriteState, reported, divergedFie
4635
4915
  // ADR-4629 §8.2 / Decision 4 (C2): names in `reported` that are declared
4636
4916
  // `target: 'section'` assertions, measured as whole-section deltas. Omitted
4637
4917
  // by every pre-C2 caller, whose output is unchanged.
4638
- sectionTargets = NO_SECTION_TARGETS) {
4918
+ sectionTargets = NO_SECTION_TARGETS,
4919
+ // #4998 review: the caller writes session fields inside the session section.
4920
+ sessionScope = false) {
4639
4921
  void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
4640
4922
  if (preWriteState.fm === undefined || preWriteState.body === undefined)
4641
4923
  return [];
@@ -4644,7 +4926,7 @@ sectionTargets = NO_SECTION_TARGETS) {
4644
4926
  const persistedBody = stripFrontmatter(persisted);
4645
4927
  const snapshotFm = preWriteState.fm;
4646
4928
  const snapshotBody = preWriteState.body;
4647
- const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets);
4929
+ const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets, sessionScope);
4648
4930
  // Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
4649
4931
  // granularity) whose persisted value differs from the snapshot, minus the
4650
4932
  // provenance exclusion. A frontmatter-diff-discovered field is mapped
@@ -5070,13 +5352,13 @@ function cmdStateJson(cwd, raw) {
5070
5352
  // change untouched — and `milestone`/`milestone_name`
5071
5353
  // (preserve-if-placeholder) are out of D3's scope entirely.
5072
5354
  if (existingFm) {
5073
- const sessionScope = matchSessionSection(body) ?? body;
5355
+ const sessionScope = sessionFieldScope(body);
5074
5356
  const positionScope = matchCurrentPositionSection(body) ?? body;
5075
5357
  const bodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped at');
5076
5358
  const bodyPausedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
5077
5359
  const bodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(body, 'Phase');
5078
5360
  const bodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase')
5079
- ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase'), resolvePhaseIdConvention(cwd)).phase;
5361
+ ?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase'), resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd)).phase;
5080
5362
  const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
5081
5363
  const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
5082
5364
  // #3836: mirrors applyPostSyncPreservation's own derivation (state.cts
@@ -5137,7 +5419,7 @@ function cmdStateJson(cwd, raw) {
5137
5419
  * phase locator's own rule, so this agrees with `init` and `phase-plan-index`.
5138
5420
  */
5139
5421
  function firstPlanToRun(cwd, phaseNumber) {
5140
- const located = phaseLocatorMod.findPhaseInternal(cwd, String(phaseNumber));
5422
+ const located = phaseLocatorMod.findPhaseInternal(cwd, String(phaseNumber), undefined, phaseDirectoryOptions(cwd));
5141
5423
  if (!located || !located.found)
5142
5424
  return null;
5143
5425
  const { plans, incomplete_plans: incomplete } = located;
@@ -5173,6 +5455,10 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
5173
5455
  if (phaseNumber == null || String(phaseNumber).trim() === '') {
5174
5456
  error('phase required (--phase <N>)');
5175
5457
  }
5458
+ if (resolvePhaseIdConvention(cwd) === 'bracket'
5459
+ && (String(phaseNumber).startsWith('[') || String(phaseNumber).includes('-'))) {
5460
+ error('state begin-phase --phase requires a bare phase number such as 02; qualified ids are not accepted.', ERROR_REASON.USAGE);
5461
+ }
5176
5462
  const statePath = planningPaths(cwd).state;
5177
5463
  if (!node_fs_1.default.existsSync(statePath)) {
5178
5464
  output({ error: 'STATE.md not found' }, raw, undefined);
@@ -5236,7 +5522,22 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
5236
5522
  if (result.data?.['resumed']) {
5237
5523
  delete rmwOptions.authoritativeFm;
5238
5524
  }
5239
- return result.content;
5525
+ let transitioned = result.content;
5526
+ if (resolvePhaseIdConvention(cwd) === 'bracket') {
5527
+ const display = phaseDisplayFor(cwd, phaseNumber);
5528
+ if (result.updated.includes('Last Activity Description')) {
5529
+ transitioned = replaceStateBodyField(transitioned, statePath, 'Last Activity Description', `${display} execution started`);
5530
+ }
5531
+ if (result.updated.includes('Current focus')) {
5532
+ transitioned = replaceStateBodyField(transitioned, statePath, 'Current focus', intent.phaseName ? `${display} — ${intent.phaseName}` : display);
5533
+ }
5534
+ const today = clock_cjs_1.realClock.localToday();
5535
+ const activitySuffix = result.data?.['resumed']
5536
+ ? 'execution resumed (wave continue)'
5537
+ : 'execution started';
5538
+ transitioned = replaceCurrentPositionFieldValue(transitioned, statePath, 'Last Activity', `${today} — Phase ${phaseNumber} ${activitySuffix}`, `${today} — ${display} ${activitySuffix}`);
5539
+ }
5540
+ return transitioned;
5240
5541
  }, cwd, rmwOptions);
5241
5542
  // ADR-3408 §8.4 (D4): reconcile `beginPhaseCore`'s own success list against
5242
5543
  // the bytes actually persisted (fix(#3351) generalized) and fold in any
@@ -5555,6 +5856,16 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
5555
5856
  }
5556
5857
  const result = transitionCore(content, intent, deps);
5557
5858
  precomputedUpdated = result.updated;
5859
+ if (resolvePhaseIdConvention(cwd) === 'bracket') {
5860
+ const display = phaseDisplayFor(cwd, phaseNumber);
5861
+ let transitioned = result.content;
5862
+ if (result.updated.includes('Last Activity Description')) {
5863
+ transitioned = replaceStateBodyField(transitioned, statePath, 'Last Activity Description', `${display} planning complete — ${planCount || '?'} plans ready`);
5864
+ }
5865
+ const today = clock_cjs_1.realClock.localToday();
5866
+ transitioned = replaceCurrentPositionFieldValue(transitioned, statePath, 'Last Activity', `${today} — Phase ${phaseNumber} planning complete`, `${today} — ${display} planning complete`);
5867
+ return transitioned;
5868
+ }
5558
5869
  return result.content;
5559
5870
  }, cwd, rmwOptions);
5560
5871
  // ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
@@ -5778,7 +6089,8 @@ function cmdStateValidate(cwd, raw, opts = {}) {
5778
6089
  const { fm, body, scope: initialScope } = readStateFrontmatterScoped(content, statePath);
5779
6090
  const scope = initialScope;
5780
6091
  const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value || '';
5781
- const resolvedPhase = resolveStatePhase(fm, body, resolvePhaseIdConvention(cwd));
6092
+ const directoryContext = phaseDirectoryOptions(cwd);
6093
+ const resolvedPhase = resolveStatePhase(fm, body, resolvePhaseIdConvention(cwd), directoryContext);
5782
6094
  const currentPhase = resolvedPhase.phase;
5783
6095
  const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
5784
6096
  const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
@@ -5788,8 +6100,8 @@ function cmdStateValidate(cwd, raw, opts = {}) {
5788
6100
  emit({ valid: false, warnings, scope });
5789
6101
  return;
5790
6102
  }
5791
- const selectedPhaseKey = phaseKeyFromToken(currentPhase);
5792
- if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source) !== selectedPhaseKey)) {
6103
+ const selectedPhaseKey = phaseKeyFromToken(currentPhase, directoryContext);
6104
+ if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source, directoryContext) !== selectedPhaseKey)) {
5793
6105
  warnings.push(stateDiagnostic('S003', SEVERITY.WARNING, `Phase reference conflict: validating authoritative phase ${currentPhase}; align STATE.md phase sources`, 'Align STATE.md phase sources (frontmatter, Current Phase, Current Position Phase) on one phase'));
5794
6106
  }
5795
6107
  if (!node_fs_1.default.existsSync(phasesDir)) {
@@ -5818,7 +6130,7 @@ function cmdStateValidate(cwd, raw, opts = {}) {
5818
6130
  let phaseDirPath;
5819
6131
  try {
5820
6132
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
5821
- const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention) === selectedPhaseKey);
6133
+ const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention, directoryContext) === selectedPhaseKey);
5822
6134
  if (!phaseDir) {
5823
6135
  warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: no phase directory matches phase ${currentPhase}`, 'Create a phase directory matching the current phase or correct current_phase'));
5824
6136
  emit({ valid: false, warnings, scope });
@@ -5976,6 +6288,7 @@ function cmdStateSync(cwd, options, raw) {
5976
6288
  // write branch can surface it identically.
5977
6289
  const existingSyncFm = extractFrontmatter(content, statePath);
5978
6290
  const syncFrontmatterWasUnparseable = isUnparseableFrontmatter(existingSyncFm);
6291
+ let directoryContext = phaseDirectoryOptions(cwd, undefined, '');
5979
6292
  const changes = [];
5980
6293
  let modified = content;
5981
6294
  const phasesDir = planningPaths(cwd).phases;
@@ -5995,8 +6308,9 @@ function cmdStateSync(cwd, options, raw) {
5995
6308
  const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
5996
6309
  if (roadmapRaw !== null) {
5997
6310
  syncRoadmapRaw = roadmapRaw;
6311
+ directoryContext = { ...directoryContext, roadmap: roadmapRaw };
5998
6312
  syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
5999
- syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention);
6313
+ syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention, directoryContext);
6000
6314
  }
6001
6315
  }
6002
6316
  catch { /* fall through: no roadmap scope → no retired exclusion */ }
@@ -6033,7 +6347,7 @@ function cmdStateSync(cwd, options, raw) {
6033
6347
  entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
6034
6348
  .filter(e => e.isDirectory())
6035
6349
  .map(e => e.name)
6036
- .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention))))
6350
+ .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention, directoryContext))))
6037
6351
  .filter(name => syncMilestonePhaseDirs === null || syncMilestonePhaseDirs.has(name))
6038
6352
  .sort();
6039
6353
  }
@@ -6101,7 +6415,7 @@ function cmdStateSync(cwd, options, raw) {
6101
6415
  // pre-existing legacy sync behavior while #3185 remains the read-path owner.
6102
6416
  let syncTotalPhases = null;
6103
6417
  const roadmapPhaseCount = syncRoadmapScope !== null
6104
- ? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket')
6418
+ ? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket', directoryContext)
6105
6419
  : 0;
6106
6420
  if (roadmapPhaseCount > 0) {
6107
6421
  syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
@@ -6232,7 +6546,7 @@ function cmdStatePrune(cwd, options, raw) {
6232
6546
  const body = stripFrontmatter(rawState);
6233
6547
  // Prune needs an integer cutoff, so it parses the resolved id itself; a
6234
6548
  // non-numeric or absent id lands on 0 and prune bails, as before.
6235
- const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body, resolvePhaseIdConvention(cwd))), 10) || 0;
6549
+ const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body, resolvePhaseIdConvention(cwd), phaseDirectoryOptions(cwd))), 10) || 0;
6236
6550
  const cutoff = currentPhase - keepRecent;
6237
6551
  if (cutoff <= 0) {
6238
6552
  emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
@@ -6457,7 +6771,7 @@ function cmdStateRebuild(cwd, options, raw) {
6457
6771
  * that the phase execution is finished and the project is ready for the next phase.
6458
6772
  * Implements the `gsd state complete-phase` subcommand (issue #2735).
6459
6773
  */
6460
- function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
6774
+ function resolvePhaseIdForCompletePhase(fm, body, overridePhase, directoryContext) {
6461
6775
  // #3187: route through the single #1760 fallback-chain owner (fm scalar
6462
6776
  // then body field) instead of two raw stateExtractField calls on
6463
6777
  // frontmatter-blind content — a STATE.md whose phase lives only in
@@ -6475,7 +6789,7 @@ function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
6475
6789
  // "Phase 0.5 complete". A canonical token at the start of the value
6476
6790
  // (3, 03, 3A, 3.3, 10.2, "3 of 5", "1 — Setup") is preserved; a milestone
6477
6791
  // closure line yields null, so the caller's "unable to resolve" guard fires.
6478
- return parsePhaseFromProse(candidate).phase;
6792
+ return parsePhaseFromProse(candidate, undefined, directoryContext).phase;
6479
6793
  }
6480
6794
  function cmdStateCompletePhase(cwd, raw, overridePhase) {
6481
6795
  const statePath = planningPaths(cwd).state;
@@ -6507,7 +6821,8 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
6507
6821
  output({ error: 'Unable to read STATE.md frontmatter; refusing to run complete-phase to avoid a destructive rollback (#3489). Fix or remove the malformed frontmatter and retry.' }, raw, undefined);
6508
6822
  return;
6509
6823
  }
6510
- const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase);
6824
+ const directoryContext = phaseDirectoryOptions(cwd);
6825
+ const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase, directoryContext);
6511
6826
  if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
6512
6827
  output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw, undefined);
6513
6828
  return;
@@ -6524,7 +6839,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
6524
6839
  const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value || '';
6525
6840
  // #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two
6526
6841
  // sites cannot diverge on the token they extract.
6527
- const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase;
6842
+ const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw, undefined, directoryContext).phase;
6528
6843
  if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
6529
6844
  output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, raw, 'false');
6530
6845
  return;
@@ -6557,6 +6872,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
6557
6872
  const rmwOptions = { divergedFields, preWriteState };
6558
6873
  const wrote = readModifyWriteStateMd(statePath, (content) => {
6559
6874
  const currentPhase = resolvedPhase;
6875
+ const currentPhaseDisplay = phaseDisplayFor(cwd, currentPhase);
6560
6876
  // Bug #1255: operate on body only so the YAML frontmatter `status:` key
6561
6877
  // cannot shadow the body Status field (pipe-table or inline).
6562
6878
  //
@@ -6586,7 +6902,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
6586
6902
  updated.push({ kind: 'field', name: 'Last Activity' });
6587
6903
  }
6588
6904
  // Update Last Activity Description
6589
- const activityDesc = `Phase ${currentPhase} marked complete`;
6905
+ const activityDesc = `${currentPhaseDisplay} marked complete`;
6590
6906
  result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
6591
6907
  if (result) {
6592
6908
  body = result;
@@ -6635,14 +6951,14 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
6635
6951
  posBody = replaced;
6636
6952
  }
6637
6953
  // Update Last activity line if present
6638
- const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`;
6954
+ const newActivity = `Last activity: ${today} — ${currentPhaseDisplay} marked complete`;
6639
6955
  if (/^Last activity:/im.test(posBody)) {
6640
6956
  posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
6641
6957
  }
6642
6958
  else {
6643
6959
  // Pipe-table format in Current Position (#1255)
6644
6960
  // Value must match the inline branch (date + narrative), not bare date.
6645
- const activityValue = `${today} — Phase ${currentPhase} marked complete`;
6961
+ const activityValue = `${today} — ${currentPhaseDisplay} marked complete`;
6646
6962
  const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', activityValue)
6647
6963
  ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', activityValue);
6648
6964
  if (replaced !== null)
@@ -6751,6 +7067,9 @@ module.exports = {
6751
7067
  cmdStateBeginPhase,
6752
7068
  cmdStatePlannedPhase,
6753
7069
  cmdStateCompletePhase,
7070
+ // #4304: the canonical write-path current-phase ladder, read by the bracket
7071
+ // `phase remove` refusal before it deletes or renumbers that phase.
7072
+ resolveCurrentPhaseId,
6754
7073
  cmdStateValidate,
6755
7074
  cmdStateSync,
6756
7075
  cmdStatePrune,