@opengsd/gsd-core 1.20.1 → 1.21.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 (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -25,6 +25,7 @@ const node_crypto_1 = require("node:crypto");
25
25
  const node_fs_1 = __importDefault(require("node:fs"));
26
26
  const node_path_1 = __importDefault(require("node:path"));
27
27
  const pattern_cjs_1 = require("./pattern.cjs");
28
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
28
29
  const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
29
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
30
31
  const phaseIdModule = require("./phase-id.cjs");
@@ -60,7 +61,7 @@ const { rawFrontmatterMapping } = frontmatterMod;
60
61
  // eslint-disable-next-line @typescript-eslint/no-require-imports
61
62
  const unusableInputMod = require("./unusable-input.cjs");
62
63
  const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
63
- const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
64
+ const markdown_sectionizer_cjs_2 = require("./markdown-sectionizer.cjs");
64
65
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
65
66
  const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
66
67
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -73,10 +74,93 @@ const { SCOPE } = planningScopeMod;
73
74
  * (`extractCurrentMilestone`, `currentMilestoneRawRanges`,
74
75
  * `isMilestoneShippedInRoadmap`) previously kept byte-identical copies.
75
76
  */
76
- const MILESTONE_CLOSED_MARKER_PATTERN = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
77
+ const CLOSED_STATUS_WORD_SOURCE = '\\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\\b';
78
+ const CLOSED_STATUS_MARK_SOURCE = '\u{2705}|\u{1f5c4}';
79
+ // Same source, same flags as the pre-#5726 literal: no `u`, so `i` keeps ASCII-only folding.
80
+ const MILESTONE_CLOSED_MARKER_PATTERN = new RegExp(`${CLOSED_STATUS_WORD_SOURCE}|${CLOSED_STATUS_MARK_SOURCE}`, 'i');
77
81
  const MILESTONE_ACTIVE_MARKER_PATTERN = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
82
+ const CLOSED_STATUS_WORD_RE = new RegExp(CLOSED_STATUS_WORD_SOURCE, 'i');
83
+ const CLOSED_STATUS_MARK_RE = new RegExp(CLOSED_STATUS_MARK_SOURCE, 'u');
84
+ /**
85
+ * THE closed-milestone predicate (#5726, ADR-3180): selection, masking, the
86
+ * archive strip and the shipped refusal all call this one function. It is the
87
+ * pre-#5726 rule, a closed marker and no active marker, with ONE exception: a
88
+ * check mark that appears only inside a parenthetical naming another version
89
+ * (`Next (after v2.0 prep` + check mark + `)`) does not close the heading. A
90
+ * status word counts anywhere, including inside a version parenthetical
91
+ * (`(shipped, replaces v0.9)`).
92
+ */
78
93
  function isClosedMilestoneHeading(headingText) {
79
- return MILESTONE_CLOSED_MARKER_PATTERN.test(headingText) && !MILESTONE_ACTIVE_MARKER_PATTERN.test(headingText);
94
+ if (!MILESTONE_CLOSED_MARKER_PATTERN.test(headingText) || MILESTONE_ACTIVE_MARKER_PATTERN.test(headingText))
95
+ return false;
96
+ return CLOSED_STATUS_WORD_RE.test(headingText) || hasClosedStatusMark(headingText);
97
+ }
98
+ /**
99
+ * Does a check mark close the heading? One linear pass splits the heading into
100
+ * its top-level text and its top-level parentheticals. A parenthetical is exempt
101
+ * only when the heading's own version (text before the first parenthetical)
102
+ * exists and every version the parenthetical names is unrelated to it; versions
103
+ * are normalized by one helper (case-insensitive, dotted segments, suffix dropped,
104
+ * a dotted prefix counts as the same). An unbalanced or nested parenthesis, a
105
+ * missing own version or a version after the group disables the exemption, so such
106
+ * a heading reads exactly as before #5726.
107
+ */
108
+ function hasClosedStatusMark(headingText) {
109
+ let depth = 0;
110
+ let start = 0;
111
+ let outside = '';
112
+ let ownText = '';
113
+ const groups = [];
114
+ for (let i = 0; i < headingText.length; i++) {
115
+ const ch = headingText[i];
116
+ if (ch === '(') {
117
+ depth += 1;
118
+ if (depth > 1)
119
+ return CLOSED_STATUS_MARK_RE.test(headingText);
120
+ if (groups.length === 0)
121
+ ownText = outside;
122
+ start = i + 1;
123
+ }
124
+ else if (ch === ')') {
125
+ depth -= 1;
126
+ if (depth < 0)
127
+ return CLOSED_STATUS_MARK_RE.test(headingText);
128
+ if (depth === 0)
129
+ groups.push(headingText.slice(start, i));
130
+ }
131
+ else if (depth === 0) {
132
+ outside += ch;
133
+ }
134
+ }
135
+ if (depth !== 0)
136
+ return CLOSED_STATUS_MARK_RE.test(headingText);
137
+ if (CLOSED_STATUS_MARK_RE.test(outside))
138
+ return true;
139
+ const own = versionSegments(ownText);
140
+ return groups.some((group) => {
141
+ if (!CLOSED_STATUS_MARK_RE.test(group))
142
+ return false;
143
+ const named = versionsIn(group);
144
+ // Fail closed: exempt only when the heading's own version precedes the group, the
145
+ // group names a version, and every version it names is unrelated to the own one.
146
+ return own === null || named.length === 0 || named.some((v) => versionsRelated(own, v));
147
+ });
148
+ }
149
+ const HEADING_VERSION_RE = /\bv\d+(?:\.\d+)*/gi;
150
+ /** `v` + digits + dotted segments, lowercased as numbers; any suffix (`-beta`, `.rc`) is dropped. */
151
+ function versionsIn(text) {
152
+ return (text.match(HEADING_VERSION_RE) ?? []).map((v) => v.slice(1).split('.').map(Number));
153
+ }
154
+ function versionSegments(text) {
155
+ return versionsIn(text)[0] ?? null;
156
+ }
157
+ /** Equal, or one a dotted-segment prefix of the other (`v2` / `v2.0` / `v2.0.1`). */
158
+ function versionsRelated(a, b) {
159
+ const n = Math.min(a.length, b.length);
160
+ for (let i = 0; i < n; i++)
161
+ if (a[i] !== b[i])
162
+ return false;
163
+ return true;
80
164
  }
81
165
  /**
82
166
  * Is this `<details>` block a CLOSED milestone? One test for the read-side strip
@@ -97,7 +181,7 @@ function isClosedMilestoneBlock(block) {
97
181
  * Strip shipped milestone content wrapped in <details> blocks.
98
182
  */
99
183
  function stripShippedMilestones(content) {
100
- return (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content, 'details');
184
+ return (0, markdown_sectionizer_cjs_2.stripTaggedBlocks)(content, 'details');
101
185
  }
102
186
  /**
103
187
  * #5543: hide every CLOSED-milestone `<details>` block (`<details>` opener, a
@@ -220,6 +304,132 @@ function isMilestoneShippedInRoadmap(content, version) {
220
304
  }
221
305
  return false;
222
306
  }
307
+ /**
308
+ * #5726: `content` with every fenced code block and HTML comment blanked to
309
+ * spaces, so offsets and line structure are unchanged but no heading inside a
310
+ * fence or comment can be located. `\n` stays at its index; every other
311
+ * character (`\r` included) becomes a space; no `<!--` appears that the input
312
+ * did not have at that index.
313
+ *
314
+ * ONE linear scan in document order; the construct that starts first wins:
315
+ *
316
+ * - At a line start, a fence opener (the `matchFenceDelimiter` grammar: 0-3 spaces
317
+ * of indent, 3+ backticks or tildes, a backtick opener's info string holds no
318
+ * backtick) blanks through its matching closer line (same character, at least
319
+ * the opener's length, nothing but whitespace after), or to EOF when none
320
+ * exists. A `<!--` inside a fence opens nothing.
321
+ * - Otherwise the next `<!--` at `p` opens a comment, and its `-->` is searched
322
+ * from `p + 2`, so `<!-->` and `<!--->` are complete comments (CommonMark
323
+ * 0.31). With a closer, `p .. closer + 3` is blanked and may span lines; a
324
+ * fence line inside it is part of the comment.
325
+ * - With no closer anywhere after it: if the `<!--` opens its line (0-3 spaces
326
+ * before it, CommonMark HTML block type 2) everything to EOF is blanked; if
327
+ * it is mid-line nothing is hidden and the scan resumes after `p + 4`.
328
+ *
329
+ * Each of the next `<!--`, `-->` and `\n` is searched through a cached finder
330
+ * (the scan position only moves forward, so a hit at or past the position is
331
+ * still the next hit, and a miss stays a miss), keeping the whole pass linear.
332
+ */
333
+ function blankFencesAndComments(content) {
334
+ const n = content.length;
335
+ const finder = (needle) => {
336
+ let hit = -2;
337
+ return (from) => {
338
+ if (hit === -1)
339
+ return -1;
340
+ if (hit < from)
341
+ hit = content.indexOf(needle, from);
342
+ return hit;
343
+ };
344
+ };
345
+ const nextOpen = finder('<!--');
346
+ const nextClose = finder('-->');
347
+ const nextNewline = finder('\n');
348
+ const fenceOn = (from, to) => (0, markdown_sectionizer_cjs_1.matchFenceDelimiter)(content.slice(from, to));
349
+ const lineEnd = (from) => {
350
+ const nl = nextNewline(from);
351
+ return nl === -1 ? n : nl;
352
+ };
353
+ const pieces = [];
354
+ let at = 0;
355
+ const hide = (from, to) => {
356
+ pieces.push(content.slice(at, from), content.slice(from, to).replace(/[^\n]/g, ' '));
357
+ at = to;
358
+ };
359
+ let pos = 0;
360
+ let lineStart = 0;
361
+ let atLineStart = true;
362
+ while (pos < n) {
363
+ const le = lineEnd(pos);
364
+ if (atLineStart) {
365
+ const opener = fenceOn(pos, le);
366
+ if (opener !== null && opener.canOpen) {
367
+ let closeEnd = n;
368
+ for (let ls = le + 1; ls < n;) {
369
+ const e = lineEnd(ls);
370
+ const m = fenceOn(ls, e);
371
+ if (m !== null && m.closes(opener)) {
372
+ closeEnd = e;
373
+ break;
374
+ }
375
+ ls = e + 1;
376
+ }
377
+ hide(pos, closeEnd);
378
+ pos = closeEnd + 1;
379
+ lineStart = pos;
380
+ continue;
381
+ }
382
+ }
383
+ const p = nextOpen(pos);
384
+ if (p === -1 || p >= le) {
385
+ pos = le + 1;
386
+ lineStart = pos;
387
+ atLineStart = true;
388
+ continue;
389
+ }
390
+ const close = nextClose(p + 2);
391
+ if (close !== -1) {
392
+ const end = close + 3;
393
+ hide(p, end);
394
+ if (le < end)
395
+ lineStart = content.lastIndexOf('\n', end - 1) + 1;
396
+ pos = end;
397
+ atLineStart = false;
398
+ continue;
399
+ }
400
+ if (p - lineStart <= 3 && /^ *$/.test(content.slice(lineStart, p))) {
401
+ hide(p, n);
402
+ return pieces.join('');
403
+ }
404
+ pos = p + 4;
405
+ atLineStart = false;
406
+ }
407
+ pieces.push(content.slice(at));
408
+ return pieces.join('');
409
+ }
410
+ /**
411
+ * #5726: is the milestone window `extractCurrentMilestoneScoped` selected for
412
+ * `version` a SHIPPED one? Decided on the heading the owner selects
413
+ * (`selectActiveMilestoneHeading`: the first non-closed match, else the first),
414
+ * located in fence- and comment-blanked content so an example inside a fence or
415
+ * comment cannot stand in for the real heading.
416
+ */
417
+ function selectedMilestoneHeadingShipped(content, version, phaseIdConvention) {
418
+ const visible = blankFencesAndComments(content);
419
+ let matches = locateMilestoneHeadings(visible, version);
420
+ if (matches.length === 0)
421
+ matches = bracketFallbackHeadingMatches(visible, version, phaseIdConvention);
422
+ if (matches.length === 0)
423
+ return false;
424
+ return isClosedMilestoneHeading(selectActiveMilestoneHeading(visible, version, matches)[1]);
425
+ }
426
+ /**
427
+ * #5726: is the `<summary>` the archive-span window was cut from a shipped one?
428
+ * Read from fence- and comment-blanked content, tags removed.
429
+ */
430
+ function archiveSummaryShipped(summaryInner) {
431
+ return isClosedMilestoneHeading(summaryInner.replace(/<[^>]{0,200}>/g, ''));
432
+ }
223
433
  // #2761 B1: matches a bracket MILESTONE heading's intro (`[GSD.02]`) at the
224
434
  // START of a heading's text, capturing the bracket id in group 1. Shared by
225
435
  // isBracketMilestoneBoundary below — the ONE recognizer for "is this heading
@@ -357,7 +567,7 @@ function computeMilestoneSectionEnd(content, headingText, headingStart, addition
357
567
  const afterHeading = headingStart + headingText.length;
358
568
  // Use tokenizeHeadings (fence-aware, offsets into original content) to find
359
569
  // the next stop boundary without re-implementing fence detection. T4 seam migration.
360
- const headings = headingTokens ?? (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
570
+ const headings = headingTokens ?? (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(content);
361
571
  for (const h of headings) {
362
572
  if (h.offset <= headingStart)
363
573
  continue;
@@ -516,6 +726,25 @@ function isMilestoneBoundedInRoadmap(content, version) {
516
726
  function hasVersionedMilestones(content) {
517
727
  return /^#{1,3}\s+.*v\d+\.\d+/mi.test(content);
518
728
  }
729
+ /**
730
+ * #5726: does this ROADMAP carry a RECOGNIZED versioned milestone heading — a
731
+ * non-Phase level 1-3 heading with a version token, outside fenced code and
732
+ * HTML comments? Stricter than `hasVersionedMilestones` (a raw line regex that
733
+ * a Phase title such as `### Phase 1: Migrate to v2.0 API`, a fenced example or
734
+ * a commented-out heading all satisfy), so it is NOT a replacement: that
735
+ * predicate feeds `classifyMilestoneWindow`'s rows 3/4 and its behavior stays
736
+ * as is. This one gates only the write refusal for an unresolved STATE
737
+ * milestone, which must not fire on a roadmap that has no real milestone
738
+ * heading at all. Fences and comments are blanked by `blankFencesAndComments`,
739
+ * and `tokenizeHeadings` then reads the headings;
740
+ * `isRecognizedMilestoneHeading` (Phase exclusion, level bound) and
741
+ * `listMilestoneHeadings` (the version-bearing grammar) judge them.
742
+ */
743
+ function hasRecognizedVersionedMilestoneHeading(content) {
744
+ const visible = blankFencesAndComments(content);
745
+ return (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(visible).some((heading) => isRecognizedMilestoneHeading(heading.text, heading.level)
746
+ && listMilestoneHeadings(`${'#'.repeat(heading.level)} ${heading.text}`).length > 0);
747
+ }
519
748
  // This file's milestone-heading vocabulary: a version token (`v1.2`-style),
520
749
  // a ✅/🚧/📋 status marker, or the word "Milestone". Tested against a
521
750
  // non-Phase heading's own text by `hasMilestoneSectioning` below — this
@@ -610,7 +839,7 @@ const MILESTONE_HEADING_SIGNAL_PATTERN = /v\d+\.\d+|✅|📋|🚧|\bMilestone\b/
610
839
  function countMilestoneHeadings(content) {
611
840
  const isPhaseHeading = (text) => /^Phase\s+\S/i.test(text);
612
841
  let milestoneHeadingCount = 0;
613
- for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)) {
842
+ for (const heading of (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(content)) {
614
843
  if (heading.level < 1 || heading.level > 3)
615
844
  continue;
616
845
  if (isPhaseHeading(heading.text))
@@ -690,7 +919,7 @@ function sliceMilestoneWindow(content, version) {
690
919
  */
691
920
  function milestoneSections(content) {
692
921
  const sections = [];
693
- const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
922
+ const headings = (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(content);
694
923
  for (const heading of headings) {
695
924
  if (heading.level < 1 || heading.level > 3)
696
925
  continue;
@@ -797,7 +1026,7 @@ function hasPhaseEntries(markdown, phaseIdConvention) {
797
1026
  // #3641: the widened grammar engages ONLY when the resolved convention is
798
1027
  // 'bracket' — a project that has not opted in runs the legacy pattern
799
1028
  // alone, byte-identically.
800
- for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(markdown)) {
1029
+ for (const h of (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(markdown)) {
801
1030
  if (h.level < 2 || h.level > 4)
802
1031
  continue;
803
1032
  if (isPhaseEntryHeading(h.text, phaseIdConvention))
@@ -808,7 +1037,7 @@ function hasPhaseEntries(markdown, phaseIdConvention) {
808
1037
  // doc showing the convention) counts as a real phase entry. Strip fences
809
1038
  // through the canonical seam before testing, matching tokenizeHeadings'
810
1039
  // fence-awareness above.
811
- if (BULLET_PHASE_LINE_PATTERN.test((0, markdown_sectionizer_cjs_1.stripFencedCode)(markdown).text))
1040
+ if (BULLET_PHASE_LINE_PATTERN.test((0, markdown_sectionizer_cjs_2.stripFencedCode)(markdown).text))
812
1041
  return true;
813
1042
  // #3577: a markdown-table phase listing also declares phases.
814
1043
  return collectTablePhaseRows(markdown).length > 0;
@@ -827,7 +1056,7 @@ const PHASE_LISTING_HEADER_RE = /^\|?\s*phase(?:\s*(?:#|no\.?|number))?\s*\|/i;
827
1056
  const PHASE_NAME_HEADER_RE = /^(?:phase\s+)?name$/i;
828
1057
  const TABLE_PHASE_ID_RE = /^[A-Za-z]?\d[\w.-]*$/;
829
1058
  function collectTablePhaseRows(window) {
830
- const unfenced = (0, markdown_sectionizer_cjs_1.stripFencedCode)(window).text;
1059
+ const unfenced = (0, markdown_sectionizer_cjs_2.stripFencedCode)(window).text;
831
1060
  const lines = unfenced.split(/\r?\n/);
832
1061
  const rows = [];
833
1062
  for (let i = 0; i + 1 < lines.length; i++) {
@@ -873,7 +1102,7 @@ function collectTablePhaseRows(window) {
873
1102
  * synthesis it was never meant to coexist with.
874
1103
  */
875
1104
  function hasPhaseListingTableHeader(content) {
876
- const unfenced = (0, markdown_sectionizer_cjs_1.stripFencedCode)(content).text;
1105
+ const unfenced = (0, markdown_sectionizer_cjs_2.stripFencedCode)(content).text;
877
1106
  return unfenced.split(/\r?\n/).some((line) => PHASE_LISTING_HEADER_RE.test(line));
878
1107
  }
879
1108
  /**
@@ -906,7 +1135,7 @@ function scanMilestonePhaseIdSets(window, convention, context) {
906
1135
  const capturing = convention === 'bracket';
907
1136
  const bracketGroups = capturing ? 1 : 0;
908
1137
  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');
909
- for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(window)) {
1138
+ for (const h of (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(window)) {
910
1139
  if (h.level < 2 || h.level > 4)
911
1140
  continue;
912
1141
  const pm = phaseHeadingPattern.exec(h.text);
@@ -929,7 +1158,7 @@ function scanMilestonePhaseIdSets(window, convention, context) {
929
1158
  // collapsing to a zero-count pass-all filter.
930
1159
  let bm;
931
1160
  const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim');
932
- const unfenced = (0, markdown_sectionizer_cjs_1.stripFencedCode)(window).text;
1161
+ const unfenced = (0, markdown_sectionizer_cjs_2.stripFencedCode)(window).text;
933
1162
  while ((bm = scanner.exec(unfenced)) !== null) {
934
1163
  if (!/^999\b/.test(bm[1]))
935
1164
  ids.add(bm[1]);
@@ -999,7 +1228,7 @@ function scanMilestonePhaseIds(window, convention) {
999
1228
  */
1000
1229
  function findMilestoneScopeHeadingLines(text, convention) {
1001
1230
  const out = [];
1002
- for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(text)) {
1231
+ for (const h of (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(text)) {
1003
1232
  if (h.level > 3)
1004
1233
  continue;
1005
1234
  if (/^Phase\s+\S/i.test(h.text))
@@ -1090,7 +1319,7 @@ function bracketFallbackHeadingMatches(content, version, bracketScopeConvention)
1090
1319
  // For every survivor, rebuilding [fullLine, fullLine] plus `.index`
1091
1320
  // retains the match shape and first-match order expected downstream.
1092
1321
  const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
1093
- return (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)
1322
+ return (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(content)
1094
1323
  .filter((h) => h.level <= 3 && content[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text))
1095
1324
  .map((h) => {
1096
1325
  const lineEnd = content.indexOf('\n', h.offset);
@@ -1146,12 +1375,15 @@ function bracketAwareMilestoneSection(content, selected, sectionStart, bracketBo
1146
1375
  }
1147
1376
  /**
1148
1377
  * #5038: single owner for classifying a `milestone:` scalar as STATE.md spells
1149
- * it. The frontmatter parser (FAILSAFE_SCHEMA) and the raw-regex readers both
1150
- * see YAML null as text, so every reader classifies it here. Recognizes the
1151
- * YAML 1.2 core-schema null spellings (`null`, `Null`, `NULL`, `~`) with an
1152
- * optional trailing ` # comment` and optional surrounding quotes (the
1153
- * frontmatter parser has already stripped them, so the raw readers must agree);
1154
- * a blank value is absent, not null.
1378
+ * it. The frontmatter parser (FAILSAFE_SCHEMA) sees YAML null as text, so every
1379
+ * reader classifies it here. The input is an already DECODED scalar (both the
1380
+ * frontmatter path and the raw line read in `readStateMilestoneScalar` decode
1381
+ * through the same FAILSAFE loader), so a quoted `"null"` is indistinguishable
1382
+ * from a bare `null` after decoding and reads as explicit null. A decoded value
1383
+ * of `null`, `Null`, `NULL` or `~` is explicit null on both paths. A decoded
1384
+ * `null # x` (quoted on disk) is a literal version; an unquoted
1385
+ * `milestone: null # x` decodes to `null` and is an explicit null. A blank value
1386
+ * is absent, not null.
1155
1387
  */
1156
1388
  function classifyMilestoneScalar(raw) {
1157
1389
  if (typeof raw !== 'string')
@@ -1159,19 +1391,31 @@ function classifyMilestoneScalar(raw) {
1159
1391
  const trimmed = raw.trim();
1160
1392
  if (trimmed === '')
1161
1393
  return { explicitNull: false, version: null };
1162
- const bare = trimmed.replace(/\s+#.*$/, '').trim().replace(/^(['"])(.*)\1$/, '$2');
1163
- if (/^(?:null|Null|NULL|~)$/.test(bare))
1394
+ if (/^(?:null|Null|NULL|~)$/.test(trimmed))
1164
1395
  return { explicitNull: true, version: null };
1396
+ // The input is already a DECODED scalar (#4998): a quote or ` #` inside it is
1397
+ // literal content, so the version is the trimmed text.
1165
1398
  return { explicitNull: false, version: trimmed };
1166
1399
  }
1400
+ /**
1401
+ * #5726: do two spellings name the same milestone version? Case-insensitive, the
1402
+ * leading `v` optional (`1.0` / `v1.0`); a suffix is significant (`v1.0-closing`
1403
+ * is a different version from `v1.0`).
1404
+ */
1405
+ function sameMilestoneVersion(a, b) {
1406
+ const fold = (v) => v.trim().replace(/^v(?=\d)/i, '').toLowerCase();
1407
+ return fold(a) === fold(b);
1408
+ }
1167
1409
  /**
1168
1410
  * #4998: STATE.md's `milestone:` scalar as the frontmatter parser DECODES it.
1169
1411
  * The raw `^milestone:` line read captured the on-disk YAML text — quotes and
1170
1412
  * 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).
1413
+ * escape layer per `state.*` write. Falls back to a `^milestone:` line read
1414
+ * only when there is no parseable frontmatter, or its `milestone` is not text;
1415
+ * that fallback line is decoded through the same FAILSAFE loader as
1416
+ * frontmatter (#5726). Frontmatter that parses without a `milestone` key names
1417
+ * none: a `milestone:` line further down (a fenced example) is not one (#4998
1418
+ * review, Minor 5).
1175
1419
  */
1176
1420
  function readStateMilestoneScalar(stateRaw) {
1177
1421
  const fm = rawFrontmatterMapping(stateRaw);
@@ -1183,8 +1427,20 @@ function readStateMilestoneScalar(stateRaw) {
1183
1427
  // A non-string value keeps the pre-#4998 line read, which finds this
1184
1428
  // frontmatter line first.
1185
1429
  }
1186
- const m = stateRaw.match(/^milestone:\s*(.+)/m);
1187
- return m ? m[1] : null;
1430
+ // `[ \t]*(\S.*)` cannot cross a newline: an empty value must not borrow the next
1431
+ // line (the `---` closing the frontmatter), so it reads as absent.
1432
+ const m = stateRaw.match(/^milestone:[ \t]*(\S.*)/m);
1433
+ if (!m)
1434
+ return null;
1435
+ // #5726: this branch holds on-disk YAML text. Decode it with the SAME guarded
1436
+ // FAILSAFE loader the frontmatter path uses (a one-key synthetic frontmatter
1437
+ // block), so quotes, YAML escapes and a trailing ` # comment` decode identically
1438
+ // on both paths by construction. An undecodable or non-string result keeps the
1439
+ // trimmed raw text.
1440
+ const text = m[1].trim();
1441
+ const decoded = rawFrontmatterMapping(`---\nmilestone: ${text}\n---\n`);
1442
+ const value = decoded ? decoded['milestone'] : undefined;
1443
+ return typeof value === 'string' ? value : text;
1188
1444
  }
1189
1445
  /**
1190
1446
  * Extract the current milestone section from ROADMAP.md by positive lookup,
@@ -1221,6 +1477,9 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1221
1477
  }
1222
1478
  let version = null;
1223
1479
  let stateAssertsNoMilestone = false;
1480
+ // #5726: set only when the version came from STATE.md's `milestone:` field,
1481
+ // not from the in-progress bullet guess below.
1482
+ let versionFromState = false;
1224
1483
  try {
1225
1484
  const statePath = node_path_1.default.join(planningDir(cwd, ws), 'STATE.md');
1226
1485
  const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
@@ -1229,6 +1488,7 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1229
1488
  if (milestoneScalar !== null) {
1230
1489
  const classified = classifyMilestoneScalar(milestoneScalar);
1231
1490
  version = classified.version;
1491
+ versionFromState = classified.version !== null;
1232
1492
  stateAssertsNoMilestone = classified.explicitNull;
1233
1493
  }
1234
1494
  }
@@ -1247,6 +1507,11 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1247
1507
  // twin copy in `getMilestonePhaseFilter`, the intra-owner-file duplicate
1248
1508
  // review caught since the drift guard exempts this file by construction).
1249
1509
  const versionedMilestonesPresent = hasVersionedMilestones(content);
1510
+ // #5726: STATE.md's milestone is one the ROADMAP itself marks shipped. Decided
1511
+ // below, from the heading (or archive `<summary>`) the window was actually cut
1512
+ // from, so a line that merely mentions the version cannot read as shipped.
1513
+ // Reported by the window's one owner.
1514
+ const shippedOf = (shipped) => shipped && versionFromState && version !== null ? { shippedMilestone: version } : {};
1250
1515
  if (!version) {
1251
1516
  const value = stripShippedMilestones(content);
1252
1517
  return {
@@ -1296,7 +1561,7 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1296
1561
  const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
1297
1562
  const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
1298
1563
  const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index : detailsOpenIdx;
1299
- const preamble = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content.slice(0, preambleCutoff), 'details')
1564
+ const preamble = (0, markdown_sectionizer_cjs_2.stripTaggedBlocks)(content.slice(0, preambleCutoff), 'details')
1300
1565
  // #5007 (Phase 6 / ADR-4910 §8): shared owner, see
1301
1566
  // PHASE_HEADING_BLOCK_STRIP_RE above.
1302
1567
  .replace(PHASE_HEADING_BLOCK_STRIP_RE, '')
@@ -1312,21 +1577,30 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1312
1577
  windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention),
1313
1578
  documentHasPhaseEntries,
1314
1579
  }),
1580
+ ...shippedOf(archiveSummaryShipped(blankFencesAndComments(content).match(summaryPattern)?.[1] ?? '')),
1315
1581
  };
1316
1582
  }
1317
1583
  }
1318
1584
  const value = stripShippedMilestones(content);
1319
- return {
1320
- value,
1321
- scope: classifyMilestoneWindow({
1322
- readable: true,
1323
- versionResolved,
1324
- hasVersionedMilestones: versionedMilestonesPresent,
1325
- headingFound: false,
1326
- windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention),
1327
- documentHasPhaseEntries,
1328
- }),
1329
- };
1585
+ const scope = classifyMilestoneWindow({
1586
+ readable: true,
1587
+ versionResolved,
1588
+ hasVersionedMilestones: versionedMilestonesPresent,
1589
+ headingFound: false,
1590
+ windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention),
1591
+ documentHasPhaseEntries,
1592
+ });
1593
+ // #5726 (row 5): STATE.md names a milestone and the ROADMAP, which carries
1594
+ // versioned milestone headings, has none for it. `value` is then the whole
1595
+ // unshipped roadmap, so a write verb must not allocate against it. Reported
1596
+ // only here, by the window's one owner; a free-form legacy ROADMAP (no
1597
+ // versioned headings) and a bullet-guessed version stay a plain UNSCOPED.
1598
+ // The gate counts only RECOGNIZED milestone headings, so a version token in
1599
+ // a Phase title, a fence or an HTML comment does not read as a milestone.
1600
+ if (scope === SCOPE.UNSCOPED && versionFromState && hasRecognizedVersionedMilestoneHeading(content)) {
1601
+ return { value, scope, unresolvedMilestone: version };
1602
+ }
1603
+ return { value, scope };
1330
1604
  }
1331
1605
  const allMatches = headingMatches;
1332
1606
  const isClosed = isClosedMilestoneHeading;
@@ -1340,7 +1614,7 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1340
1614
  // #2761 B3: tokenize once for both the centralized section-end owner and
1341
1615
  // the bracket preamble scan below. This keeps both boundary decisions
1342
1616
  // fence-aware without restoring the local section walker retired by #3184.
1343
- const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
1617
+ const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(content);
1344
1618
  const { sectionEnd, selectedBracketId, bracketBoundary } = bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings);
1345
1619
  const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
1346
1620
  let earliestMilestoneIndex;
@@ -1528,7 +1802,7 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1528
1802
  // forcing the two sites onto an identical terminal token that would be a
1529
1803
  // behavior change here.
1530
1804
  const currentSectionHasPhaseDetails = new RegExp(`^#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY)}\\S`, 'im').test(currentSection);
1531
- const preambleBase = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(beforeMilestones, 'details');
1805
+ const preambleBase = (0, markdown_sectionizer_cjs_2.stripTaggedBlocks)(beforeMilestones, 'details');
1532
1806
  // #3235: the conditional wraps the REPLACE, not the pattern. This used to select between the
1533
1807
  // strip regex and a `/$/` sentinel, which made the do-not-strip branch an identity replacement
1534
1808
  // (CodeQL js/identity-replacement, alert 53) -- correct, but it left both branches sharing one
@@ -1553,6 +1827,7 @@ function extractCurrentMilestoneScoped(content, cwd, ws, phaseIdConvention) {
1553
1827
  windowHasPhaseEntries: hasPhaseEntries(value, phaseIdConvention),
1554
1828
  documentHasPhaseEntries,
1555
1829
  }),
1830
+ ...shippedOf(selectedMilestoneHeadingShipped(content, version, bracketScopeConvention)),
1556
1831
  };
1557
1832
  }
1558
1833
  /**
@@ -1605,7 +1880,7 @@ function blankCodeAndComments(content) {
1605
1880
  // closes a block.
1606
1881
  const lines = content.split('\n');
1607
1882
  const blank = (t) => t.replace(/[^\r\n]/g, ' ');
1608
- for (const f of (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(lines)) {
1883
+ for (const f of (0, markdown_sectionizer_cjs_2.scanFencedBlocks)(lines)) {
1609
1884
  // An unclosed fence is bounded at the next heading, not EOF, so later archives stay visible.
1610
1885
  let last = f.closeLineIdx;
1611
1886
  if (last === -1) {
@@ -1622,7 +1897,7 @@ function blankCodeAndComments(content) {
1622
1897
  const joined = lines.join('\n');
1623
1898
  let codeBlanked = '';
1624
1899
  let at = 0;
1625
- for (const sp of (0, markdown_sectionizer_cjs_1.scanInlineCodeSpans)(joined)) {
1900
+ for (const sp of (0, markdown_sectionizer_cjs_2.scanInlineCodeSpans)(joined)) {
1626
1901
  codeBlanked += joined.slice(at, sp.start) + blank(joined.slice(sp.start, sp.end));
1627
1902
  at = sp.end;
1628
1903
  }
@@ -1636,7 +1911,7 @@ function plainClosedSections(content, overlapsActive, activeVersion) {
1636
1911
  // A closed milestone written as a plain heading section (no `<details>`) is
1637
1912
  // archive too. It ends at the next heading of the same or shallower level, so
1638
1913
  // a following `## Progress` table stays visible.
1639
- const headingTokens = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
1914
+ const headingTokens = (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(content);
1640
1915
  const spans = [];
1641
1916
  for (let i = 0; i < headingTokens.length; i++) {
1642
1917
  const h = headingTokens[i];
@@ -1891,7 +2166,7 @@ function withPhaseSection(content, phaseId, edit, levelBounded = false) {
1891
2166
  const cut = body.search(/^#{1,6}[ \t]+(?:\[[^\]\n]{1,200}\][ \t]*)?Phase\s+\S/m);
1892
2167
  return cut === -1 ? edit(body) : edit(body.slice(0, cut)) + body.slice(cut);
1893
2168
  };
1894
- return (0, markdown_sectionizer_cjs_1.withSection)(content, (h) => headingRe.test(h.text), ownBody, { levelBounded });
2169
+ return (0, markdown_sectionizer_cjs_2.withSection)(content, (h) => headingRe.test(h.text), ownBody, { levelBounded });
1895
2170
  }
1896
2171
  // ─── Roadmap phase lookup ─────────────────────────────────────────────────────
1897
2172
  // #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**`
@@ -1908,7 +2183,7 @@ function bulletPhaseLineFor(phaseNum, phaseSource) {
1908
2183
  function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
1909
2184
  // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
1910
2185
  const headingPattern = new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
1911
- const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
2186
+ const headings = (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(content);
1912
2187
  const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
1913
2188
  if (headingIndex === -1)
1914
2189
  return null;
@@ -1916,7 +2191,7 @@ function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
1916
2191
  const headerMatch = heading.text.match(headingPattern);
1917
2192
  if (!headerMatch)
1918
2193
  return null;
1919
- const phaseName = headerMatch[1].trim();
2194
+ const phaseName = (0, phase_entry_grammar_cjs_1.stripInsertedMarker)(headerMatch[1]);
1920
2195
  const nextHeading = headings.slice(headingIndex + 1).find((candidate) => candidate.level <= heading.level);
1921
2196
  const sectionEnd = nextHeading ? nextHeading.offset : content.length;
1922
2197
  const section = content.slice(heading.offset, sectionEnd).trim();
@@ -2642,7 +2917,7 @@ function currentMilestoneRawRanges(content, cwd, phaseIdConvention) {
2642
2917
  const selected = selectActiveMilestoneHeading(content, version, headingMatches);
2643
2918
  const sectionStart = selected.index ?? 0;
2644
2919
  const bracketBoundaryActive = bracketScopeConvention === 'bracket';
2645
- const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
2920
+ const currentMilestoneHeadings = (0, markdown_sectionizer_cjs_2.tokenizeHeadings)(content);
2646
2921
  const { sectionEnd, bracketBoundary } = bracketAwareMilestoneSection(content, selected, sectionStart, bracketBoundaryActive, currentMilestoneHeadings);
2647
2922
  const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
2648
2923
  const detailsVersionBoundary = selectedVersionToken
@@ -2885,6 +3160,7 @@ module.exports = {
2885
3160
  getMilestoneInfo,
2886
3161
  classifyMilestoneScalar,
2887
3162
  readStateMilestoneScalar,
3163
+ sameMilestoneVersion,
2888
3164
  getMilestonePhaseFilter,
2889
3165
  currentMilestoneRawRanges,
2890
3166
  withPhaseSection,
@@ -2944,6 +3220,8 @@ module.exports = {
2944
3220
  milestoneVersionAtOffset,
2945
3221
  // #5679: same-length copy with fenced blocks, code spans and HTML comments blanked.
2946
3222
  blankCodeAndComments,
3223
+ // #5726: fences and HTML comments blanked (same length, newlines kept).
3224
+ blankFencesAndComments,
2947
3225
  // #4144 round 5 Blocker 1: the readers' own phase-heading grammar, so the
2948
3226
  // bracket roadmap migrator's "phase-like but unparsed" refusal
2949
3227
  // (src/roadmap-upgrade.cts) can gate on it instead of a private, looser