@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
@@ -21,9 +21,11 @@
21
21
  var __importDefault = (this && this.__importDefault) || function (mod) {
22
22
  return (mod && mod.__esModule) ? mod : { "default": mod };
23
23
  };
24
+ const node_crypto_1 = require("node:crypto");
24
25
  const node_fs_1 = __importDefault(require("node:fs"));
25
26
  const node_path_1 = __importDefault(require("node:path"));
26
27
  const pattern_cjs_1 = require("./pattern.cjs");
28
+ const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
27
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
30
  const phaseIdModule = require("./phase-id.cjs");
29
31
  const { phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE,
@@ -53,10 +55,14 @@ const planningWorkspace = require("./planning-workspace.cjs");
53
55
  const { planningDir, resolvePhaseIdConvention } = planningWorkspace;
54
56
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
55
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports
58
+ const frontmatterMod = require("./frontmatter.cjs");
59
+ const { rawFrontmatterMapping } = frontmatterMod;
60
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
56
61
  const unusableInputMod = require("./unusable-input.cjs");
57
62
  const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
58
63
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
59
64
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
65
+ const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
60
66
  // eslint-disable-next-line @typescript-eslint/no-require-imports
61
67
  const planningScopeMod = require("./planning-scope.cjs");
62
68
  const { SCOPE } = planningScopeMod;
@@ -72,6 +78,21 @@ const MILESTONE_ACTIVE_MARKER_PATTERN = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progres
72
78
  function isClosedMilestoneHeading(headingText) {
73
79
  return MILESTONE_CLOSED_MARKER_PATTERN.test(headingText) && !MILESTONE_ACTIVE_MARKER_PATTERN.test(headingText);
74
80
  }
81
+ /**
82
+ * Is this `<details>` block a CLOSED milestone? One test for the read-side strip
83
+ * and the write-side mask. The `<summary>` is read with inline tags removed
84
+ * (`<b>v1.0</b> ✅`); a milestone-shaped summary (version token or "milestone")
85
+ * that says "Complete(d)" counts too. No `<summary>` means not closed.
86
+ */
87
+ function isClosedMilestoneBlock(block) {
88
+ const summary = block.match(/<summary[^>]*>([\s\S]{0,1000}?)<\/summary>/i);
89
+ if (!summary)
90
+ return false;
91
+ const text = summary[1].replace(/<[^>]{0,200}>/g, '');
92
+ return isClosedMilestoneHeading(text)
93
+ || (/\bcomplete(?:d)?\b/i.test(text) && /\bv\d+(?:\.\d+)*\b|\bmilestone\b/i.test(text)
94
+ && !MILESTONE_ACTIVE_MARKER_PATTERN.test(text));
95
+ }
75
96
  /**
76
97
  * Strip shipped milestone content wrapped in <details> blocks.
77
98
  */
@@ -79,15 +100,71 @@ function stripShippedMilestones(content) {
79
100
  return (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content, 'details');
80
101
  }
81
102
  /**
82
- * #5543: run `edit` over the ROADMAP with every shipped `<details>` block hidden
83
- * and restored byte-for-byte afterwards. For writers whose active milestone
84
- * could not be resolved: the first `### Phase N:` in document order must never
85
- * be the one inside a shipped block. Layout-independent (the active phase may
86
- * sit before or after a shipped block) and, unlike a "region after the last
87
- * `</details>`" cut, not fooled by a `</details>` in a fence or inline code.
103
+ * #5543: hide every CLOSED-milestone `<details>` block (`<details>` opener, a
104
+ * `<summary>` that `isClosedMilestoneHeading` reads as shipped/archived, the
105
+ * #3982 test) behind a placeholder comment; `restore` puts them back
106
+ * byte-for-byte, or returns null when `edited` lost or duplicated a placeholder.
107
+ * For writers whose first `### Phase N:` in document order must never sit in a
108
+ * shipped block. Other `<details>` (notes, a phase wrapper, an active
109
+ * milestone's collapsed block) stay visible, so a roadmap with no archived
110
+ * milestone is edited as before. Blocks are matched by depth, so a notes
111
+ * `<details>` nested inside a shipped one stays inside it (the flat
112
+ * `taggedBlockPattern` closes at the inner `</details>`). An unmatched
113
+ * opener (prose, a fence) wraps nothing, so a later shipped block is still
114
+ * masked. Fences are not parsed, as in every ROADMAP `<details>` reader here.
88
115
  */
116
+ function maskShippedBlocks(content) {
117
+ const blocks = [];
118
+ let nonce = 0;
119
+ while (content.includes(`<!-- gsd-masked-shipped-${nonce}-`))
120
+ nonce += 1;
121
+ const placeholder = (i) => `<!-- gsd-masked-shipped-${nonce}-${i} -->`;
122
+ const tags = /<details(?:\s[^>]{0,1000})?>|<\/details>/gi;
123
+ // Matched pairs by stack; an unmatched opener (prose, a fence) never closes, so it
124
+ // wraps nothing and a later block is still found. `outer` keeps only the pairs not
125
+ // nested in another matched pair.
126
+ const open = [];
127
+ const outer = [];
128
+ for (let m = tags.exec(content); m !== null; m = tags.exec(content)) {
129
+ if (m[0][1] !== '/')
130
+ open.push(m.index);
131
+ else if (open.length > 0) {
132
+ const start = open.pop();
133
+ while (outer.length > 0 && outer[outer.length - 1][0] >= start)
134
+ outer.pop();
135
+ outer.push([start, m.index + m[0].length]);
136
+ }
137
+ }
138
+ let masked = '';
139
+ let copied = 0;
140
+ for (const [start, end] of outer) {
141
+ const block = content.slice(start, end);
142
+ if (block.startsWith('<details>') && isClosedMilestoneBlock(block)) {
143
+ masked += content.slice(copied, start) + placeholder(blocks.length);
144
+ blocks.push(block);
145
+ copied = end;
146
+ }
147
+ }
148
+ masked += content.slice(copied);
149
+ return {
150
+ masked,
151
+ restore(edited) {
152
+ let restored = edited;
153
+ for (let i = 0; i < blocks.length; i++) {
154
+ const marker = placeholder(i);
155
+ const at = restored.indexOf(marker);
156
+ if (at === -1 || restored.indexOf(marker, at + marker.length) !== -1)
157
+ return null;
158
+ restored = restored.slice(0, at) + blocks[i] + restored.slice(at + marker.length);
159
+ }
160
+ return restored;
161
+ },
162
+ };
163
+ }
89
164
  function editOutsideShippedBlocks(content, edit) {
90
- return (0, markdown_sectionizer_cjs_1.editOutsideTaggedBlocks)(content, 'details', edit);
165
+ const { masked, restore } = maskShippedBlocks(content);
166
+ const edited = edit(masked);
167
+ return (typeof edited === 'string' ? restore(edited) : null) ?? content;
91
168
  }
92
169
  /**
93
170
  * #3982: strip only <details> blocks whose <summary> marks a CLOSED milestone
@@ -97,12 +174,15 @@ function editOutsideShippedBlocks(content, edit) {
97
174
  * reproduce the phase_count: 0 class of #557/#2947.
98
175
  */
99
176
  function stripClosedMilestoneDetails(content) {
100
- return content.replace(/<details\b[^>]*>[\s\S]*?<\/details>/gi, (block) => {
101
- const summaryMatch = block.match(/<summary[^>]*>([^<]*)<\/summary>/i);
102
- if (!summaryMatch)
103
- return block;
104
- return isClosedMilestoneHeading(summaryMatch[1]) ? '' : block;
105
- });
177
+ return content.replace(/<details\b[^>]*>[\s\S]*?<\/details>/gi, (block) => (isClosedMilestoneBlock(block) ? '' : block));
178
+ }
179
+ /**
180
+ * Classify a complete `<details>` block through isClosedMilestoneBlock, the one rule shared with
181
+ * stripClosedMilestoneDetails and the write-side shipped-block mask. Destructive consumers share this predicate so
182
+ * an active collapsed phase list is never reclassified as shipped history.
183
+ */
184
+ function isClosedMilestoneDetails(block) {
185
+ return isClosedMilestoneBlock(block);
106
186
  }
107
187
  /**
108
188
  * #2562: is the milestone `version` marked SHIPPED by the ROADMAP itself?
@@ -358,6 +438,25 @@ function listMilestoneHeadings(content) {
358
438
  // rather than typing `^#{1,3}\s+(?!Phase\s+\S)` a second time — the exact
359
439
  // duplication class ADR-3180 §7.2's widened guard exists to catch.
360
440
  const MILESTONE_HEADING_LINE_SOURCE = '^#{1,3}\\s+(?!Phase\\s+\\S)[^\\n]*';
441
+ /**
442
+ * #4304: classify one level-1..3 heading through the same milestone grammar
443
+ * the window locator owns. Phase headings are excluded first through the
444
+ * reader's own legacy/bracket discriminators, regardless of words or markers
445
+ * in their titles; `Failed Payment Recovery` and `Done ✅` remain phase
446
+ * titles, never milestone section boundaries. Version-bearing headings route
447
+ * through `listMilestoneHeadings`; version-less bracket headings route through
448
+ * `isBracketMilestoneBoundary`, the discriminator also used by the bracket
449
+ * fallback locator.
450
+ */
451
+ function isRecognizedMilestoneHeading(headingText, level) {
452
+ if (level < 1 || level > 3)
453
+ return false;
454
+ if (/^Phase\s+\S/i.test(headingText) || BRACKET_PHASE_TAIL_RE.test(headingText))
455
+ return false;
456
+ const line = `${'#'.repeat(level)} ${headingText}`;
457
+ return listMilestoneHeadings(line).length > 0
458
+ || isBracketMilestoneBoundary(headingText, level, null);
459
+ }
361
460
  /**
362
461
  * #3184: the sole milestone-heading locator. Boundary-matched on the version
363
462
  * token with `\b`, NOT the stricter `(?![\w.-])`: this function keeps `\b`
@@ -676,23 +775,32 @@ const PHASE_HEADING_TEXT_RE = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:
676
775
  * leading `#{1,6}` markers and surrounding whitespace already stripped
677
776
  * (`tokenizeHeadings`'s own `h.text`, or the equivalent for a line a caller
678
777
  * already knows is a `#{2,4}` heading) — is a phase heading by the readers'
679
- * own grammar. The single owner of this test; `hasPhaseEntries` below is
680
- * itself just the first caller.
778
+ * own grammar. The single owner of this test: `hasPhaseEntries` reaches it
779
+ * through `isPhaseEntryHeading` below, and the bracket roadmap migrator
780
+ * calls it directly.
681
781
  */
682
782
  function isPhaseHeadingText(headingText) {
683
783
  return PHASE_HEADING_TEXT_RE.test(headingText);
684
784
  }
785
+ /**
786
+ * Does one fence-excluded heading token use the reader's phase-entry grammar?
787
+ * Kept separate from heading depth so destructive consumers can apply the
788
+ * same bracket-plus-legacy recognition to tokens from `tokenizeHeadings`.
789
+ * The legacy arm is `isPhaseHeadingText`, that grammar's single owner.
790
+ */
791
+ function isPhaseEntryHeading(headingText, phaseIdConvention) {
792
+ if (isPhaseHeadingText(headingText))
793
+ return true;
794
+ return phaseIdConvention === 'bracket' && BRACKET_PHASE_ENTRY_HEADING_RE.test(headingText);
795
+ }
685
796
  function hasPhaseEntries(markdown, phaseIdConvention) {
686
797
  // #3641: the widened grammar engages ONLY when the resolved convention is
687
798
  // 'bracket' — a project that has not opted in runs the legacy pattern
688
799
  // alone, byte-identically.
689
- const bracketMode = phaseIdConvention === 'bracket';
690
800
  for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(markdown)) {
691
801
  if (h.level < 2 || h.level > 4)
692
802
  continue;
693
- if (isPhaseHeadingText(h.text))
694
- return true;
695
- if (bracketMode && BRACKET_PHASE_ENTRY_HEADING_RE.test(h.text))
803
+ if (isPhaseEntryHeading(h.text, phaseIdConvention))
696
804
  return true;
697
805
  }
698
806
  // #3184 review finding: the bullet fallback must be fence-aware too, or a
@@ -786,7 +894,7 @@ function hasPhaseListingTableHeader(content) {
786
894
  * narrower question — "which phase ids does this window declare" — where only
787
895
  * the 999 icebox range is excluded.
788
896
  */
789
- function scanMilestonePhaseIdSets(window, convention) {
897
+ function scanMilestonePhaseIdSets(window, convention, context) {
790
898
  const ids = new Set();
791
899
  const qualifiedIds = new Set();
792
900
  // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
@@ -797,7 +905,7 @@ function scanMilestonePhaseIdSets(window, convention) {
797
905
  // belonging to different milestones.
798
906
  const capturing = convention === 'bracket';
799
907
  const bracketGroups = capturing ? 1 : 0;
800
- const phaseHeadingPattern = new RegExp(`^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, capturing)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
908
+ const phaseHeadingPattern = new RegExp(`^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, capturing)}(${context?.phaseNaming === 'custom' && convention !== 'bracket' ? phaseIdModule.phaseScanTokenSource(context) : '[\\w][\\w.-]*'})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
801
909
  for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(window)) {
802
910
  if (h.level < 2 || h.level > 4)
803
911
  continue;
@@ -955,6 +1063,87 @@ function classifyMilestoneWindow(input) {
955
1063
  const PHASE_HEADING_BLOCK_STRIP_RE = new RegExp(
956
1064
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` (OPTIONAL_PHASE_TAG_SOURCE) tolerates a pre-colon ( ) tag.
957
1065
  `^#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY)}${PHASE_NUMBER_TOKEN_SOURCE}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:[^\\n]*(?:\\n(?!#{1,6}\\s)[^\\n]*)*\\n?`, 'gim');
1066
+ /**
1067
+ * #612/#4304: the bracket-fallback candidate search for a milestone heading
1068
+ * carrying no vX.Y version token at all — the ADR-612 canonical shape
1069
+ * (`## [GSD.09] Hidden`). Tried by a caller only when the legacy
1070
+ * version-string search (`locateMilestoneHeadings`) finds nothing and the
1071
+ * project is on the bracket convention. Extracted (#4304 PR-4) from
1072
+ * `extractCurrentMilestoneScoped`'s own inline block so it and
1073
+ * `currentMilestoneRawRanges` share one heading grammar instead of each
1074
+ * re-deriving the bracket intro pattern independently, the Generative Fix
1075
+ * Divergence class #2761 documents. Byte-identical logic to the block it replaces; moving it
1076
+ * changes no behavior.
1077
+ */
1078
+ function bracketFallbackHeadingMatches(content, version, bracketScopeConvention) {
1079
+ if (bracketScopeConvention !== 'bracket')
1080
+ return [];
1081
+ const vMatch = version.match(/^v(\d+)/i);
1082
+ const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
1083
+ if (!Number.isSafeInteger(milestoneInt))
1084
+ return [];
1085
+ // #2761 M3: the phase-id owner supplies both the bracket intro grammar
1086
+ // and canonical pad2 spelling; `[CODE.2]` therefore cannot bound a
1087
+ // section whose phase headings the bracket grammar rejects.
1088
+ // #612: HeadingToken.offset is the line start, so require the
1089
+ // `#` there to preserve the old line-start anchor's indentation parity.
1090
+ // For every survivor, rebuilding [fullLine, fullLine] plus `.index`
1091
+ // retains the match shape and first-match order expected downstream.
1092
+ const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
1093
+ return (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)
1094
+ .filter((h) => h.level <= 3 && content[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text))
1095
+ .map((h) => {
1096
+ const lineEnd = content.indexOf('\n', h.offset);
1097
+ const fullLine = content.slice(h.offset, lineEnd === -1 ? content.length : lineEnd);
1098
+ return Object.assign([fullLine, fullLine], { index: h.offset });
1099
+ });
1100
+ }
1101
+ /**
1102
+ * #612/#4304: the milestone-heading SELECTION fallback chain — prefers the
1103
+ * canonical legacy selector (`selectMilestoneHeading`), then falls back to
1104
+ * the gated bracket candidates (`headingMatches`, already populated by
1105
+ * `bracketFallbackHeadingMatches` when the legacy selector's own internal
1106
+ * `locateMilestoneHeadings` call finds nothing) when a name-only bracket
1107
+ * milestone carries no version token for that selector to find. Shared
1108
+ * (#4304 PR-4) by `extractCurrentMilestoneScoped` and
1109
+ * `currentMilestoneRawRanges` so both agree on which heading is "the active
1110
+ * one" for a version-less bracket milestone. `headingMatches` must be
1111
+ * non-empty (every caller checks this first).
1112
+ */
1113
+ function selectActiveMilestoneHeading(content, version, headingMatches) {
1114
+ return selectMilestoneHeading(content, version)
1115
+ ?? headingMatches.find((m) => !isClosedMilestoneHeading(m[1]))
1116
+ ?? headingMatches[0];
1117
+ }
1118
+ /**
1119
+ * #612/#4304: resolves the bracket-aware milestone SECTION END, the
1120
+ * SELECTED heading's own bracket id, and the `bracketBoundary` predicate
1121
+ * itself — the caller reuses all three afterward (`extractCurrentMilestoneScoped`'s
1122
+ * own "earliest milestone heading" preamble scan reuses `selectedBracketId`;
1123
+ * its "(Phase Details)" section-end lookup reuses `bracketBoundary`).
1124
+ * `bracketBoundaryActive` and `currentMilestoneHeadings` are supplied by the
1125
+ * caller (not recomputed here) because both callers need them again
1126
+ * afterward for their own, divergent purposes. Shared (#4304 PR-4) by
1127
+ * `extractCurrentMilestoneScoped` and `currentMilestoneRawRanges` so a
1128
+ * version-less bracket milestone heading (`## [GSD.09] Hidden`) bounds its
1129
+ * section identically for both, instead of one of them silently running the
1130
+ * LEGACY-only (vX.Y/emoji-only) boundary rule. Byte-identical logic to the
1131
+ * block it replaces; moving it changes no behavior.
1132
+ */
1133
+ function bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings) {
1134
+ // #2761 B1: a bracket heading bearing the selected milestone's own id is
1135
+ // a continuation, not a boundary. Derive the selected id once and compose
1136
+ // the shared discriminator with upstream's centralized section-end walk.
1137
+ const selectedBracketMatch = bracketBoundaryActive
1138
+ ? selected[0].match(new RegExp(`^#{1,3}\\s+\\[(${BRACKET_ID_SRC})\\]`, 'i'))
1139
+ : null;
1140
+ const selectedBracketId = selectedBracketMatch ? foldBracketId(selectedBracketMatch[1]) : null;
1141
+ const bracketBoundary = bracketBoundaryActive
1142
+ ? (heading) => isBracketMilestoneBoundary(heading.text, heading.level, selectedBracketId)
1143
+ : undefined;
1144
+ const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart, bracketBoundary, currentMilestoneHeadings);
1145
+ return { sectionEnd, selectedBracketId, bracketBoundary };
1146
+ }
958
1147
  /**
959
1148
  * #5038: single owner for classifying a `milestone:` scalar as STATE.md spells
960
1149
  * it. The frontmatter parser (FAILSAFE_SCHEMA) and the raw-regex readers both
@@ -975,6 +1164,28 @@ function classifyMilestoneScalar(raw) {
975
1164
  return { explicitNull: true, version: null };
976
1165
  return { explicitNull: false, version: trimmed };
977
1166
  }
1167
+ /**
1168
+ * #4998: STATE.md's `milestone:` scalar as the frontmatter parser DECODES it.
1169
+ * The raw `^milestone:` line read captured the on-disk YAML text — quotes and
1170
+ * escapes included — and that text was re-serialized as the value, adding one
1171
+ * escape layer per `state.*` write. Falls back to the legacy line read only
1172
+ * when there is no parseable frontmatter, or its `milestone` is not text. One
1173
+ * that parses without a `milestone` key names none: a `milestone:` line further
1174
+ * down (a fenced example) is not one (#4998 review, Minor 5).
1175
+ */
1176
+ function readStateMilestoneScalar(stateRaw) {
1177
+ const fm = rawFrontmatterMapping(stateRaw);
1178
+ if (fm) {
1179
+ if (!Object.prototype.hasOwnProperty.call(fm, 'milestone'))
1180
+ return null;
1181
+ if (typeof fm['milestone'] === 'string')
1182
+ return fm['milestone'];
1183
+ // A non-string value keeps the pre-#4998 line read, which finds this
1184
+ // frontmatter line first.
1185
+ }
1186
+ const m = stateRaw.match(/^milestone:\s*(.+)/m);
1187
+ return m ? m[1] : null;
1188
+ }
978
1189
  /**
979
1190
  * Extract the current milestone section from ROADMAP.md by positive lookup,
980
1191
  * carrying a `scope` discriminator (ADR-3180 Decision 2) alongside the value.
@@ -1014,9 +1225,9 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1014
1225
  const statePath = node_path_1.default.join(planningDir(cwd, ws), 'STATE.md');
1015
1226
  const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
1016
1227
  if (stateRaw !== null) {
1017
- const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
1018
- if (milestoneMatch) {
1019
- const classified = classifyMilestoneScalar(milestoneMatch[1]);
1228
+ const milestoneScalar = readStateMilestoneScalar(stateRaw);
1229
+ if (milestoneScalar !== null) {
1230
+ const classified = classifyMilestoneScalar(milestoneScalar);
1020
1231
  version = classified.version;
1021
1232
  stateAssertsNoMilestone = classified.explicitNull;
1022
1233
  }
@@ -1067,26 +1278,8 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1067
1278
  }
1068
1279
  catch { /* unresolvable convention → preserve the legacy fallback */ }
1069
1280
  }
1070
- if (headingMatches.length === 0 && bracketScopeConvention === 'bracket') {
1071
- const vMatch = version.match(/^v(\d+)/i);
1072
- const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
1073
- if (Number.isSafeInteger(milestoneInt)) {
1074
- // #2761 M3: the phase-id owner supplies both the bracket intro grammar
1075
- // and canonical pad2 spelling; `[CODE.2]` therefore cannot bound a
1076
- // section whose phase headings the bracket grammar rejects.
1077
- // #612 round-5: HeadingToken.offset is the line start, so require the
1078
- // `#` there to preserve the old line-start anchor's indentation parity.
1079
- // For every survivor, rebuilding [fullLine, fullLine] plus `.index`
1080
- // retains the match shape and first-match order expected downstream.
1081
- const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
1082
- headingMatches = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)
1083
- .filter((h) => h.level <= 3 && content[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text))
1084
- .map((h) => {
1085
- const lineEnd = content.indexOf('\n', h.offset);
1086
- const fullLine = content.slice(h.offset, lineEnd === -1 ? content.length : lineEnd);
1087
- return Object.assign([fullLine, fullLine], { index: h.offset });
1088
- });
1089
- }
1281
+ if (headingMatches.length === 0) {
1282
+ headingMatches = bracketFallbackHeadingMatches(content, version, bracketScopeConvention);
1090
1283
  }
1091
1284
  if (headingMatches.length === 0) {
1092
1285
  const summaryMatch = content.match(summaryPattern);
@@ -1141,29 +1334,14 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1141
1334
  // #3184: selection collapses to the sole owner; `allMatches` is still needed
1142
1335
  // below (offsets, detailsMatch search), so only the selection itself routes
1143
1336
  // through `selectMilestoneHeading` rather than the whole block.
1144
- // Preserve the canonical legacy selector, then fall back to the gated
1145
- // bracket candidates above when a name-only bracket milestone carries no
1146
- // version token for that selector to find.
1147
- const selected = selectMilestoneHeading(content, version)
1148
- ?? allMatches.find((m) => !isClosed(m[1]))
1149
- ?? firstMatch;
1337
+ const selected = selectActiveMilestoneHeading(content, version, allMatches);
1150
1338
  const sectionStart = selected.index;
1151
- // #2761 B1: a bracket heading bearing the selected milestone's own id is
1152
- // a continuation, not a boundary. Derive the selected id once and compose
1153
- // the shared discriminator with upstream's centralized section-end walk.
1154
1339
  const bracketBoundaryActive = bracketScopeConvention === 'bracket';
1155
- const selectedBracketMatch = bracketBoundaryActive
1156
- ? selected[0].match(new RegExp(`^#{1,3}\\s+\\[(${BRACKET_ID_SRC})\\]`, 'i'))
1157
- : null;
1158
- const selectedBracketId = selectedBracketMatch ? foldBracketId(selectedBracketMatch[1]) : null;
1159
1340
  // #2761 B3: tokenize once for both the centralized section-end owner and
1160
1341
  // the bracket preamble scan below. This keeps both boundary decisions
1161
1342
  // fence-aware without restoring the local section walker retired by #3184.
1162
1343
  const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
1163
- const bracketBoundary = bracketBoundaryActive
1164
- ? (heading) => isBracketMilestoneBoundary(heading.text, heading.level, selectedBracketId)
1165
- : undefined;
1166
- const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart, bracketBoundary, currentMilestoneHeadings);
1344
+ const { sectionEnd, selectedBracketId, bracketBoundary } = bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings);
1167
1345
  const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
1168
1346
  let earliestMilestoneIndex;
1169
1347
  if (!bracketBoundaryActive) {
@@ -1386,18 +1564,261 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1386
1564
  function extractCurrentMilestone(content, cwd, ws) {
1387
1565
  return extractCurrentMilestoneScoped(content, cwd, ws).value;
1388
1566
  }
1389
- function replaceInCurrentMilestone(content, pattern, replacement) {
1390
- const apply = (src) => typeof replacement === 'function'
1391
- ? src.replace(pattern, replacement)
1392
- : src.replace(pattern, replacement);
1393
- const lastDetailsClose = content.lastIndexOf('</details>');
1394
- if (lastDetailsClose === -1) {
1395
- return apply(content);
1567
+ /**
1568
+ * Same-length copy of `s` with HTML comments blanked. An unterminated `<!--`, or
1569
+ * one whose `-->` lies beyond the next heading, is bounded at the end of its own
1570
+ * line so a prose mention never hides later archives. Linear: `-->` and heading
1571
+ * lookups are cached while they stay ahead of the cursor.
1572
+ */
1573
+ function blankComments(s) {
1574
+ const heading = /\n#{1,6} /g;
1575
+ let out = '';
1576
+ let i = 0;
1577
+ let close = 0;
1578
+ let head = -2; // -2: heading not looked up yet (-1 means none ahead); offset 0 must still trigger the first lookup
1579
+ for (let o = s.indexOf('<!--'); o !== -1; o = s.indexOf('<!--', i)) {
1580
+ if (close !== -1 && close < o + 4)
1581
+ close = s.indexOf('-->', o + 4);
1582
+ if (head !== -1 && head < o) {
1583
+ heading.lastIndex = o;
1584
+ const m = heading.exec(s);
1585
+ head = m ? m.index : -1;
1586
+ }
1587
+ let end;
1588
+ if (close !== -1 && (head === -1 || close < head))
1589
+ end = close + 3;
1590
+ else {
1591
+ end = s.indexOf('\n', o);
1592
+ if (end === -1)
1593
+ end = s.length;
1594
+ }
1595
+ out += s.slice(i, o) + s.slice(o, end).replace(/[^\r\n]/g, ' ');
1596
+ i = end;
1597
+ }
1598
+ return out + s.slice(i);
1599
+ }
1600
+ const VERSION_TOKEN = /v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i;
1601
+ /** Same-length copy of `content` with fenced blocks, inline code spans and HTML comments blanked. */
1602
+ function blankCodeAndComments(content) {
1603
+ // Same-length copy with fenced blocks, inline code spans and HTML comments
1604
+ // blanked (code first: a backticked `<!--` must not open a comment), so a `<details>` mentioned in prose or an example never opens or
1605
+ // closes a block.
1606
+ const lines = content.split('\n');
1607
+ const blank = (t) => t.replace(/[^\r\n]/g, ' ');
1608
+ for (const f of (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(lines)) {
1609
+ // An unclosed fence is bounded at the next heading, not EOF, so later archives stay visible.
1610
+ let last = f.closeLineIdx;
1611
+ if (last === -1) {
1612
+ last = lines.length - 1;
1613
+ for (let i = f.openLineIdx + 1; i < lines.length; i++)
1614
+ if (/^#{1,6} /.test(lines[i])) {
1615
+ last = i - 1;
1616
+ break;
1617
+ }
1618
+ }
1619
+ for (let i = f.openLineIdx; i <= last; i++)
1620
+ lines[i] = blank(lines[i]);
1621
+ }
1622
+ const joined = lines.join('\n');
1623
+ let codeBlanked = '';
1624
+ let at = 0;
1625
+ for (const sp of (0, markdown_sectionizer_cjs_1.scanInlineCodeSpans)(joined)) {
1626
+ codeBlanked += joined.slice(at, sp.start) + blank(joined.slice(sp.start, sp.end));
1627
+ at = sp.end;
1628
+ }
1629
+ return blankComments(codeBlanked + joined.slice(at));
1630
+ }
1631
+ /**
1632
+ * A closed milestone written as a plain heading section (no `<details>`) is archive too; the
1633
+ * active milestone's own `## v2.0 Part 2 ✅` sections are not.
1634
+ */
1635
+ function plainClosedSections(content, overlapsActive, activeVersion) {
1636
+ // A closed milestone written as a plain heading section (no `<details>`) is
1637
+ // archive too. It ends at the next heading of the same or shallower level, so
1638
+ // a following `## Progress` table stays visible.
1639
+ const headingTokens = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
1640
+ const spans = [];
1641
+ for (let i = 0; i < headingTokens.length; i++) {
1642
+ const h = headingTokens[i];
1643
+ if (/^Phase\s+\S/i.test(h.text))
1644
+ continue;
1645
+ if (!isClosedMilestoneHeading(h.text) || extractMilestoneHeadingName(h.text) === null)
1646
+ continue;
1647
+ let next;
1648
+ for (let j = i + 1; j < headingTokens.length && !next; j++) {
1649
+ const t = headingTokens[j];
1650
+ if (t.level <= h.level && !/^Phase\s+\S/i.test(t.text))
1651
+ next = t;
1652
+ }
1653
+ // End before the newline that precedes `next`, so that heading stays at line start.
1654
+ const end = next ? next.offset - (content[next.offset - 2] === '\r' ? 2 : 1) : content.length;
1655
+ if (!overlapsActive(h.offset, end) && !(activeVersion && h.text.match(VERSION_TOKEN)?.[0].toLowerCase() === activeVersion))
1656
+ spans.push({ start: h.offset, end });
1657
+ }
1658
+ return spans;
1659
+ }
1660
+ /**
1661
+ * #5553: locate every archive span in `content` (`<details>` blocks and plain closed-milestone
1662
+ * sections) that does not wrap the active milestone; `inActive` is the subset overlapping it.
1663
+ */
1664
+ function scanArchiveSpans(content, active) {
1665
+ const activeVersion = active.length > 0 ? content.slice(active[0].start, active[0].start + 300).split('\n', 1)[0].match(VERSION_TOKEN)?.[0].toLowerCase() : undefined;
1666
+ // A block whose own <summary> marks a closed milestone is an archive wherever it sits.
1667
+ const closedSummary = (at) => {
1668
+ const m = /^<details\b[^>]{0,200}>[\s>]*<summary[^>]{0,200}>([^<]{0,300})<\/summary>/i.exec(content.slice(at, at + 800));
1669
+ return m !== null && isClosedMilestoneHeading(m[1]);
1670
+ };
1671
+ const scan = blankCodeAndComments(content);
1672
+ // Depth-aware: an outer block closes at its own `</details>`, so a nested
1673
+ // collapsed section inside an archive is masked with it.
1674
+ const spans = [];
1675
+ const inActive = [];
1676
+ const overlapsActive = (start, end) => active.some((r) => start < r.end && end > r.start);
1677
+ // Block-level tags only: a tag at line start (<=3 spaces, optionally inside a
1678
+ // blockquote `>` prefix) starts the scan of its line, and further tags on that
1679
+ // same line (a one-line `<details>...</details>`) count with it. Inline or
1680
+ // indented mentions elsewhere never count, and the bounded attribute run keeps
1681
+ // a hostile run of `<details ` linear.
1682
+ const lineStart = /^[ \t]{0,3}(?:>[ \t]{0,3})*(?=<\/?details\b)/gim;
1683
+ const anyTag = /<details\b[^>]{0,200}>|<\/details[ \t]*>/gi;
1684
+ const tags = [];
1685
+ for (let l = lineStart.exec(scan); l; l = lineStart.exec(scan)) {
1686
+ const from = l.index + l[0].length;
1687
+ const nl = scan.indexOf('\n', from);
1688
+ const lineEnd = nl === -1 ? scan.length : nl;
1689
+ anyTag.lastIndex = from;
1690
+ for (let t = anyTag.exec(scan); t && t.index < lineEnd; t = anyTag.exec(scan)) {
1691
+ tags.push({ at: t.index, len: t[0].length, closer: t[0][1] === '/' });
1692
+ }
1693
+ lineStart.lastIndex = Math.max(lineStart.lastIndex, lineEnd);
1694
+ }
1695
+ // Stack pairing: every closer matches its nearest opener, so each closed pair is
1696
+ // evaluated on its own (a nested archive inside an active wrapper is masked) and
1697
+ // an unmatched opener or closer is ignored instead of disabling later masking.
1698
+ const openers = [];
1699
+ const kept = [];
1700
+ let paired = false;
1701
+ let strayCloser = false;
1702
+ for (const tag of tags) {
1703
+ if (!tag.closer) {
1704
+ openers.push(tag.at);
1705
+ continue;
1706
+ }
1707
+ const start = openers.pop();
1708
+ if (start === undefined) {
1709
+ // A closer with nothing to pair after an earlier pair closed: that pair may have swallowed this block's own opener.
1710
+ if (paired)
1711
+ strayCloser = true;
1712
+ continue;
1713
+ }
1714
+ paired = true;
1715
+ const end = tag.at + tag.len;
1716
+ // Only a block wrapping an active heading stays editable; any other block is archive, with or without a closed <summary>.
1717
+ if (active.some((r) => start <= r.start && r.start < end))
1718
+ continue;
1719
+ const inRange = overlapsActive(start, end) && !closedSummary(start);
1720
+ // A collapsed block of the active range that holds the Progress table stays editable, so the row moves with the plan ticks.
1721
+ // A level-1 `# Progress` has always counted here (#5692: no behavior change), hence `levels`.
1722
+ if (inRange && (0, roadmap_progress_cjs_1.findProgressSection)(scan.slice(start, end), { levels: [1, 2] }) !== null) {
1723
+ kept.push({ start, end });
1724
+ continue;
1725
+ }
1726
+ spans.push({ start, end });
1727
+ if (inRange)
1728
+ inActive.push({ start, end });
1729
+ }
1730
+ // An opener left unpaired makes the structure ambiguous when it either opens a
1731
+ // closed-summary archive that does not close on its own line (its extent is unknown) or precedes a masked
1732
+ // block in the active range (it may be that block's real wrapper, with the
1733
+ // stray tag inside). A harmless stray opener is ignored.
1734
+ const eol = (o) => { const e = content.indexOf('\n', o); return e === -1 ? content.length : e; };
1735
+ const ambiguous = strayCloser || openers.some((o) => closedSummary(o) && !/<\/details/i.test(content.slice(o, eol(o)))) || (openers.length > 0 && [...inActive, ...kept].some((r) => r.start > openers[0]));
1736
+ spans.push(...plainClosedSections(content, overlapsActive, activeVersion));
1737
+ return { spans, inActive, ambiguous };
1738
+ }
1739
+ /**
1740
+ * #5553: hide every archived `<details>` block, and every closed milestone
1741
+ * written as a plain heading section, behind a placeholder; `restore` puts them
1742
+ * back unchanged. Tag case, attributes and archive position
1743
+ * (before OR after the active milestone) do not matter, unlike a "region after
1744
+ * the last `</details>`" cut. A block stays visible only when it wraps the
1745
+ * start of a resolved active milestone range (`<details open>` around the
1746
+ * active milestone); every other block is hidden, whatever its `<summary>`
1747
+ * (none, non-closed, closed), including a collapsed block inside the active
1748
+ * range. That is deliberate for THIS write path only; the read-side
1749
+ * `stripClosedMilestoneDetails` window (#3982/#1341) is unchanged. With no
1750
+ * resolvable active milestone every block is hidden, as the old
1751
+ * last-`</details>` cut did. `ambiguous` flags unpaired tags that hide where an
1752
+ * archive ends (the caller declines); `activeRange` is the active milestone's
1753
+ * range in `visible` coordinates.
1754
+ */
1755
+ function maskArchivedDetails(content, cwd) {
1756
+ if (!/<details\b/i.test(content) && !MILESTONE_CLOSED_MARKER_PATTERN.test(content)) {
1757
+ return { visible: content, restore: (edited) => edited, hiddenInActive: '', ambiguous: false, activeRange: null };
1758
+ }
1759
+ const resolved = currentMilestoneRawRanges(content, cwd);
1760
+ // A stale STATE can name a milestone whose own heading is closed (all headings closed): nothing is active, so everything shipped stays masked.
1761
+ const ranges = resolved && !isClosedMilestoneHeading(content.slice(resolved.primary.start).split('\n', 1)[0]) ? resolved : null;
1762
+ const active = ranges ? [ranges.primary, ...(ranges.details ? [ranges.details] : [])] : [];
1763
+ const { spans, inActive, ambiguous } = scanArchiveSpans(content, active);
1764
+ const blocks = [];
1765
+ // Unguessable per call: a plan filename or any other edited-in text cannot forge a marker.
1766
+ const nonce = (0, node_crypto_1.randomBytes)(8).toString('hex');
1767
+ const marker = (i) => `<!-- gsd-archived-details-${nonce}-${i} -->`;
1768
+ spans.sort((x, y) => x.start - y.start || y.end - x.end);
1769
+ let visible = '';
1770
+ let last = 0;
1771
+ const hidden = [];
1772
+ for (const span of spans) {
1773
+ if (span.start < last) {
1774
+ // Nested in an already-masked span: only a partial overlap extends it.
1775
+ if (span.end <= last)
1776
+ continue;
1777
+ span.start = last;
1778
+ }
1779
+ blocks.push(content.slice(span.start, span.end));
1780
+ visible += content.slice(last, span.start);
1781
+ const m = marker(blocks.length - 1);
1782
+ hidden.push({ start: span.start, end: span.end, vis: visible.length, len: m.length });
1783
+ visible += m;
1784
+ last = span.end;
1396
1785
  }
1397
- const offset = lastDetailsClose + '</details>'.length;
1398
- const before = content.slice(0, offset);
1399
- const after = content.slice(offset);
1400
- return before + apply(after);
1786
+ visible += content.slice(last);
1787
+ // One pass, so a hostile ROADMAP with thousands of blocks stays linear.
1788
+ const markerRe = new RegExp(`<!-- gsd-archived-details-${nonce}-(\\d+) -->`, 'g');
1789
+ const restore = (edited) => {
1790
+ const seen = new Array(blocks.length).fill(0);
1791
+ let bad = false;
1792
+ const restored = edited.replace(markerRe, (m, i) => {
1793
+ const n = Number(i);
1794
+ if (n >= blocks.length || seen[n]++ > 0) {
1795
+ bad = true;
1796
+ return m;
1797
+ }
1798
+ return blocks[n];
1799
+ });
1800
+ // edit dropped or duplicated a placeholder: caller must refuse rather than lose or misplace an archive
1801
+ return bad || seen.includes(0) ? null : restored;
1802
+ };
1803
+ // Blocks hidden inside the active range stay unticked, but their rows still count as present.
1804
+ const hiddenInActive = inActive.map((r) => content.slice(r.start, r.end)).join('\n');
1805
+ // Original offset -> offset in `visible`.
1806
+ const toVisible = (p) => {
1807
+ let h;
1808
+ for (const x of hidden) {
1809
+ if (x.start < p)
1810
+ h = x;
1811
+ else
1812
+ break;
1813
+ }
1814
+ if (!h)
1815
+ return p;
1816
+ return p >= h.end ? h.vis + h.len + (p - h.end) : h.vis;
1817
+ };
1818
+ const activeRange = active.length > 0
1819
+ ? { start: toVisible(Math.min(...active.map((r) => r.start))), end: toVisible(Math.max(...active.map((r) => r.end))) }
1820
+ : null;
1821
+ return { visible, restore, hiddenInActive, ambiguous, activeRange };
1401
1822
  }
1402
1823
  /**
1403
1824
  * Resolve a single phase's detail-section heading (`### Phase N: …`, any level
@@ -1431,11 +1852,21 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
1431
1852
  * phase-heading level, so a level-bounded stop could fold a deeper sibling
1432
1853
  * heading (e.g. a `####` phase following a `###` phase) into this phase's
1433
1854
  * body and let `edit` reach into it.
1855
+ *
1856
+ * `levelBounded: true` instead keeps the phase's own non-phase `####` sub-sections
1857
+ * (a plan list under a sub-heading) in the body, still ending at the next heading
1858
+ * that opens another phase, of any level. Serves the presence check and the
1859
+ * plan-row tick writer.
1434
1860
  */
1435
- function withPhaseSection(content, phaseId, edit) {
1861
+ function withPhaseSection(content, phaseId, edit, levelBounded = false) {
1436
1862
  const src = phaseMarkdownRegexSource(phaseId);
1437
1863
  const headingRe = new RegExp(`^\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${src}(?=[\\s:(]|$)`, 'i');
1438
- return (0, markdown_sectionizer_cjs_1.withSection)(content, (h) => headingRe.test(h.text), edit, { levelBounded: false });
1864
+ // levelBounded runs to the next same-or-higher heading, so cut at a deeper phase heading too.
1865
+ const ownBody = !levelBounded ? edit : (body) => {
1866
+ const cut = body.search(/^#{1,6}[ \t]+(?:\[[^\]\n]{1,200}\][ \t]*)?Phase\s+\S/m);
1867
+ return cut === -1 ? edit(body) : edit(body.slice(0, cut)) + body.slice(cut);
1868
+ };
1869
+ return (0, markdown_sectionizer_cjs_1.withSection)(content, (h) => headingRe.test(h.text), ownBody, { levelBounded });
1439
1870
  }
1440
1871
  // ─── Roadmap phase lookup ─────────────────────────────────────────────────────
1441
1872
  // #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**`
@@ -1443,11 +1874,11 @@ function withPhaseSection(content, phaseId, edit) {
1443
1874
  // is captured in group 1, the name in group 2; the separator may be an em-dash,
1444
1875
  // en-dash, hyphen, or colon. Used as a fallback when no ATX heading matches, and
1445
1876
  // to count phases in a milestone that uses the bullet form.
1446
- const BULLET_PHASE_LINE_PATTERN = /^\s*[-*]\s+(?:\[[ xX]\]\s+)?\*\*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*[—–:\-]\s*(.+?)\*\*/im;
1877
+ const BULLET_PHASE_LINE_PATTERN = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*(.+?)${phase_entry_grammar_cjs_1.PHASE_ENTRY_BOLD_CLOSE_SRC}`, 'im', { box: 'optional', bold: 'required' });
1447
1878
  /** Build a bullet-phase-line regex pinned to a specific phase number (#2199). */
1448
1879
  function bulletPhaseLineFor(phaseNum, phaseSource) {
1449
1880
  const num = phaseSource ?? phaseMarkdownRegexSource(phaseNum);
1450
- return new RegExp(`^\\s*[-*]\\s+(?:\\[[ xX]\\]\\s+)?\\*\\*Phase\\s+(${num})${OPTIONAL_PHASE_TAG_SOURCE}\\s*[—–:\\-]\\s*(.+?)\\*\\*`, 'im');
1881
+ return (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${num})${OPTIONAL_PHASE_TAG_SOURCE}\\s*[—–:\\-]\\s*(.+?)${phase_entry_grammar_cjs_1.PHASE_ENTRY_BOLD_CLOSE_SRC}`, 'im', { box: 'optional', bold: 'required' });
1451
1882
  }
1452
1883
  function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
1453
1884
  // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
@@ -1588,39 +2019,6 @@ function reportUnreadableRoadmap(err, roadmapPath) {
1588
2019
  return;
1589
2020
  warnUnusableInput({ reason: UNUSABLE_REASON.ROADMAP_UNREADABLE, source: roadmapPath });
1590
2021
  }
1591
- // ─── Roadmap progress table (#1956/#2012 decoy avoidance) ─────────────────────
1592
- /**
1593
- * Locate ROADMAP.md's "Progress" table — the sole owner of the #2012
1594
- * decoy-avoidance scope for the `drift-guard phase-status` CLI seam (#1956).
1595
- *
1596
- * Scopes to the `## Progress` heading first (level-2, exact case-insensitive
1597
- * text `'progress'`, `{ levelBounded: true }`) via `collectSection` — the
1598
- * same CRLF-safe seam `stateCurrentPositionSlice` (state-document.cts) uses
1599
- * to scope STATE.md's `## Current Position` — so a differently-headed table
1600
- * that happens to share the same column names (e.g. an "Archive Notes"
1601
- * table) is never picked up instead of the real one (#2012). Falls back to
1602
- * scanning the WHOLE document when no `## Progress` heading exists, so a
1603
- * headingless milestone slice (#1445) still resolves rather than going
1604
- * uncheckable — the same fallback `deriveProgressFromRoadmap`
1605
- * (phase-lifecycle.cts) deliberately preserves.
1606
- *
1607
- * `deriveProgressFromRoadmap` independently expresses this same "scope to
1608
- * `## Progress`, else whole document" rule via its own regex-based scope
1609
- * (kept there deliberately rather than refactored onto this function — its
1610
- * blast radius is large). The two locators are therefore separate
1611
- * implementations of the same scoping rule and must agree about WHICH table
1612
- * is the Progress table; a parity test in
1613
- * tests/adr-22-plan-drift-guard.test.cjs asserts they do, per the repo's
1614
- * generative-fix-divergence guard.
1615
- *
1616
- * Returns the same shape `findTableWithColumns` returns (or `null`).
1617
- */
1618
- function findRoadmapProgressTable(roadmapContent) {
1619
- const isProgressHeading = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'progress';
1620
- const section = (0, markdown_sectionizer_cjs_1.collectSection)(roadmapContent, isProgressHeading, { levelBounded: true });
1621
- const scoped = section ? section.body : roadmapContent;
1622
- return (0, markdown_table_cjs_1.findTableWithColumns)(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
1623
- }
1624
2022
  /**
1625
2023
  * Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
1626
2024
  * milestone-name capture. Markdown headings commonly take the shape
@@ -1777,9 +2175,9 @@ function getMilestoneInfo(cwd) {
1777
2175
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1778
2176
  const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
1779
2177
  if (stateRaw !== null) {
1780
- const m = stateRaw.match(/^milestone:\s*(.+)/m);
1781
- if (m) {
1782
- const classified = classifyMilestoneScalar(m[1]);
2178
+ const milestoneScalar = readStateMilestoneScalar(stateRaw);
2179
+ if (milestoneScalar !== null) {
2180
+ const classified = classifyMilestoneScalar(milestoneScalar);
1783
2181
  explicitlyNoMilestone = classified.explicitNull;
1784
2182
  stateVersion = classified.version;
1785
2183
  }
@@ -1913,7 +2311,7 @@ function getMilestoneInfo(cwd) {
1913
2311
  * resolution exactly, including its `GSD_WORKSTREAM` env fallback — every
1914
2312
  * pre-#2562 call site is unaffected.
1915
2313
  */
1916
- function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
2314
+ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws, context) {
1917
2315
  const milestonePhaseNums = new Set();
1918
2316
  // #612: the milestone-QUALIFIED form (`{CODE}.{MM}-{PP}`) of each in-scope
1919
2317
  // bracket heading, kept in its OWN set — deliberately not in
@@ -2010,7 +2408,7 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
2010
2408
  // #3262 remains the single owner of the phase-set scan. #612 extends its
2011
2409
  // return with qualified bracket ids rather than restoring the superseded
2012
2410
  // inline duplicate this commit was originally written against.
2013
- const scanned = scanMilestonePhaseIdSets(roadmap, headingConvention);
2411
+ const scanned = scanMilestonePhaseIdSets(roadmap, headingConvention, context);
2014
2412
  for (const id of scanned.ids)
2015
2413
  milestonePhaseNums.add(id);
2016
2414
  for (const qualified of scanned.qualifiedIds)
@@ -2064,7 +2462,11 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
2064
2462
  ? new RegExp(`^0*(\\d+[A-Za-z]?(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?)*(?:\\.\\d+)*)(?=-|$)`)
2065
2463
  // phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE.
2066
2464
  : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
2465
+ const customKeys = context?.phaseNaming === 'custom' && headingConvention !== 'bracket'
2466
+ ? new Set([...milestonePhaseNums].map(id => phaseIdModule.phaseKeyFromToken(id, context))) : null;
2067
2467
  function isDirInMilestone(dirName) {
2468
+ if (customKeys)
2469
+ return customKeys.has(phaseIdModule.phaseKeyFromDir(dirName, headingConvention, context));
2068
2470
  // #612: the DIR side of this filter, selected by the same convention the
2069
2471
  // heading side selected. Without it the heading scan's new bracket reach was
2070
2472
  // half a fix: `milestonePhaseNums` became non-empty, so the pass-all degrade
@@ -2153,27 +2555,31 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
2153
2555
  * Details section) — both consume the same `locateMilestoneHeadings` /
2154
2556
  * `computeMilestoneSectionEnd` owner (#3184), so there is no separate copy to
2155
2557
  * keep in sync. Returns null when there is no versioned active milestone;
2156
- * callers then fall back to whole-content mutation (the prior behaviour).
2558
+ * callers then fall back to the shipped-block-masked whole content
2559
+ * (`editOutsideShippedBlocks`) via `editCurrentMilestone`, or to unscoped
2560
+ * whole-content handling where they call this directly (`phase.cts`
2561
+ * `phaseEntryInsertOffset`).
2157
2562
  *
2158
- * #2761 (round-2 review, Minor 3 — latent, currently harmless): this function
2159
- * consumes #3184's shared owners, but it does not pass the bracket-specific
2160
- * B1/B2 boundary predicate and still has no bracket-fallback SELECTION branch:
2161
- * it returns null when the version-string owner finds nothing, unlike
2162
- * `extractCurrentMilestoneScoped`. The read owner can therefore scope a
2163
- * bracket ROADMAP this write-range consumer still calls unscoped. Probed and
2164
- * confirmed harmless TODAY: this
2165
- * function's single consumer (`mutateMilestonePhase`, src/phase.cts) falls
2166
- * back to whole-content mutation when it returns null, and every mutation
2167
- * inside that caller is still `Phase`-labelled-only (not bracket-widened) per
2168
- * the changeset's own "READ-path opt-in until the migrator and write path
2169
- * land" — so a bracket ROADMAP's checkbox/heading patterns never match inside
2170
- * that fallback and nothing is mutated cross-milestone. The moment the write
2171
- * path is widened (PR-3+), this divergence becomes live: the whole-content
2172
- * fallback would become a cross-milestone writer, which is exactly what this
2173
- * note exists to prevent. Bracket-widen this function in lockstep with the
2174
- * write path landing, not before.
2563
+ * #2761 / #4304: bracket-widened
2564
+ * in lockstep with the write path landing, per this doc's own prior note.
2565
+ * `phaseIdConvention` is the same additive, optional trailing parameter
2566
+ * `extractCurrentMilestoneScoped` accepts — omitted (every pre-#4304 2-arg
2567
+ * call site), it compiles byte-identically to before: `bracketFallbackHeadingMatches`
2568
+ * and `bracketAwareMilestoneSection`'s bracket branch are both no-ops unless
2569
+ * explicitly told `'bracket'`, so a version-string match still short-circuits
2570
+ * both, and a non-bracket/unresolved convention still returns null exactly as
2571
+ * it always has. Now shares its heading-selection and section-end grammar
2572
+ * with `extractCurrentMilestoneScoped` (`bracketFallbackHeadingMatches`,
2573
+ * `selectActiveMilestoneHeading`, `bracketAwareMilestoneSection`) instead of
2574
+ * carrying its own unwidened copy, so a version-less bracket milestone
2575
+ * heading (`## [GSD.09] Hidden`) bounds a section for this function's callers
2576
+ * too, not only for the read path.
2577
+ * Consumers: `editCurrentMilestone` (`phase complete`'s `mutateMilestonePhase`,
2578
+ * `roadmap update-plan-progress`) and `phase.cts` `phaseEntryInsertOffset`. On the null
2579
+ * path `editCurrentMilestone` edits the ROADMAP with closed-milestone `<details>`
2580
+ * blocks masked, so no writer reaches into a shipped block.
2175
2581
  */
2176
- function currentMilestoneRawRanges(content, cwd) {
2582
+ function currentMilestoneRawRanges(content, cwd, phaseIdConvention) {
2177
2583
  if (!cwd)
2178
2584
  return null;
2179
2585
  let version = null;
@@ -2182,9 +2588,9 @@ function currentMilestoneRawRanges(content, cwd) {
2182
2588
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
2183
2589
  const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
2184
2590
  if (stateRaw !== null) {
2185
- const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
2186
- if (milestoneMatch) {
2187
- const classified = classifyMilestoneScalar(milestoneMatch[1]);
2591
+ const milestoneScalar = readStateMilestoneScalar(stateRaw);
2592
+ if (milestoneScalar !== null) {
2593
+ const classified = classifyMilestoneScalar(milestoneScalar);
2188
2594
  version = classified.version;
2189
2595
  stateAssertsNoMilestone = classified.explicitNull;
2190
2596
  }
@@ -2198,15 +2604,21 @@ function currentMilestoneRawRanges(content, cwd) {
2198
2604
  }
2199
2605
  if (!version)
2200
2606
  return null;
2201
- const headingMatches = locateMilestoneHeadings(content, version);
2607
+ const bracketScopeConvention = phaseIdConvention ?? null;
2608
+ let headingMatches = locateMilestoneHeadings(content, version);
2609
+ if (headingMatches.length === 0) {
2610
+ headingMatches = bracketFallbackHeadingMatches(content, version, bracketScopeConvention);
2611
+ }
2202
2612
  if (headingMatches.length === 0)
2203
2613
  return null;
2204
2614
  const isClosed = isClosedMilestoneHeading;
2205
2615
  // #3184: selection collapses to the sole owner; `headingMatches` is still
2206
2616
  // needed below for the detailsMatch search over all headings.
2207
- const selected = selectMilestoneHeading(content, version);
2617
+ const selected = selectActiveMilestoneHeading(content, version, headingMatches);
2208
2618
  const sectionStart = selected.index ?? 0;
2209
- const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart);
2619
+ const bracketBoundaryActive = bracketScopeConvention === 'bracket';
2620
+ const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
2621
+ const { sectionEnd, bracketBoundary } = bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings);
2210
2622
  const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
2211
2623
  const detailsVersionBoundary = selectedVersionToken
2212
2624
  ? new RegExp(`${(0, pattern_cjs_1.escapeRegex)(selectedVersionToken)}(?![\\w.-])`, 'i')
@@ -2218,10 +2630,160 @@ function currentMilestoneRawRanges(content, cwd) {
2218
2630
  let details = null;
2219
2631
  if (detailsMatch) {
2220
2632
  const detailsStart = detailsMatch.index ?? 0;
2221
- details = { start: detailsStart, end: computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart) };
2633
+ // #4304: pass the SAME bracketBoundary (and heading tokens)
2634
+ // bracketAwareMilestoneSection already derived for the primary range —
2635
+ // extractCurrentMilestoneScoped's own details lookup (line ~1312) has
2636
+ // always done this; this copy omitted both trailing arguments, so a
2637
+ // version-less bracket milestone heading (no v\d+.\d+/emoji marker) never
2638
+ // stopped this details window at the NEXT milestone's own "(Phase
2639
+ // Details)" section, and the two copies silently disagreed on the active
2640
+ // details window.
2641
+ details = {
2642
+ start: detailsStart,
2643
+ end: computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart, bracketBoundary, currentMilestoneHeadings),
2644
+ };
2222
2645
  }
2223
2646
  return { primary: { start: sectionStart, end: sectionEnd }, details };
2224
2647
  }
2648
+ /** `<details>` / `</details>` tags of `content` in document order (bare opener only: `<details open>` wraps the active milestone, it is not an archive). */
2649
+ function bareDetailsTags(content) {
2650
+ return [...content.matchAll(/<(\/?)details>/gi)].map((m) => ({ at: m.index, close: m[1] === '/' }));
2651
+ }
2652
+ /** Whether offset `idx` sits inside a bare `<details>` block that has not closed yet (`tags` from `bareDetailsTags`). */
2653
+ function insideBareDetails(tags, idx) {
2654
+ let lo = 0;
2655
+ let hi = tags.length;
2656
+ while (lo < hi) {
2657
+ const mid = (lo + hi) >> 1;
2658
+ if (tags[mid].at < idx)
2659
+ lo = mid + 1;
2660
+ else
2661
+ hi = mid;
2662
+ }
2663
+ return lo > 0 && !tags[lo - 1].close;
2664
+ }
2665
+ /** A phase heading or a bold phase-list entry: what makes a block a shipped/backlog milestone. */
2666
+ const ACTIVE_SCOPE_PHASE_ENTRY_SRC = `(?:^#{2,4}[ \\t]+Phase\\b|${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ bold: 'required' })}Phase\\b)`;
2667
+ /**
2668
+ * The active milestone's writable scope as offsets into `content`.
2669
+ *
2670
+ * `start` is just past the last `</details>` that closes BEFORE the selected
2671
+ * milestone heading, so a backlog `</details>` AFTER it no longer hides it
2672
+ * (#5221) while phase/Progress content above the heading (#2947 preamble layout)
2673
+ * stays in scope. No resolved heading, or one that is itself closed (stale
2674
+ * STATE), uses the last `</details>` before the first phase entry outside any
2675
+ * open <details>, so a backlog `</details>` below the active phases does not
2676
+ * hide them.
2677
+ *
2678
+ * `end` is the first line-leading bare `<details>` AFTER that heading (or first
2679
+ * phase entry): a shipped block placed below the active milestone is never
2680
+ * writable, so a second run cannot tick the archived phase once the active one
2681
+ * is done. Writers that must reach a bottom `## Progress` past a backlog block
2682
+ * (`editProgressTableSlice`) use `start` alone. Tags match case-insensitively.
2683
+ * An archive whose `</details>` is missing cannot be told from a wrapper, so
2684
+ * `start` stays 0 for it (documented fail-open).
2685
+ */
2686
+ function activeScope(content, cwd) {
2687
+ const tags = bareDetailsTags(content);
2688
+ const headingAt = currentMilestoneRawRanges(content, cwd)?.primary.start;
2689
+ const open = headingAt !== undefined && !isClosedMilestoneHeading(content.slice(headingAt).split('\n', 1)[0]);
2690
+ let anchor;
2691
+ if (open) {
2692
+ anchor = headingAt;
2693
+ }
2694
+ else {
2695
+ const firstActive = [...content.matchAll(new RegExp(ACTIVE_SCOPE_PHASE_ENTRY_SRC, 'gim'))]
2696
+ .find((m) => !insideBareDetails(tags, m.index));
2697
+ anchor = firstActive?.index ?? content.length;
2698
+ }
2699
+ let start = 0;
2700
+ for (const t of tags) {
2701
+ if (t.at >= anchor)
2702
+ break;
2703
+ if (t.close)
2704
+ start = t.at + '</details>'.length;
2705
+ }
2706
+ // Only a block that carries phase entries is a shipped/backlog milestone; a collapsible note inside the active milestone stays writable.
2707
+ const phaseEntry = new RegExp(ACTIVE_SCOPE_PHASE_ENTRY_SRC, 'im');
2708
+ const below = tags.find((t, i) => {
2709
+ if (t.close || t.at <= anchor || !/^[ \t]*$/.test(content.slice(content.lastIndexOf('\n', t.at - 1) + 1, t.at)))
2710
+ return false;
2711
+ const close = tags.slice(i + 1).find((c) => c.close);
2712
+ return phaseEntry.test(content.slice(t.at, close ? close.at : content.length));
2713
+ });
2714
+ return { start, end: below ? below.at : content.length };
2715
+ }
2716
+ function activeScopeStart(content, cwd) {
2717
+ return activeScope(content, cwd).start;
2718
+ }
2719
+ /** [start,end) windows of the current milestone, document order; the whole ROADMAP when none resolves. */
2720
+ function currentMilestoneWindows(content, cwd) {
2721
+ const ranges = currentMilestoneRawRanges(content, cwd);
2722
+ if (!ranges)
2723
+ return [{ start: 0, end: content.length }];
2724
+ // `details` always starts at or after `primary.end` (see `currentMilestoneRawRanges`),
2725
+ // so this is document order: a "first window wins" caller means the primary one.
2726
+ return [ranges.primary, ranges.details].filter((w) => w !== null);
2727
+ }
2728
+ /**
2729
+ * Read-only counterpart of `editCurrentMilestone`: the text of each window,
2730
+ * with closed-milestone `<details>` blocks hidden exactly as the writers see it.
2731
+ */
2732
+ function readCurrentMilestone(content, cwd) {
2733
+ return currentMilestoneWindows(content, cwd).map((w) => maskShippedBlocks(content.slice(w.start, w.end)).masked);
2734
+ }
2735
+ /**
2736
+ * Run `edit` over the current milestone's raw window(s) from
2737
+ * `currentMilestoneRawRanges`, or, when no milestone resolves, over the whole
2738
+ * ROADMAP, each with closed-milestone `<details>` blocks hidden (#5543;
2739
+ * `editOutsideShippedBlocks`: a notes or phase-wrapper block stays editable).
2740
+ * `edit` is called once per window, in document order. The one milestone
2741
+ * scoping for per-phase writers (`phase complete`, `roadmap
2742
+ * update-plan-progress` #5483), so neither cuts at the last `</details>`.
2743
+ */
2744
+ function editCurrentMilestone(content, cwd, edit) {
2745
+ const windows = currentMilestoneWindows(content, cwd);
2746
+ const edited = windows.map((w) => editOutsideShippedBlocks(content.slice(w.start, w.end), edit));
2747
+ // Splice last-first so an earlier window's offsets are not shifted by a
2748
+ // length-changing mutation in a later window.
2749
+ return windows.reduceRight((acc, w, i) => acc.slice(0, w.start) + edited[i] + acc.slice(w.end), content);
2750
+ }
2751
+ /**
2752
+ * The checklist bullet of the phase whose number matches `phasePattern`
2753
+ * (`- [ ] **Phase N: …**`), as the one grammar shared by the Plans-count /
2754
+ * completion stamp and the plan-row ticks. `anyState` also matches a checked box.
2755
+ * Group 1 is everything through the opening `[` (indent and marker included),
2756
+ * group 2 the rest of the line from the closing `]`. The prefix (marker, box, bold token) is the phase-entry grammar's (#5764); the
2757
+ * tail stops before `\r` so a stamp appended to group 2 lands on the entry's
2758
+ * line, before its CRLF.
2759
+ */
2760
+ function phaseChecklistBullet(phasePattern, anyState) {
2761
+ return (0, phase_entry_grammar_cjs_1.phaseEntrySplitRegExp)(`Phase[ \\t]+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s]|$)[^\\r\\n]*`, 'i', { state: anyState ? 'any' : 'unchecked' });
2762
+ }
2763
+ /** A checklist bullet of ANY phase: ends the rows nested under the previous one. */
2764
+ const ANY_PHASE_BULLET = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)('Phase[ \\t]', 'i');
2765
+ /**
2766
+ * [start,end) of the rows nested under a phase's checklist bullet: from the
2767
+ * line after `bullet` (a match of `phaseChecklistBullet` in `text`) up to a
2768
+ * blank line, a shallower line, or the next phase's bullet. Empty when the
2769
+ * bullet is the last line with no trailing newline.
2770
+ */
2771
+ function checklistBulletExtent(text, bullet) {
2772
+ // The bullet's own indent, read from its line: a caller's capture group 1 may
2773
+ // be the indent alone (which has no non-space and used to give -1, so a
2774
+ // shallower line never ended the extent) or everything through the `[`.
2775
+ const depth = text.slice(bullet.index).search(/\S/);
2776
+ const start = text.indexOf('\n', bullet.index) + 1;
2777
+ let end = start;
2778
+ while (start > 0 && end < text.length) {
2779
+ const next = text.indexOf('\n', end);
2780
+ const line = text.slice(end, next === -1 ? text.length : next);
2781
+ if (line.trim() === '' || line.search(/\S/) < depth || ANY_PHASE_BULLET.test(line))
2782
+ break;
2783
+ end = next === -1 ? text.length : next + 1;
2784
+ }
2785
+ return { start, end };
2786
+ }
2225
2787
  /**
2226
2788
  * #4731 — extract a bold-labeled phase field (Goal, Requirements, ...) reading
2227
2789
  * past hard-wrapped continuation lines. The roadmapper soft-wraps long fields
@@ -2286,22 +2848,32 @@ function extractPhaseFieldMultiline(section, label) {
2286
2848
  return [firstLine, ...contLines].join(' ').trim() || null;
2287
2849
  }
2288
2850
  module.exports = {
2851
+ activeScope,
2852
+ activeScopeStart,
2289
2853
  stripShippedMilestones,
2290
2854
  extractCurrentMilestone,
2291
2855
  extractCurrentMilestoneScoped,
2292
2856
  isMilestoneShippedInRoadmap,
2293
2857
  isMilestoneBoundedInRoadmap,
2294
- replaceInCurrentMilestone,
2858
+ maskArchivedDetails,
2295
2859
  getRoadmapPhaseInternal,
2296
2860
  getMilestoneInfo,
2297
2861
  classifyMilestoneScalar,
2862
+ readStateMilestoneScalar,
2298
2863
  getMilestonePhaseFilter,
2299
2864
  currentMilestoneRawRanges,
2300
2865
  withPhaseSection,
2301
2866
  editOutsideShippedBlocks,
2867
+ editCurrentMilestone,
2868
+ readCurrentMilestone,
2869
+ phaseChecklistBullet,
2870
+ checklistBulletExtent,
2302
2871
  computeMilestoneSectionEnd,
2303
2872
  locateMilestoneHeadings,
2304
2873
  listMilestoneHeadings,
2874
+ // #4304: historical mutation guards consume the window locator's
2875
+ // milestone-vs-phase decision before applying CLOSED markers.
2876
+ isRecognizedMilestoneHeading,
2305
2877
  selectMilestoneHeading,
2306
2878
  classifyMilestoneWindow,
2307
2879
  // #3184: the sole "give me this version's window" composition — see its
@@ -2313,9 +2885,6 @@ module.exports = {
2313
2885
  hasMilestoneSectioning,
2314
2886
  // #3642: the >=1 sibling buildStateFrontmatter's unbounded branch consumes.
2315
2887
  hasAnyMilestoneSection,
2316
- // #1956: sole owner of the #2012 decoy-avoidance scope for the
2317
- // `drift-guard phase-status` CLI seam.
2318
- findRoadmapProgressTable,
2319
2888
  // #3262 (write-time milestone-scope guard): the window phase-id scan owner
2320
2889
  // (consumed by getMilestonePhaseFilter above and the roadmap milestone-scope
2321
2890
  // CLI probe) and the free-text predicate the phase add/add-batch/insert
@@ -2329,6 +2898,23 @@ module.exports = {
2329
2898
  // owner (and its convention gate) instead of a private inline copy.
2330
2899
  extractPhaseFieldMultiline,
2331
2900
  hasPhaseEntries,
2901
+ isPhaseEntryHeading,
2902
+ // #4304: exported so `phase.cts`'s milestone-marker
2903
+ // enumeration (bracketRecognizedMilestoneMarkers) can recognize a
2904
+ // version-less bracket milestone heading through the SAME grammar the
2905
+ // window locator (bracketAwareMilestoneSection) already uses to decide
2906
+ // section boundaries, instead of a narrower, independently-typed copy.
2907
+ isBracketMilestoneBoundary,
2908
+ bracketFallbackHeadingMatches,
2909
+ // #4304: exported so `phase.cts`'s pre-mutation window guard
2910
+ // (bracketOwnedLineOutsideActiveWindow) can recognize a CLOSED/shipped
2911
+ // milestone heading through the SAME predicate `currentMilestoneRawRanges`
2912
+ // itself already uses to skip a closed heading when selecting the active
2913
+ // one, instead of a re-typed copy of MILESTONE_CLOSED_MARKER_PATTERN.
2914
+ isClosedMilestoneHeading,
2915
+ // #4304: destructive ROADMAP writers classify details blocks through
2916
+ // the same closed-summary rule as stripClosedMilestoneDetails.
2917
+ isClosedMilestoneDetails,
2332
2918
  // #4144 round 5 Blocker 1: the readers' own phase-heading grammar, so the
2333
2919
  // bracket roadmap migrator's "phase-like but unparsed" refusal
2334
2920
  // (src/roadmap-upgrade.cts) can gate on it instead of a private, looser